Skip to content

Commit a62f52a

Browse files
authored
ci: preserve pending fork docs preview deployments
1 parent 74487a2 commit a62f52a

3 files changed

Lines changed: 92 additions & 4 deletions

File tree

.github/scripts/__tests__/docs-fork-preview.mjs

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,13 @@ function fixture() {
9797
return { data: { permission: state.permission } };
9898
},
9999
},
100-
pulls: { list() {}, get: async () => ({ data: pr }) },
100+
pulls: {
101+
list() {},
102+
get: async (params) => {
103+
state.requests.push(params);
104+
return { data: structuredClone(pr) };
105+
},
106+
},
101107
actions: {
102108
listWorkflowRunArtifacts() {},
103109
getWorkflowRun: async (params) => {
@@ -118,7 +124,7 @@ function fixture() {
118124
paginate: async (method, params) => {
119125
state.requests.push(params);
120126
if (method === github.rest.pulls.list) {
121-
return state.pulls;
127+
return structuredClone(state.pulls);
122128
}
123129
if (method === github.rest.actions.listWorkflowRunArtifacts) {
124130
return state.artifacts;
@@ -297,6 +303,17 @@ await test('isolates build concurrency by PR and SHA, including delayed old runs
297303
assert.equal(group(2684, 'b'.repeat(40)), newer);
298304
});
299305

306+
await test('queues pending deployments without replacing them when an old run arrives late', async () => {
307+
const yaml = await readFile(
308+
new URL('../../workflows/deploy-docs-fork-preview.yml', import.meta.url),
309+
'utf8',
310+
);
311+
assert.match(
312+
yaml,
313+
/concurrency:\n\s+group: deploy-docs-fork-preview-\$\{\{ needs\.authorize\.outputs\.pr \}\}\n\s+queue: max\n\s+cancel-in-progress: false/,
314+
);
315+
});
316+
300317
await test('authorizes a fork with an empty workflow_run PR list and pins its artifact', async () => {
301318
const f = fixture();
302319
await authorizePreview(f);
@@ -312,6 +329,10 @@ await test('authorizes a fork with an empty workflow_run PR list and pins its ar
312329
{ owner: 'voidzero-dev', repo: 'vite-plus', run_id: 123 },
313330
{ owner: 'voidzero-dev', repo: 'vite-plus', ref: 'a'.repeat(40), per_page: 100 },
314331
{ owner: 'voidzero-dev', repo: 'vite-plus', run_id: 987 },
332+
{ owner: 'voidzero-dev', repo: 'vite-plus', pull_number: 2684 },
333+
{ owner: 'voidzero-dev', repo: 'vite-plus', username: 'maintainer' },
334+
{ owner: 'voidzero-dev', repo: 'vite-plus', ref: 'a'.repeat(40), per_page: 100 },
335+
{ owner: 'voidzero-dev', repo: 'vite-plus', run_id: 987 },
315336
]);
316337
assert.deepEqual(f.state.outputs, {
317338
pr: 2684,
@@ -452,6 +473,66 @@ for (const pending of ['missing status', 'running workflow']) {
452473
});
453474
}
454475

476+
await test('does not enqueue an older authorization that finishes after a newer preview', async () => {
477+
const older = fixture();
478+
older.approval.status = 'in_progress';
479+
older.approval.conclusion = null;
480+
const newerOutputs = {};
481+
const newer = {
482+
...older,
483+
context: structuredClone(older.context),
484+
core: {
485+
info() {},
486+
setOutput: (key, value) => (newerOutputs[key] = value),
487+
},
488+
};
489+
newer.context.payload.workflow_run.id = 124;
490+
newer.context.payload.workflow_run.head_sha = 'b'.repeat(40);
491+
older.sleep = async (ms) => {
492+
older.state.sleeps.push(ms);
493+
older.pr.head.sha = newer.context.payload.workflow_run.head_sha;
494+
grantApproval(older.state, older.pr.head.sha, older.pr.number, 988);
495+
await authorizePreview(newer);
496+
older.approval.status = 'completed';
497+
older.approval.conclusion = 'success';
498+
};
499+
500+
await authorizePreview(older);
501+
502+
assert.equal(newerOutputs.pr, 2684);
503+
assert.deepEqual(older.state.sleeps, [5000]);
504+
assert.deepEqual(older.state.outputs, {});
505+
assert.equal(await isCurrentPreview(newer, 2684), true);
506+
});
507+
508+
for (const { name, mutate } of [
509+
{ name: 'label removal', mutate: (f) => (f.pr.labels = []) },
510+
{ name: 'PR closure', mutate: (f) => (f.pr.state = 'closed') },
511+
{ name: 'a base change', mutate: (f) => (f.pr.base.ref = 'release') },
512+
{ name: 'requester permission removal', mutate: (f) => (f.state.permission = 'read') },
513+
]) {
514+
await test(`rechecks ${name} after approval polling and before queueing`, async () => {
515+
const f = fixture();
516+
f.approval.status = 'in_progress';
517+
f.approval.conclusion = null;
518+
f.sleep = async () => {
519+
grantApproval(f.state);
520+
mutate(f);
521+
};
522+
await authorizePreview(f);
523+
assert.deepEqual(f.state.outputs, {});
524+
});
525+
}
526+
527+
await test('fails closed when the PR recheck before queueing fails', async () => {
528+
const f = fixture();
529+
f.github.rest.pulls.get = async () => {
530+
throw new Error('GitHub PR lookup failed');
531+
};
532+
await assert.rejects(authorizePreview(f), /GitHub PR lookup failed/);
533+
assert.deepEqual(f.state.outputs, {});
534+
});
535+
455536
for (const api of ['statuses', 'workflow proof']) {
456537
await test(`fails closed when the ${api} lookup fails`, async () => {
457538
const f = fixture();

.github/scripts/docs-fork-preview.mjs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,12 @@ export async function authorizePreview({ github, context, core, sleep = setTimeo
209209
);
210210
return;
211211
}
212+
// Approval polling can outlive the PR state checked above. Do not send a
213+
// stale or revoked request to the deployment queue.
214+
if (!(await isCurrentPreview({ github, context }, candidates[0].number))) {
215+
core.info('The PR changed or preview permission was revoked; skipping the deployment queue.');
216+
return;
217+
}
212218
core.setOutput('pr', candidates[0].number);
213219
core.setOutput('artifact-id', matches[0].id);
214220
core.setOutput('preview-url', previewUrl(candidates[0].number));

.github/workflows/deploy-docs-fork-preview.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,11 @@ jobs:
6363
if: needs.authorize.outputs.pr != ''
6464
runs-on: ubuntu-latest
6565
timeout-minutes: 10
66-
# Serialize uploads for a PR. An older run rechecks the head after waiting,
67-
# so it cannot overwrite a preview that a newer run has already uploaded.
66+
# Serialize uploads without replacing pending jobs when an old run arrives
67+
# late. Recheck the head after waiting so stale jobs do not upload.
6868
concurrency:
6969
group: deploy-docs-fork-preview-${{ needs.authorize.outputs.pr }}
70+
queue: max
7071
cancel-in-progress: false
7172
environment:
7273
name: docs-preview

0 commit comments

Comments
 (0)