Make build-approved release pending PR workflows
This commit is contained in:
1 parent
cce11a6917
commit
3628915c5d
7 files changed
+405
-8
No files matched your search
+4
-1
@@ -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
|
||||
|
||||
@@ -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.');
|
||||
};
|
||||
@@ -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 });
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user