Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/automerge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ permissions:
issues: write
checks: read
statuses: read
actions: read
actions: write

concurrency:
group: automerge-${{ github.event.pull_request.number || github.event.workflow_run.id }}
Expand Down
213 changes: 180 additions & 33 deletions .github/workflows/reusable-automerge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,25 @@ name: reusable-automerge
# 2026-09-23), so the merge push triggers downstream push workflows (a GITHUB_TOKEN push
# never does, which silently skipped deploys) and GitHub honours `Closes #N`. GITHUB_TOKEN
# is used only (a) to read the check rollup (the App has no checks permission) and (b) as
# the automatic retry when GitHub refuses the App a merge mutation. Merge-queue ENQUEUE
# never falls back to GITHUB_TOKEN: the queue's merge_group event would be attributed to
# it and start no workflows, so the entry wedges at AWAITING_CHECKS. The App deliberately
# (c) to dispatch deploy workflows from the post-merge guard (workflow_dispatch is the one
# event GitHub still starts runs for when GITHUB_TOKEN causes it). NO merge, auto-merge or
# enqueue mutation ever runs as GITHUB_TOKEN: a GITHUB_TOKEN merge push triggers no push
# workflows, so its deploys are silently skipped (emkraan-apps#1798: #1793 was merged as
# github-actions after the App's merge hit a transient "Base branch was modified" race and
# the old code retried as GITHUB_TOKEN). A transient refusal is retried as the App; any
# other refusal is left for the next event or sweep. Merge-queue ENQUEUE likewise: the
# queue's merge_group event would be attributed to GITHUB_TOKEN and start no workflows,
# so the entry wedges at AWAITING_CHECKS. The App deliberately
# has NO workflows:write: its key is an org secret visible to every repo, so that
# permission would let any repo's workflow rewrite CI (and reach every secret) org-wide.
# If the App token cannot be minted, plain-repo merges fall back to GITHUB_TOKEN with a
# warning and merge-queue enqueues are skipped with a warning.
# If the App token cannot be minted the job FAILS loudly; there is no GITHUB_TOKEN fallback.
#
# POST-MERGE GUARD: after every merge this job performs, and on every sweep for PRs merged
# in the last 24h, the merge actor is verified. If a PR was merged by anyone other than the
# deploy-bot App as github-actions (so its merge push started no workflows), every
# push-triggered workflow on the default branch whose path filters match the PR's files and
# that declares workflow_dispatch is dispatched, and the job emits an error and fails.
# Callers must grant `actions: write` for the dispatch.
#
# TRIGGERS the caller must declare (see the caller template in the standard):
# pull_request [opened, reopened, synchronize, ready_for_review, closed]
Expand Down Expand Up @@ -65,12 +77,10 @@ jobs:
automerge:
runs-on: ${{ fromJSON(inputs.runs-on) }}
steps:
# Best effort on purpose: if minting fails the script falls back to GITHUB_TOKEN.
# A missing app token must never stop PRs merging.
- name: Mint deploy-bot app token (best effort)
# Hard requirement: a merge by any other identity skips every push-triggered deploy
# (emkraan-apps#1798). If minting fails the job fails; nothing merges as GITHUB_TOKEN.
- name: Mint deploy-bot app token (required)
id: app-token
if: inputs.app-client-id != ''
continue-on-error: true
uses: actions/create-github-app-token@v3
with:
client-id: ${{ inputs.app-client-id }}
Expand All @@ -79,18 +89,21 @@ jobs:
- uses: actions/github-script@v9
env:
FALLBACK_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PRIMARY_IS_APP: ${{ steps.app-token.outputs.token != '' && steps.app-token.outputs.token != null }}
APP_SLUG: ${{ steps.app-token.outputs.app-slug }}
with:
github-token: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }}
github-token: ${{ steps.app-token.outputs.token }}
script: |
const {owner, repo} = context.repo;
const primaryIsApp = process.env.PRIMARY_IS_APP === 'true';
const fallbackToken = process.env.FALLBACK_TOKEN || '';
const appSlug = process.env.APP_SLUG || '';
const appLogin = `${appSlug}[bot]`;
const sleep = ms => new Promise(r => setTimeout(r, ms));
core.info(`acting as ${primaryIsApp ? 'deploy-bot app' : 'github-actions'}; workflow="${context.workflow}" run=${context.runId}`);
if (!appSlug) { core.setFailed('deploy-bot app token minted without an app slug; refusing to merge as any other identity'); return; }
core.info(`acting as ${appLogin}; workflow="${context.workflow}" run=${context.runId}`);
let guardFailed = false;

