Files
omarchy-pkgs/.github/scripts/auto-merge-pr.cjs
T
Ryan Hughes ba7561a34c Open one auto-merging PR per package from every sync workflow
sync-upstream.yml, sync-rebuilds.yml and track-branches.yml each opened one
batch PR, so one package with a failing build held back every other update
in it (#788, #837). All three now group their changes with
package-pr-groups.sh and hand them to package-prs.yml, which opens a PR per
package and arms auto-merge. Packages pinned from the same upstream branch
still share one. Green packages land on their own and only the failures stay
open; a red PR conflicting with master, and each lane's old batch PR, get
closed.

sync-upstream.yml loses its human-merged lane: it now pushes with the PAT
like the other two, so its builds start without an approval hold and its PRs
auto-merge when green. min_release_age still quarantines fresh releases. Its
approve job, approve-sync-push.cjs, and auto-merge-pr.cjs's exception for
auto/sync-upstream go with it.
2026-10-09 18:20:57 -04:00

77 lines
3.7 KiB
JavaScript

// 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 package changes. 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. Allowed: anything under
// pkgbuilds/, plus the test a package PR brings with it, which is a new
// file under tests/ and lines in test.yml that only run new tests/*.sh.
// test.yml runs on PRs only; an existing test may also run on master
// (builder-images.yml runs tests/build-isolation.sh with packages: write),
// so editing one still needs a maintainer.
const PACKAGES = 'pkgbuilds/';
const TESTS = 'tests/';
const TEST_WORKFLOW = '.github/workflows/test.yml';
const TEST_LINE = /^\+\s*\.\/tests\/[A-Za-z0-9._-]+\.sh\s*$/;
// Every changed line adds a ./tests/<name>.sh call; nothing removed. A diff
// too large for the API has no patch and does not qualify.
function onlyRunsTests(patch) {
if (!patch) return false;
const changed = patch.split('\n').filter(line =>
/^[+-]/.test(line) && !line.startsWith('+++') && !line.startsWith('---'));
return changed.length > 0 && changed.every(line => TEST_LINE.test(line));
}
function packageChange(file) {
if (file.previous_filename && !file.previous_filename.startsWith(PACKAGES)) return false;
if (file.filename.startsWith(PACKAGES)) return true;
if (file.filename.startsWith(TESTS)) return file.status === 'added';
if (file.filename === TEST_WORKFLOW) return file.status === 'modified' && onlyRunsTests(file.patch);
return false;
}
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 (!files.length) return { enable: false, reason: 'PR changes no files' };
const outside = files.filter(file => !packageChange(file));
if (outside.length) {
const names = outside.slice(0, 3).map(file => file.filename).join(', ');
return { enable: false, reason: `changes more than packages and their new tests: ${names}${outside.length > 3 ? ', ...' : ''}` };
}
return { enable: true, reason: `${vouchStatus === 'unknown' ? 'build-approved' : vouchStatus} author, package changes 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;