Auto-merge package PRs that are trusted to build (#867)

A package PR approved to build, by its author being trusted or by the
build-approved label, sat open after going green until someone merged it by
hand, so nothing it built was published. Enable GitHub's auto-merge on it
with PKGS_BOT_TOKEN, so the merge lands once the required checks pass and
starts publish.yml.

The trust rule is build-pr.yml's. Only PRs changing nothing outside
pkgbuilds/ qualify: a PR's own tooling never runs in its build, and after
merge it runs with the publish secrets. The upstream sync, which labels its
own PRs, stays on the reviewed lane. Removing build-approved withdraws the
auto-merge.
This commit is contained in:
Ryan Hughes authored and GitHub committed 2026-10-08 14:33:54 -04:00
1 parent 7d2c7c50af
commit 48d6217ff7
4 files changed
+235

No files matched your search

+62
View File
@@ -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;
+108
View File
@@ -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
+11
View File
@@ -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=<number>`.
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
+54
View File
@@ -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);
});