Skip to content

.github/workflows/auto-merge.yml #649

.github/workflows/auto-merge.yml

.github/workflows/auto-merge.yml #649

Workflow file for this run

name: Auto-Merge PRs
# Merges open PRs once every check that ran for their head commit has gone green.
#
# Why `workflow_run` and not `check_suite`:
# GitHub does not start a new workflow run from events generated by the
# built-in GITHUB_TOKEN, so the `check_suite: completed` suites produced by
# Actions never triggered this workflow (0 runs in ~40 attempts). `workflow_run`
# is the supported way to react to another workflow finishing.
#
# Why not GitHub's native auto-merge (`gh pr merge --auto`):
# `main` has no branch protection and therefore no *required* status checks.
# Native auto-merge only waits for required checks, so with none configured it
# merges as soon as the PR is mergeable — i.e. before CI has finished. This
# workflow evaluates the checks itself instead.
#
# Security: jobs here run in the base-repo context with a writable token and
# deliberately never check out or execute PR-authored code — they only call the
# REST API.
on:
workflow_run:
workflows:
- "Frontend CI"
- "Frontend Build Check"
- "Smart Contract CI"
- "Contract Tests"
types: [completed]
# Safety net: re-sweep periodically so a PR whose triggering event was missed
# (or that went green while another PR was merging) still lands.
schedule:
- cron: "*/30 * * * *"
workflow_dispatch:
permissions:
contents: write # needed to merge
pull-requests: write # needed to comment / update PRs
checks: read
statuses: read
concurrency:
group: auto-merge
cancel-in-progress: false
jobs:
merge-green-prs:
name: Merge PRs with all checks passing
runs-on: ubuntu-latest
steps:
- name: Evaluate and merge
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const { owner, repo } = context.repo;
const TERMINAL_OK = ['success', 'skipped', 'neutral'];
// ── Work out which PRs to consider ────────────────────────────
// `workflow_run.pull_requests` is always empty for PRs from forks,
// so resolve candidates by matching head SHA against open PRs.
const openPrs = await github.paginate(github.rest.pulls.list, {
owner, repo, state: 'open', per_page: 100,
});
let candidates = openPrs;
if (context.eventName === 'workflow_run') {
const run = context.payload.workflow_run;
if (run.conclusion !== 'success') {
core.info(`Upstream run "${run.name}" concluded "${run.conclusion}" — nothing to do.`);
return;
}
candidates = openPrs.filter(pr => pr.head.sha === run.head_sha);
if (candidates.length === 0) {
core.info(`No open PR matches head SHA ${run.head_sha}.`);
return;
}
}
core.info(`Evaluating ${candidates.length} PR(s).`);
for (const summary of candidates) {
const number = summary.number;
// Re-fetch: the list payload omits `mergeable`/`mergeable_state`.
let { data: pr } = await github.rest.pulls.get({
owner, repo, pull_number: number,
});
if (pr.draft) { core.info(`#${number}: draft — skipping.`); continue; }
if (pr.state !== 'open') { core.info(`#${number}: not open — skipping.`); continue; }
// ── Checks must all be finished and green ───────────────────
const sha = pr.head.sha;
const { data: { check_runs } } = await github.rest.checks.listForRef({
owner, repo, ref: sha, per_page: 100,
});
// Ignore this workflow's own check so it never waits on itself.
const relevant = check_runs.filter(r => r.name !== 'Merge PRs with all checks passing');
if (relevant.length === 0) {
core.info(`#${number}: no checks reported for ${sha} — refusing to merge unchecked.`);
continue;
}
const pending = relevant.filter(r => r.status !== 'completed');
if (pending.length > 0) {
core.info(`#${number}: still running (${pending.map(r => r.name).join(', ')}) — waiting.`);
continue;
}
const failing = relevant.filter(r => !TERMINAL_OK.includes(r.conclusion));
if (failing.length > 0) {
core.info(`#${number}: failing checks (${failing.map(r => r.name).join(', ')}) — skipping.`);
continue;
}
// Legacy commit statuses (non-Actions integrations) count too.
const { data: combined } = await github.rest.repos.getCombinedStatusForRef({
owner, repo, ref: sha,
});
if (combined.total_count > 0 && combined.state !== 'success') {
core.info(`#${number}: commit status is "${combined.state}" — skipping.`);
continue;
}
// ── Mergeability (computed asynchronously by GitHub) ────────
for (let attempt = 0; pr.mergeable === null && attempt < 3; attempt++) {
core.info(`#${number}: mergeability not computed yet, retrying in 5s…`);
await new Promise(r => setTimeout(r, 5000));
({ data: pr } = await github.rest.pulls.get({
owner, repo, pull_number: number,
}));
}
if (pr.mergeable === false) {
core.info(`#${number}: merge conflicts (${pr.mergeable_state}) — skipping.`);
continue;
}
if (pr.mergeable === null) {
core.info(`#${number}: mergeability still unknown — will retry next sweep.`);
continue;
}
if (['blocked', 'behind', 'draft'].includes(pr.mergeable_state)) {
core.info(`#${number}: mergeable_state="${pr.mergeable_state}" — skipping.`);
continue;
}
// ── Merge ──────────────────────────────────────────────────
// Drop AI-assistant co-author trailers from the squash message.
// Human co-authors are deliberately left intact — stripping those
// would erase real contributor attribution.
const body = (pr.body ?? '')
.split('\n')
.filter(line => !/^\s*co-authored-by:.*(claude|anthropic\.com)/i.test(line))
.join('\n')
.trim();
try {
await github.rest.pulls.merge({
owner, repo,
pull_number: number,
merge_method: 'squash',
commit_title: `${pr.title} (#${number})`,
commit_message: body,
sha,
});
core.notice(`Auto-merged #${number}: ${pr.title}`);
} catch (err) {
// 405 = not mergeable right now, 409 = head moved. Both are
// transient; the next sweep retries.
core.warning(`#${number}: merge failed (${err.status}): ${err.message}`);
}
}