Make build-approved release pending PR workflows

This commit is contained in:
Ryan Hughes committed 2026-09-20 02:11:34 -04:00
1 parent cce11a6917
commit 3628915c5d
7 files changed
+405 -8

No files matched your search

+4 -1
View File
@@ -4,7 +4,10 @@
# its author is trusted: repository collaborators are trusted automatically
# and do not need listing; external contributors listed here are trusted
# too. Anyone else gets the plan only, until a maintainer either adds them
# here or applies the "build-approved" label to that one PR.
# here or applies the "build-approved" label to that one PR. The label also
# releases GitHub's approval hold for that PR's build and test workflows.
# It remains effective while attached, without vouching for the author's
# other PRs. An explicit denouncement cannot be overridden by the label.
#
# Syntax:
# github:username
+78
View File
@@ -0,0 +1,78 @@
const BUILD = '.github/workflows/build-pr.yml';
const TESTS = '.github/workflows/test.yml';
module.exports = async function approve({ github, context, core, vouchStatus,
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 = context.payload.pull_request;
const eventTime = Date.parse(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 &&
(context.payload.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}.`);
if (run.path === BUILD) precedingBuild = run.id;
if (pending.length === 1) return;
}
throw new Error('Timed out waiting for PR workflows. Remove and reapply build-approved to retry.');
};
+46
View File
@@ -0,0 +1,46 @@
name: Approve PR workflows
# A pull_request workflow cannot approve itself: GitHub can hold it before
# any job starts. This workflow only runs trusted default-branch code and
# releases the ordinary, unprivileged PR workflows after build approval.
on:
pull_request_target:
types: [opened, synchronize, reopened, labeled]
permissions:
contents: read
pull-requests: read
actions: write
concurrency:
group: approve-pr-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
approve:
# Match build-pr.yml's events, including other labels applied while this
# PR still carries build-approved: each labeled event creates a build.
if: contains(github.event.pull_request.labels.*.name, 'build-approved')
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
# Never check out the PR head or its merge ref with this write token.
- uses: actions/checkout@v4
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
- id: vouch
uses: mitchellh/vouch/action/check-user@f23dbb5e745334f97414ec70463ce7301071a661 # v1
with:
user: ${{ github.event.pull_request.user.login }}
allow-fail: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Approve this PR's pending build and test runs
uses: actions/github-script@v7
env:
VOUCH_STATUS: ${{ steps.vouch.outputs.status }}
with:
script: |
const approve = require('./.github/scripts/approve-pr-workflows.cjs');
await approve({ github, context, core, vouchStatus: process.env.VOUCH_STATUS });
+19 -7
View File
@@ -28,8 +28,7 @@ jobs:
# collaborators, anyone in .github/VOUCHED.td (read from the default
# branch, so a PR cannot vouch for itself), or a PR a maintainer has
# labelled "build-approved". Everyone else gets this job's plan output
# and a passing `result`, which is enough for a maintainer to review
# before deciding to spend the compute.
# and a failing `result` until a maintainer approves the build.
changes:
runs-on: ubuntu-latest
outputs:
@@ -64,6 +63,19 @@ jobs:
allow-fail: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- id: approval
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const { data: pr } = await github.rest.pulls.get({
...context.repo, pull_number: context.payload.pull_request.number,
});
// Approving or rerunning a held run keeps its original event,
// which may predate the label. Read the current approval instead.
core.setOutput('approved', pr.state === 'open' &&
pr.head.sha === context.payload.pull_request.head.sha &&
pr.labels.some(label => label.name === 'build-approved'));
# One matrix entry per package per architecture. Every package builds
# once, against edge; the channels it ships to on merge are carried
# along for information. A filename means one set of bytes.
@@ -92,16 +104,17 @@ jobs:
fi
- id: gate
env:
STATUS: ${{ steps.vouch.outputs.status || 'dispatch' }}
STATUS: ${{ github.event_name == 'workflow_dispatch' && 'dispatch' || steps.vouch.outputs.status }}
AUTHOR: ${{ github.event.pull_request.user.login }}
APPROVED: ${{ contains(github.event.pull_request.labels.*.name, 'build-approved') }}
APPROVED: ${{ steps.approval.outputs.approved || 'false' }}
PLANNED: ${{ steps.list.outputs.planned }}
run: |
case "$STATUS" in
bot|collaborator|vouched|dispatch) trusted=true ;;
# A denouncement is absolute: the label cannot override it.
denounced) trusted=false ;;
*) trusted=$APPROVED ;;
unknown) trusted=$APPROVED ;;
*) trusted=false ;;
esac
echo "trusted=$trusted" >> "$GITHUB_OUTPUT"
if [[ $trusted == true ]]; then
@@ -176,8 +189,7 @@ jobs:
steps:
- run: |
echo "trusted=${{ needs.changes.outputs.trusted }} build=${{ needs.build.result }}"
# An untrusted author's PR is held, not failed: the required check
# stays pending until a maintainer vouches or labels it.
# An untrusted author's PR fails until a maintainer vouches or labels it.
if [[ "${{ needs.changes.outputs.trusted }}" != "true" ]]; then
echo "::error::Builds were not run: author is not vouched. Add to .github/VOUCHED.td or apply the 'build-approved' label."
exit 1
+3
View File
@@ -32,6 +32,9 @@ jobs:
with:
persist-credentials: false
- name: Test PR workflow approval
run: node --test tests/pr-workflow-approval.cjs
# An Arch container for vercmp: version ordering has to be decided by
# the same comparator pacman uses on users' machines.
- name: Run self-tests