Three things kept the upstream sync PR (#589) from ever finishing a build: Scoped dispatches wiped the shared PR. A workflow_dispatch with `packages` regenerates only those packages from master, and pushing that to auto/sync-upstream replaced 38 pending updates with one. Scoped runs now push to their own auto/sync-{upstream,rebuilds}-<packages> branch and PR; scheduled runs keep the shared branch. build-approved stopped working after the first bot push. A GITHUB_TOKEN push creates pull_request runs held for approval but no pull_request_target run, so approve-pr.yml never saw it: its last run on the branch was the label itself (2026-09-25T19:26), and each of the next four syncs sat at action_required. The sync workflows now release the held runs for the commit they just pushed, from a separate job holding actions: write, and only for their own bot-authored, same-repo PR while build-approved is on it. Each approved push cancelled the in-flight build. Approving the 21:43 sync's build cancelled the label-triggered one still queued on strata and schist-bin. On auto/sync-* branches a new build now waits for the running one instead, then reuses its artifacts. The approval script no longer waits for a lone approved build to start before releasing tests, which a queued build would have turned into a timeout.
89 lines
4.5 KiB
JavaScript
89 lines
4.5 KiB
JavaScript
const BUILD = '.github/workflows/build-pr.yml';
|
|
const TESTS = '.github/workflows/test.yml';
|
|
|
|
// pullRequest/action/since default to the pull_request_target event. The
|
|
// sync workflows pass them explicitly: GitHub creates no pull_request_target
|
|
// run for a GITHUB_TOKEN push, so they release their own pushes' held runs.
|
|
module.exports = async function approve({ github, context, core, vouchStatus,
|
|
pullRequest = context.payload.pull_request, action = context.payload.action, since,
|
|
sleep = ms => new Promise(resolve => setTimeout(resolve, ms)), attempts = 36 }) {
|
|
// Missing/failed vouch lookups must not become approval. Denouncements
|
|
// remain absolute, just as they are in the package build gate.
|
|
if (!['unknown', 'bot', 'collaborator', 'vouched'].includes(vouchStatus)) {
|
|
throw new Error(`Cannot approve workflows: vouch status is ${vouchStatus || 'missing'}.`);
|
|
}
|
|
|
|
const expected = pullRequest;
|
|
const eventTime = Date.parse(since ?? expected.updated_at);
|
|
if (!Number.isFinite(eventTime)) throw new Error('Missing PR event timestamp.');
|
|
const approved = new Set();
|
|
let precedingBuild;
|
|
|
|
const stillApproved = async () => {
|
|
const { data: pr } = await github.rest.pulls.get({
|
|
...context.repo, pull_number: expected.number,
|
|
});
|
|
return pr.state === 'open' && pr.head.sha === expected.head.sha &&
|
|
pr.labels.some(label => label.name === 'build-approved');
|
|
};
|
|
|
|
// The label and PR-run events arrive independently. Wait for the build
|
|
// belonging to this event, rather than returning after approving an older
|
|
// run and leaving the new label-triggered run stuck behind GitHub's gate.
|
|
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
if (attempt) await sleep(5000);
|
|
if (!await stillApproved()) {
|
|
core.info('PR closed, head changed, or build-approved removed; stopping.');
|
|
return;
|
|
}
|
|
|
|
const all = await github.paginate(github.rest.actions.listWorkflowRunsForRepo, {
|
|
...context.repo, event: 'pull_request', head_sha: expected.head.sha, per_page: 100,
|
|
});
|
|
const runs = all.filter(run =>
|
|
run.event === 'pull_request' && run.head_sha === expected.head.sha &&
|
|
run.head_repository?.id === expected.head.repo.id && run.head_branch === expected.head.ref &&
|
|
[BUILD, TESTS].includes(run.path) &&
|
|
// Fork runs awaiting approval often have no pull_requests entries.
|
|
(!run.pull_requests?.length || run.pull_requests.some(pr => pr.number === expected.number))
|
|
).sort((a, b) => a.id - b.id);
|
|
|
|
const newestBuild = runs.findLast(run => run.path === BUILD);
|
|
if (!newestBuild || !(Date.parse(newestBuild.created_at) >= eventTime) ||
|
|
!runs.some(run => run.path === TESTS &&
|
|
(action === 'labeled' || Date.parse(run.created_at) >= eventTime))) continue;
|
|
|
|
if (precedingBuild) {
|
|
const { data: run } = await github.rest.actions.getWorkflowRun({
|
|
...context.repo, run_id: precedingBuild,
|
|
});
|
|
// Approve older builds first, and let them acquire concurrency before
|
|
// releasing a newer build. Otherwise an older queued run could start
|
|
// last and cancel the label-triggered build that carries approval.
|
|
if (!['in_progress', 'completed'].includes(run.status) || run.conclusion === 'action_required') continue;
|
|
precedingBuild = undefined;
|
|
}
|
|
|
|
const pending = runs.filter(run => run.conclusion === 'action_required' && !approved.has(run.id) &&
|
|
// If the newest build already runs (e.g. a maintainer approved it),
|
|
// don't resurrect an obsolete hold that could cancel that newer run.
|
|
(run.path !== BUILD || run.id === newestBuild.id || newestBuild.conclusion === 'action_required'));
|
|
if (!pending.length) return;
|
|
const run = pending[0];
|
|
// Recheck after the API reads, immediately before exercising write access.
|
|
if (!await stillApproved()) return;
|
|
await github.rest.actions.approveWorkflowRun({ ...context.repo, run_id: run.id });
|
|
approved.add(run.id);
|
|
core.info(`Approved ${run.path} run ${run.id} for PR #${expected.number}.`);
|
|
// Only a newer held build needs this one to take the concurrency slot
|
|
// first. A lone build may sit pending behind an in-flight build of an
|
|
// older commit (sync branches queue rather than cancel); waiting for it
|
|
// to start would time out before the tests run was released.
|
|
if (run.path === BUILD && pending.some(other => other.path === BUILD && other.id > run.id)) {
|
|
precedingBuild = run.id;
|
|
}
|
|
if (pending.length === 1) return;
|
|
}
|
|
throw new Error('Timed out waiting for PR workflows. Remove and reapply build-approved to retry.');
|
|
};
|