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:
1 parent
7d2c7c50af
commit
48d6217ff7
4 files changed
+235
No files matched your search
@@ -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;
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user