diff --git a/.github/scripts/auto-merge-pr.cjs b/.github/scripts/auto-merge-pr.cjs new file mode 100644 index 0000000..58d1e48 --- /dev/null +++ b/.github/scripts/auto-merge-pr.cjs @@ -0,0 +1,62 @@ +// Whether a PR rides to master on its own once the required checks pass. +// +// The rule is the build gate's, from build-pr.yml: a PR trusted to build is +// trusted to ship. Collaborators, vouched authors and bots are trusted; an +// unknown author is trusted while the PR carries build-approved; a +// denouncement is absolute. Two limits on top of it: +// +// - Only PRs that change nothing outside pkgbuilds/. A PR's workflows, +// scripts and build tooling never run in its own build (build-pr.yml +// overlays only its package directories onto base tooling), so green +// checks say nothing about them, and after merge they run with the +// publish secrets. +// - Not the upstream sync. It labels its own PR build-approved to release +// GitHub's hold on its pushes, which is no one's approval; it stays on the +// reviewed lane. +const PACKAGES = 'pkgbuilds/'; +const REVIEWED_BRANCHES = /^auto\/sync-upstream(\/|$)/; + +function decide({ pr, files, vouchStatus, repository }) { + if (pr.state !== 'open') return { enable: false, reason: 'PR is not open' }; + if (pr.draft) return { enable: false, reason: 'PR is a draft' }; + + const labelled = pr.labels.some(label => label.name === 'build-approved'); + let trusted; + switch (vouchStatus) { + case 'bot': case 'collaborator': case 'vouched': trusted = true; break; + case 'unknown': trusted = labelled; break; + default: trusted = false; // denounced, or a failed lookup + } + if (!trusted) { + return { enable: false, reason: `author not trusted to build (${vouchStatus || 'missing'}${labelled ? ', labelled' : ''})` }; + } + + if (pr.head.repo?.full_name === repository && REVIEWED_BRANCHES.test(pr.head.ref)) { + return { enable: false, reason: `${pr.head.ref} stays on the reviewed lane` }; + } + + if (!files.length) return { enable: false, reason: 'PR changes no files' }; + const outside = files.filter(file => + !file.filename.startsWith(PACKAGES) || + (file.previous_filename && !file.previous_filename.startsWith(PACKAGES))); + if (outside.length) { + const names = outside.slice(0, 3).map(file => file.filename).join(', '); + return { enable: false, reason: `changes files outside ${PACKAGES}: ${names}${outside.length > 3 ? ', ...' : ''}` }; + } + return { enable: true, reason: `${vouchStatus === 'unknown' ? 'build-approved' : vouchStatus} author, package files only` }; +} + +module.exports = async function autoMerge({ github, context, core, number, vouchStatus }) { + const { data: pr } = await github.rest.pulls.get({ ...context.repo, pull_number: number }); + const files = await github.paginate(github.rest.pulls.listFiles, { + ...context.repo, pull_number: number, per_page: 100, + }); + const decision = decide({ + pr, files, vouchStatus, repository: `${context.repo.owner}/${context.repo.repo}`, + }); + core.info(`#${number}: ${decision.enable ? 'auto-merge' : 'leave for a maintainer'} (${decision.reason})`); + core.setOutput('enable', String(decision.enable)); + core.setOutput('head_sha', pr.head.sha); + return decision; +}; +module.exports.decide = decide; diff --git a/.github/workflows/auto-merge-pr.yml b/.github/workflows/auto-merge-pr.yml new file mode 100644 index 0000000..fa3a4f7 --- /dev/null +++ b/.github/workflows/auto-merge-pr.yml @@ -0,0 +1,108 @@ +name: Auto-merge approved package PRs + +# A package PR trusted to build is trusted to ship: once its builds are +# green it should merge and publish without a maintainer pressing the +# button. This enables GitHub's auto-merge on such PRs; branch protection +# still holds the merge until `result`, `self-tests` and `build-isolation` +# pass, and a red build stays an open PR. .github/scripts/auto-merge-pr.cjs +# has the rule. +# +# Auto-merge is enabled with the PAT in PKGS_BOT_TOKEN: a merge made with the +# built-in GITHUB_TOKEN does not start publish.yml. +# +# pull_request_target runs this default-branch code with secrets; the PR's +# code is never checked out here. +on: + pull_request_target: + types: [opened, reopened, synchronize, ready_for_review, labeled, unlabeled] + workflow_dispatch: + inputs: + pr: + description: 'PR number to evaluate' + required: true + +permissions: + contents: read + pull-requests: read + +concurrency: + group: auto-merge-pr-${{ github.event.pull_request.number || github.event.inputs.pr }} + cancel-in-progress: true + +jobs: + auto-merge: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + + - name: Find the PR's author + id: pr + uses: actions/github-script@v7 + env: + NUMBER: ${{ github.event.pull_request.number || github.event.inputs.pr }} + with: + script: | + const { data: pr } = await github.rest.pulls.get({ + ...context.repo, pull_number: Number(process.env.NUMBER), + }); + core.setOutput('number', String(pr.number)); + core.setOutput('author', pr.user.login); + + - id: vouch + uses: mitchellh/vouch/action/check-user@f23dbb5e745334f97414ec70463ce7301071a661 # v1 + with: + user: ${{ steps.pr.outputs.author }} + allow-fail: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Decide + id: decide + uses: actions/github-script@v7 + env: + NUMBER: ${{ steps.pr.outputs.number }} + VOUCH_STATUS: ${{ steps.vouch.outputs.status }} + with: + script: | + const autoMerge = require('./.github/scripts/auto-merge-pr.cjs'); + await autoMerge({ github, context, core, + number: Number(process.env.NUMBER), vouchStatus: process.env.VOUCH_STATUS }); + + - name: Enable auto-merge + if: steps.decide.outputs.enable == 'true' + env: + GH_TOKEN: ${{ secrets.PKGS_BOT_TOKEN }} + PR: ${{ steps.pr.outputs.number }} + HEAD_SHA: ${{ steps.decide.outputs.head_sha }} + run: | + if [[ -z "$GH_TOKEN" ]]; then + echo "::error::Set PKGS_BOT_TOKEN; a GITHUB_TOKEN merge would not publish." + exit 1 + fi + # Idempotent: enabling twice errors. Bot lanes enable their own. + if [[ "$(gh pr view "$PR" -R "$GITHUB_REPOSITORY" --json autoMergeRequest --jq '.autoMergeRequest != null')" == true ]]; then + echo "auto-merge already enabled on #$PR" + exit 0 + fi + # --match-head-commit: never arm a merge for a commit newer than + # the one just judged. + gh pr merge --auto --squash --match-head-commit "$HEAD_SHA" "$PR" -R "$GITHUB_REPOSITORY" + + # Removing build-approved withdraws the approval, so withdraw the + # auto-merge it armed too. Only on that event: auto-merge a maintainer + # enabled by hand on any other PR is theirs to keep. + - name: Withdraw auto-merge + if: >- + steps.decide.outputs.enable == 'false' && + github.event.action == 'unlabeled' && github.event.label.name == 'build-approved' + env: + GH_TOKEN: ${{ secrets.PKGS_BOT_TOKEN }} + PR: ${{ steps.pr.outputs.number }} + run: | + if [[ "$(gh pr view "$PR" -R "$GITHUB_REPOSITORY" --json autoMergeRequest --jq '.autoMergeRequest != null')" == true ]]; then + gh pr merge --disable-auto "$PR" -R "$GITHUB_REPOSITORY" + fi diff --git a/README.md b/README.md index 0a10536..b3928c3 100644 --- a/README.md +++ b/README.md @@ -920,6 +920,17 @@ the push that opens the PR. A push to an `auto/sync-*` branch does not cancel the PR's in-flight build: the new build waits for it and then reuses its artifacts, so a long aarch64 build is not restarted by every sync. +Package PRs that are trusted to build also merge themselves. +`auto-merge-pr.yml` enables auto-merge (with `PKGS_BOT_TOKEN`, so the merge +publishes) on any open, non-draft PR whose author is a collaborator, vouched, +or a bot, or that carries **`build-approved`**, and that changes nothing +outside `pkgbuilds/`. The PR lands once `result`, `self-tests` and +`build-isolation` pass; a red build stays open. PRs that also touch +workflows, scripts or build tooling still need a maintainer to merge, as does +the upstream sync (`auto/sync-upstream`), which labels itself. Removing +`build-approved` withdraws the auto-merge it armed. For a PR opened before +the workflow existed, run it by hand: `gh workflow run auto-merge-pr.yml -f pr=`. + To approve builds for an unvouched contributor's PR, apply **`build-approved`**. Until approval, the PR shows **Awaiting build approval** and its required `result` check stays pending, keeping the PR blocked from merging without diff --git a/tests/pr-workflow-approval.cjs b/tests/pr-workflow-approval.cjs index 6202c64..4150c00 100644 --- a/tests/pr-workflow-approval.cjs +++ b/tests/pr-workflow-approval.cjs @@ -464,3 +464,57 @@ test('rebuild sync opens its PR with the bot token and auto-merges it', () => { assert.doesNotMatch(text, /^ +actions: write$/m, file); assert.doesNotMatch(text, /\n approve:\n/, file); }); + +const { decide } = require('../.github/scripts/auto-merge-pr.cjs'); +const repository = 'omacom/omarchy-pkgs'; +const mergeable = (overrides = {}) => ({ + state: 'open', draft: false, labels: [], + head: { ref: 'superwhisper-bin', repo: { full_name: repository } }, ...overrides, +}); +const packageFiles = [{ filename: 'pkgbuilds/superwhisper-bin/PKGBUILD' }, + { filename: 'pkgbuilds/superwhisper-bin/.omarchy/package.json' }]; + +test('auto-merge follows the build gate: trusted authors, or build-approved for unknown ones', () => { + for (const vouchStatus of ['collaborator', 'vouched', 'bot']) { + assert.equal(decide({ pr: mergeable(), files: packageFiles, vouchStatus, repository }).enable, true, vouchStatus); + } + assert.equal(decide({ pr: mergeable(), files: packageFiles, vouchStatus: 'unknown', repository }).enable, false); + const labelled = mergeable({ labels: [{ name: 'build-approved' }] }); + assert.equal(decide({ pr: labelled, files: packageFiles, vouchStatus: 'unknown', repository }).enable, true); + // A denouncement is absolute, and a failed lookup is not approval. + for (const vouchStatus of ['denounced', '', undefined, 'error']) { + assert.equal(decide({ pr: labelled, files: packageFiles, vouchStatus, repository }).enable, false, String(vouchStatus)); + } +}); + +test('auto-merge only lands PRs that change nothing outside pkgbuilds/', () => { + const check = files => decide({ pr: mergeable(), files, vouchStatus: 'collaborator', repository }).enable; + assert.equal(check(packageFiles), true); + assert.equal(check([...packageFiles, { filename: '.github/workflows/publish.yml' }]), false); + assert.equal(check([{ filename: 'bin/build' }]), false); + assert.equal(check([{ filename: 'pkgbuilds/x/PKGBUILD', previous_filename: 'helpers/x.sh' }]), false); + assert.equal(check([]), false); +}); + +test('auto-merge skips drafts, closed PRs and the reviewed upstream sync', () => { + const check = pr => decide({ pr, files: packageFiles, vouchStatus: 'bot', repository }).enable; + assert.equal(check(mergeable({ draft: true })), false); + assert.equal(check(mergeable({ state: 'closed' })), false); + for (const ref of ['auto/sync-upstream', 'auto/sync-upstream/walker']) { + assert.equal(check(mergeable({ head: { ref, repo: { full_name: repository } } })), false, ref); + } + // The unattended lanes already enable their own auto-merge; agreeing is harmless. + assert.equal(check(mergeable({ head: { ref: 'auto/sync-rebuilds', repo: { full_name: repository } } })), true); + // A fork's branch named like ours is just a contributor branch. + assert.equal(check(mergeable({ head: { ref: 'auto/sync-upstream', repo: { full_name: 'someone/omarchy-pkgs' } } })), true); +}); + +test('auto-merge workflow enables with the bot token and never checks out the PR', () => { + const text = readFileSync(join(__dirname, '../.github/workflows/auto-merge-pr.yml'), 'utf8'); + assert.match(text, /pull_request_target:/); + assert.match(text, /ref: \$\{\{ github\.event\.repository\.default_branch \}\}/); + assert.doesNotMatch(text, /head\.sha \}\}|head\.ref \}\}|refs\/pull\//); + assert.match(text, /GH_TOKEN: \$\{\{ secrets\.PKGS_BOT_TOKEN \}\}/); + assert.match(text, /gh pr merge --auto --squash --match-head-commit "\$HEAD_SHA"/); + assert.doesNotMatch(text, /^ +(contents|pull-requests|actions): write$/m); +});