// Raw fetch as GITHUB_TOKEN. Used for the check rollup (the App token cannot read
// checks) and as the retry identity for mutations the App is refused.
// Raw fetch as GITHUB_TOKEN. Used ONLY for the check rollup (the App token cannot
// read checks). Never for a merge, auto-merge or enqueue mutation.
async function graphqlAsToken(query, variables) {
const res = await fetch('https://api.github.com/graphql', {
method: 'POST',
Expand All @@ -102,24 +115,152 @@ jobs:
return body.data;
}

async function mutate(query, vars, what) {
try {
await github.graphql(query, vars);
core.info(`${what} (as ${primaryIsApp ? 'deploy-bot app' : 'github-actions'})`);
return;
} catch (e) {
if (!primaryIsApp || !fallbackToken) throw e;
core.warning(`${what} refused for the app token (${e.message}); retrying as github-actions`);
async function restAsToken(method, path, body) {
const res = await fetch(`https://api.github.com${path}`, {
method,
headers: {authorization: `token ${fallbackToken}`, accept: 'application/vnd.github+json', 'content-type': 'application/json', 'user-agent': 'emkraan-automerge'},
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text().catch(() => '')}`);
}

// Every mutation runs as the App, always. A transient refusal (another PR merged
// between our read and the mutation) is retried as the App after a short wait;
// refreshVars re-reads the head oid so the retry is not stale.
const TRANSIENT = /base branch was modified|head branch was modified|try the merge again|timeout|was submitted too quickly/i;
async function mutate(query, vars, what, refreshVars) {
for (let attempt = 1; ; attempt++) {
try {
await github.graphql(query, vars);
core.info(`${what} (as ${appLogin})`);
return;
} catch (e) {
if (attempt >= 4 || !TRANSIENT.test(e.message)) throw e;
core.info(`${what}: transient refusal (${e.message}); retrying as ${appLogin} (attempt ${attempt + 1})`);
await sleep(5000 * attempt);
if (refreshVars) vars = await refreshVars();
}
}
}

// ---- Post-merge guard (emkraan-apps#1798). A merge by any identity other than the
// App (in practice github-actions via GITHUB_TOKEN) starts no push workflows, so
// dispatch the ones that should have run and fail loudly.
function globToRe(g) {
let r = '';
for (let i = 0; i < g.length; i++) {
const c = g[i];
if (c === '*' && g[i + 1] === '*') { r += '.*'; i++; if (g[i + 1] === '/') i++; }
else if (c === '*') r += '[^/]*';
else if (c === '?') r += '[^/]';
else r += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
}
return new RegExp(`^${r}$`);
}
// Minimal reader of a workflow's top-level `on:` block: does it trigger on push to
// the default branch, with which path filters, and does it accept workflow_dispatch.
function pushTrigger(text, branch) {
const lines = text.split('\n');
const onIdx = lines.findIndex(l => /^(on|"on"|'on')\s*:/.test(l));
if (onIdx < 0) return null;
const inline = lines[onIdx].replace(/^[^:]*:\s*/, '').replace(/#.*/, '').trim();
if (inline) {
const evs = inline.replace(/[\[\]]/g, '').split(',').map(x => x.trim());
return evs.includes('push') ? {paths: [], branches: [], dispatch: evs.includes('workflow_dispatch')} : null;
}
const block = [];
for (let i = onIdx + 1; i < lines.length && (/^\s/.test(lines[i]) || lines[i].trim() === ''); i++) block.push(lines[i]);
const dispatch = block.some(l => /^\s{1,4}workflow_dispatch\s*:/.test(l));
const pi = block.findIndex(l => /^\s{1,4}push\s*:/.test(l));
if (pi < 0) return null;
const ind = block[pi].match(/^\s*/)[0].length;
const sub = [];
for (let i = pi + 1; i < block.length; i++) {
if (block[i].trim() === '' || /^\s*#/.test(block[i])) continue;
if (block[i].match(/^\s*/)[0].length <= ind) break;
sub.push(block[i]);
}
await graphqlAsToken(query, vars);
core.info(`${what} (as github-actions; a linked Closes #N will NOT fire)`);
const list = key => {
const out = [];
const k = sub.findIndex(l => new RegExp(`^\\s*${key}\\s*:`).test(l));
if (k < 0) return out;
const rest = sub[k].replace(/^[^:]*:\s*/, '').replace(/#.*/, '').trim();
if (rest) return rest.replace(/[\[\]]/g, '').split(',').map(x => x.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
const kind = sub[k].match(/^\s*/)[0].length;
for (let i = k + 1; i < sub.length && sub[i].match(/^\s*/)[0].length > kind; i++) {
const m = sub[i].match(/^\s*-\s*(.+?)\s*(#.*)?$/);
if (m) out.push(m[1].replace(/^["']|["']$/g, ''));
}
return out;
};
const branches = list('branches');
if (branches.length && !branches.some(b => globToRe(b).test(branch))) return null;
return {paths: list('paths'), ignore: list('paths-ignore'), branches, dispatch};
}
function pathsMatch(t, files) {
if (t.ignore && t.ignore.length && files.every(f => t.ignore.some(g => globToRe(g).test(f)))) return false;
if (!t.paths.length) return true;
return files.some(f => {
let hit = false;
for (const g of t.paths) {
if (g.startsWith('!')) { if (globToRe(g.slice(1)).test(f)) hit = false; }
else if (globToRe(g).test(f)) hit = true;
}
return hit;
});
}

async function guardMerged(n) {
const {data: pr} = await github.rest.pulls.get({owner, repo, pull_number: n});
if (!pr.merged) return;
const actor = pr.merged_by ? pr.merged_by.login : '(unknown)';
if (actor === appLogin) { core.info(`#${n}: merge actor ${actor} verified`); return; }
// A human merge (or a merge queue) triggers push workflows normally; only a
// GITHUB_TOKEN merge (github-actions[bot]) suppresses them.
if (actor !== 'github-actions[bot]') { core.info(`#${n}: merged by ${actor}; push workflows fire normally`); return; }
const sha = pr.merge_commit_sha;
const branch = pr.base.ref;
// Idempotent: skip when a push run already exists for the merge commit, or when a
// dispatch run was started after the merge (a previous guard already ran).
const runs = (await github.rest.actions.listWorkflowRunsForRepo({owner, repo, head_sha: sha, per_page: 100})).data.workflow_runs;
if (runs.some(r => r.event === 'push')) { core.info(`#${n}: merged by ${actor} but push runs exist for ${sha}`); return; }
const files = (await github.paginate(github.rest.pulls.listFiles, {owner, repo, pull_number: n, per_page: 100})).map(f => f.filename);
const wfs = (await github.paginate(github.rest.actions.listRepoWorkflows, {owner, repo, per_page: 100})).filter(w => w.state === 'active');
const dispatched = [], undispatchable = [], failed = [];
for (const w of wfs) {
let text;
try {
const {data} = await github.rest.repos.getContent({owner, repo, path: w.path, ref: sha});
text = Buffer.from(data.content, 'base64').toString('utf8');
} catch (e) { continue; }
const t = pushTrigger(text, branch);
if (!t || !pathsMatch(t, files)) continue;
if (!t.dispatch) { undispatchable.push(w.path); continue; }
const prior = (await github.rest.actions.listWorkflowRuns({owner, repo, workflow_id: w.id, event: 'workflow_dispatch', created: `>=${pr.merged_at}`, per_page: 1})).data.total_count;
if (prior > 0) { core.info(`#${n}: ${w.path} already dispatched since the merge`); continue; }
try {
await restAsToken('POST', `/repos/${owner}/${repo}/actions/workflows/${w.id}/dispatches`, {ref: branch});
dispatched.push(w.path);
} catch (e) { failed.push(`${w.path} (${e.message})`); }
}
guardFailed = true;
core.error(`#${n} was merged by ${actor}, not ${appLogin}: its merge push ${sha.slice(0, 7)} started NO push workflows (GITHUB_TOKEN loop prevention). ` +
`Dispatched on ${branch}: ${dispatched.join(', ') || 'none'}. ` +
(undispatchable.length ? `Matching but WITHOUT workflow_dispatch (run by hand): ${undispatchable.join(', ')}. ` : '') +
(failed.length ? `Dispatch FAILED (grant the caller actions: write): ${failed.join('; ')}. ` : '') +
`Find the path that merged as GITHUB_TOKEN (emkraan-apps#1798).`);
}
async function safeGuard(n) {
try { await guardMerged(n); }
catch (e) { guardFailed = true; core.error(`#${n}: post-merge guard could not run (${e.message})`); }
}

// ---- Merged PR: close linked issues explicitly. GitHub does not honour
// `Closes #N` when github-actions[bot] merged, so do it in the open (apollo#645).
if (context.eventName === 'pull_request' && context.payload.action === 'closed') {
const pr = context.payload.pull_request;
if (!pr.merged) { core.info(`#${pr.number}: closed without merging`); return; }
await safeGuard(pr.number);
let linked = [];
try {
const d = await github.graphql(`query($o:String!,$r:String!,$n:Int!){ repository(owner:$o,name:$r){
Expand All @@ -133,6 +274,7 @@ jobs:
} catch (e) { core.warning(`#${pr.number}: could not close #${issue.number} (${e.message})`); }
}
if (linked.length === 0) core.info(`#${pr.number}: no linked issues`);
if (guardFailed) core.setFailed('post-merge guard: see errors above');
return;
}

Expand All @@ -154,8 +296,15 @@ jobs:
// pending event (e.g. fell out of a merge queue after enqueue).
const open = await github.paginate(github.rest.pulls.list, {owner, repo, state: 'open', per_page: 100});
numbers = open.filter(p => !p.draft).map(p => p.number);
// Guard sweep: a GITHUB_TOKEN-caused pull_request.closed event starts no run, so
// the closed-event guard never sees such a merge. Re-check the last 24h here.
const since = Date.now() - 24 * 3600 * 1000;
const closed = (await github.rest.pulls.list({owner, repo, state: 'closed', sort: 'updated', direction: 'desc', per_page: 50})).data;
for (const p of closed.filter(p => p.merged_at && Date.parse(p.merged_at) >= since)) {
await safeGuard(p.number);
}
}
if (numbers.length === 0) { core.info('no candidate PRs'); return; }
if (numbers.length === 0) { core.info('no candidate PRs'); if (guardFailed) core.setFailed('post-merge guard: see errors above'); return; }

const Q = `query($o:String!,$r:String!,$n:Int!){ repository(owner:$o,name:$r){
mergeQueue{ id }
Expand Down Expand Up @@ -228,17 +377,14 @@ jobs:
// masked it: the queue's branch push is by github-merge-queue[bot].) So there is
// no GITHUB_TOKEN fallback here: an App-less or App-refused enqueue is skipped
// loudly and retried by the next event or sweep.
if (!primaryIsApp) {
core.warning(`#${n}: ready but NOT enqueued: no deploy-bot app token, and a GITHUB_TOKEN enqueue forms no merge_group run (the entry would wedge). Fix the app token; the sweep will retry.`);
continue;
}
try { await github.graphql(ENQUEUE, {id: pr.id}); core.info(`#${n}: enqueued (as deploy-bot app)`); }
catch (e) { core.warning(`#${n}: enqueue refused for the deploy-bot app (${e.message}); NOT retrying as GITHUB_TOKEN (it would wedge). The next event or the sweep will retry.`); }
continue;
}

const refresh = async () => { const f = (await readPr(n)).pr; return {id: f.id, oid: f.headRefOid}; };
if (r.ready) {
try { await mutate(MERGE, {id: pr.id, oid: pr.headRefOid}, `#${n}: merged (${r.why})`); continue; }
try { await mutate(MERGE, {id: pr.id, oid: pr.headRefOid}, `#${n}: merged (${r.why})`, refresh); await safeGuard(n); continue; }
catch (e) { core.warning(`#${n}: direct merge failed (${e.message}); trying auto-merge`); }
}
if (pr.mergeStateStatus === 'DIRTY') { core.warning(`#${n}: DIRTY (merge conflict), needs a rebase`); continue; }
Expand All @@ -263,9 +409,10 @@ jobs:
if (fresh.state !== 'OPEN' || fresh.merged) { core.info(`#${n}: no longer open`); done = true; break; }
if (last === 'DIRTY' || last === 'BLOCKED') break;
const fr = await readiness(fresh);
if (fr.ready) { await mutate(MERGE, {id: fresh.id, oid: fresh.headRefOid}, `#${n}: merged after poll (${fr.why})`); done = true; }
if (fr.ready) { await mutate(MERGE, {id: fresh.id, oid: fresh.headRefOid}, `#${n}: merged after poll (${fr.why})`, refresh); await safeGuard(n); done = true; }
else last = `${last} (${fr.why})`;
} catch (e2) { core.warning(`#${n}: poll ${i + 1} failed (${e2.message})`); }
}
if (!done) core.warning(`#${n}: NOT merged; last state ${last}. The next CI completion or the sweep will retry.`);
}
if (guardFailed) core.setFailed('post-merge guard: a PR merged as a non-App identity; see errors above');
Loading