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
36 changes: 36 additions & 0 deletions .github/agent/merge-gate-ruleset.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"name": "main merge gate",
"target": "branch",
"enforcement": "active",
"bypass_actors": [],
"conditions": {
"ref_name": {
"include": ["refs/heads/main"],
"exclude": []
}
},
"rules": [
{ "type": "deletion" },
{ "type": "non_fast_forward" },
{
"type": "required_status_checks",
"parameters": {
"strict_required_status_checks_policy": false,
"required_status_checks": [
{ "context": "tests-ok" },
{ "context": "enqueue-guard" }
]
}
},
{
"type": "pull_request",
"parameters": {
"required_approving_review_count": 0,
"dismiss_stale_reviews_on_push": false,
"require_code_owner_review": false,
"require_last_push_approval": false,
"required_review_thread_resolution": false
}
}
]
}
17 changes: 13 additions & 4 deletions .github/scripts/enqueue-guard.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,12 @@ function arg(name) {
return i > 0 ? process.argv[i + 1] : undefined
}

// `gh` and `env` are injectable so a test drives the whole check without the network, the way
// merge-gate.mjs does.
export function main({ gh = realGh, env = process.env, repo: repoOption, pr: prOption } = {}) {
// The decision for one pull request, read live: the single place that reads the pull request, its
// conclusions and its diff, and calls `judge` + `guard`. `main` below prints it (the command an agent
// runs); `gate-check.mjs` turns the same object into the `enqueue-guard` check run the ruleset requires
// (#812). One function with two readers, so the check and the printed command cannot disagree — #812's
// kill condition is a second copy of these rules, and there is none.
export function decidePull({ gh = realGh, env = process.env, repo: repoOption, pr: prOption, expectedHead } = {}) {
const list = (path) => gh('api', '--paginate', '--jq', '.[]', path).split('\n').filter(Boolean).map((line) => JSON.parse(line))
const repo = repoOption || env.GH_REPO || arg('--repo')
if (!repo) throw new Error('set GH_REPO or pass --repo owner/name')
Expand All @@ -133,14 +136,20 @@ export function main({ gh = realGh, env = process.env, repo: repoOption, pr: prO

// The live head is judged, never a cached one: a push that moved the tip voids the PASS the caller
// believes it has, and `--sha` lets a caller insist on the head it read a moment ago.
const result = judge({ labels, files, head, author: pull.user?.login, reports, policy: loadPolicy(policyPath), expectedHead: arg('--sha'), mode: 'queue' })
const result = judge({ labels, files, head, author: pull.user?.login, reports, policy: loadPolicy(policyPath), expectedHead: expectedHead ?? arg('--sha'), mode: 'queue' })
const mechanicalRelease = isMechanicalRelease(files.map((f) => f.filename))
// The branch this repository owns, not a contributor's: `head.repo` is null when the fork was deleted,
// which is not ours either. Both sides are lower-cased because the repository name in the API paths is
// case-insensitive: `--repo thinkflowlab/flybot` used to read our own branch as a fork and print ALLOW.
const fork = String(pull.head?.repo?.full_name ?? '').toLowerCase() !== String(repo).toLowerCase()
const decision = guard({ pr, head, labels, body: pull.body, mechanicalRelease, fork, result, reports })
return { decision, pull, repo, pr }
}

// The printed form: the guard's own ALLOW/REFUSE line, then the command to run.
export function main({ gh = realGh, env = process.env, repo: repoOption, pr: prOption } = {}) {
const { decision, pull, pr } = decidePull({ gh, env, repo: repoOption, pr: prOption })
const labels = (pull.labels ?? []).map((l) => l.name)
console.log(`enqueue-guard: PR #${pr} head ${short(decision.head)}, labels [${labels.join(', ')}]`)
console.log(decision.ok
? `enqueue-guard: ALLOW — ${decision.message}; ${decision.exempt ? `a ${decision.tier ?? 'T0/T1'} tier or a mechanical release PR (AGENTS.md 6)` : `${decision.verdict?.by}'s PASS names this tip`}`
Expand Down
159 changes: 159 additions & 0 deletions .github/scripts/gate-check.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// The merge gate GitHub enforces, not just a script (#812).
//
// `enqueue-guard.mjs` decides whether a pull request may merge and prints the command; nothing ran it
// before a person merged, and on the code fork `Birfy/FlyBot`, where every routine pull request lands,
// `main` had no ruleset and no branch protection — so a PASS was advisory and #30 merged without
// `agent-pr`. This script writes that same decision onto the pull request head as a check run named
// `enqueue-guard`, the context the ruleset on `main` requires; with no bypass actor, an unreviewed
// pull request cannot be squash-merged through the API at all.
//
// It decides nothing itself: `decidePull` in enqueue-guard.mjs is called, and inside it the merge
// gate's `judge`. A second copy of the rules is #812's kill condition, so there is none — the check and
// the command the guard prints come from one function.
//
// The check is written through the API instead of being the job's own result because it has to flip on
// events no `pull_request` run follows: a verdict arrives as a comment (`issue_comment`), a label is
// added or removed (`labeled`/`unlabeled`), and neither starts a run for the head being judged (#639).
// `external_id` is the head, so a re-judgement PATCHes that one run instead of piling runs up, and a
// check written for an older head is never reused: a stale green must not answer for a new tip.
//
// `enqueue-guard.yml` decides when this runs, and takes the code it runs from `main`
// (`pull_request_target` + `issue_comment` + `workflow_dispatch`); a `pull_request` job from a fork is
// read-only and could not write the check, which is why that trigger is not used. Nothing here
// checks out or runs pull request code: the head is read through the API only.
//
// Exit code: 0 whenever the judgement was written, whatever it concluded — a red `enqueue-guard` check
// is the refusal, and a red job on top of it would report the same thing twice. 1 only when the head
// could not be judged or the check could not be written. `--dry-run` (or `DRY_RUN=1`) judges the live
// head, prints the payload and writes nothing: it is how a person checks the decision against a real
// pull request before the workflow exists on `main`, and it cannot leave a context behind.
import { appendFileSync } from 'node:fs'
import { execFileSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { decidePull } from './enqueue-guard.mjs'

export const CHECK_NAME = 'enqueue-guard'
// `output.title` is capped by the API at 255 characters; a refusal that names several defects is
// longer than that, and the full text is in the summary either way.
const TITLE_MAX = 250
const SHORT = 12
const short = (s) => String(s ?? '').slice(0, SHORT)

// The check run's body, for `POST /check-runs` and for the fields `PATCH` may change. `conclusion` is
// the decision: `success` only when the guard allows the merge, so every refusal — a `hold`, a tip with
// no PASS, a missing `agent-pr`, a PASS that lists no `Ran:` — is one red required check.
export function payloadFor({ decision, head }) {
const refused = !decision?.ok
const message = decision?.message ?? (refused ? 'the guard refused this head' : 'the guard allows this head')
return {
name: CHECK_NAME,
head_sha: String(head ?? ''),
status: 'completed',
conclusion: refused ? 'failure' : 'success',
external_id: String(head ?? ''),
output: {
title: message.length > TITLE_MAX ? message.slice(0, TITLE_MAX - 1) + '…' : message,
summary: summaryOf({ decision, head }),
},
}
}

// The check run this head already carries, if any. One per head, by `external_id`: a label or a comment
// re-judges the same head, and a second run for one context on one commit is a state nobody can read.
export function planCheck({ runs, head } = {}) {
const target = String(head ?? '')
const mine = (runs ?? []).find((run) =>
run?.name === CHECK_NAME &&
String(run?.external_id ?? '') === target &&
String(run?.app?.slug ?? 'github-actions') === 'github-actions')
return mine?.id != null ? { action: 'update', id: mine.id } : { action: 'create' }
}

// The evidence under the check: same decision, same words, one place to read why it is red.
export function summaryOf({ decision, head, pr } = {}) {
const ok = Boolean(decision?.ok)
const labels = (decision?.labels ?? []).map((l) => `\`${l}\``).join(', ') || '(none)'
const lines = [
`### \`${CHECK_NAME}\`: ${ok ? 'this head may merge' : 'this head must not merge'}`,
'',
`Pull request${pr ? ` #${pr}` : ''}, head \`${head ?? ''}\`. ${ok
? 'The ruleset on `main` lets this merge once `tests-ok` is green here too.'
: 'While this check is red, a merge of this head through the API is refused.'}`,
'',
`- decision: \`${decision?.message ?? 'no decision'}\``,
`- labels: ${labels}`,
]
if (decision?.exempt) lines.push(`- no PASS needed: ${decision.tier ? `computed tier ${decision.tier}` : 'a T0/T1 tier or a mechanical release pull request'} (AGENTS.md → Merge gates)`)
if (decision?.mechanicalRelease) lines.push('- a mechanical release pull request: it carries no issue and no `agent-pr` by design')
if (decision?.fork) lines.push('- the head lives in a fork: the issue reference and `agent-pr` rules are not asked of it')
if (decision?.verdict?.by) lines.push(`- newest conclusion at this tip: ${decision.verdict.verdict ?? 'PASS'} by ${decision.verdict.by}`)
if (decision?.recordOnly) lines.push('- the newest conclusion at this head says "record only — do not merge"')
if (!ok) lines.push('', 'What refuses it:', ...(decision?.reasons?.length ? decision.reasons.map((r) => `- ${r}`) : ['- no reason was recorded — treat this as a bug in the gate itself']))
lines.push('', '_One decision, two readers: this check and `node .github/scripts/enqueue-guard.mjs --pr <n>` call the same function. Rules: [`AGENTS.md` → Merge gates](../../AGENTS.md#merge-gates). This check reads the repository and writes this one run; it never merges, labels or comments._')
return lines.join('\n')
}

// One CLI shape for both callers: the guard's reads are `gh('api', …, path)`, and a write has to carry a
// JSON body, so it is `gh([args], JSON.stringify(body))` (the way controller.mjs calls `ghRaw`). Both
// arrive here, and getting this wrong is invisible to a fake that accepts either shape — a live
// `--dry-run` is what caught it once, which is why that run is part of the verification of this file.
const realGh = (...a) => {
const [args, input] = Array.isArray(a[0]) ? [a[0], a[1]] : [a.filter((x) => x !== undefined), undefined]
return execFileSync('gh', args, { encoding: 'utf8', maxBuffer: 256 << 20, input, stdio: ['pipe', 'pipe', 'pipe'] })
}

// `gh` is injectable so a test drives the whole write path without the network: it is called as
// `gh(args, input)`, the way controller.mjs calls `ghRaw`, because the checks API takes a JSON body.
export function applyCheck({ gh = realGh, repo, head, decision } = {}) {
if (!repo) throw new Error('set GH_REPO or pass --repo owner/name')
const runs = gh(['api', '--paginate', '--jq', '.check_runs[]', `repos/${repo}/commits/${head}/check-runs?filter=all&per_page=100`])
.split('\n').filter(Boolean).map((line) => JSON.parse(line))
const plan = planCheck({ runs, head })
const body = payloadFor({ decision, head })
if (plan.action === 'update') {
// `status` goes with `conclusion`: the API rejects a conclusion that is not paired with a completed
// run, and this run always is one.
const patch = { status: 'completed', conclusion: body.conclusion, output: body.output }
gh(['api', '-X', 'PATCH', `repos/${repo}/check-runs/${plan.id}`, '--input', '-'], JSON.stringify(patch))
return { action: 'update', id: plan.id, conclusion: body.conclusion }
}
const created = gh(['api', '-X', 'POST', `repos/${repo}/check-runs`, '--input', '-'], JSON.stringify(body))
let id = null
try { id = JSON.parse(created)?.id ?? null } catch { /* the write succeeded; the id is only for the log */ }
return { action: 'create', id, conclusion: body.conclusion }
}

export function main({ gh = realGh, env = process.env, repo: repoOption, pr: prOption, dryRun } = {}) {
const repo = repoOption || env.GH_REPO
const pr = prOption || env.PR
if (!repo) throw new Error('set GH_REPO or pass --repo owner/name')
if (!pr) throw new Error('set PR or pass --pr N')
const dry = dryRun ?? (Boolean(env.DRY_RUN) || process.argv.includes('--dry-run'))
let decided
try {
decided = decidePull({ gh, env, repo, pr })
} catch (error) {
// `issue_comment` fires for a plain issue too, and the workflow filters those out; a pull request
// that no longer exists is not a head to judge. Leave no check behind: a context nothing will ever
// complete would hold a merge that cannot happen anyway, and hide the real reason.
if (/404|Not Found/.test(String(error?.message ?? ''))) {
console.log(`gate-check: pull request ${pr} is gone; nothing to judge`)
return { ok: true, skipped: true }
}
throw error
}
const { decision, pull } = decided
const head = pull.head?.sha
if (!head) throw new Error(`pull request ${pr} has no head sha`)
const applied = dry ? { action: 'dry-run', id: null, conclusion: payloadFor({ decision, head }).conclusion } : applyCheck({ gh, repo, head, decision })
const summary = summaryOf({ decision, head, pr })
console.log(`gate-check: PR #${pr} head ${short(head)} — ${CHECK_NAME}: ${applied.conclusion} (${applied.action}${applied.id != null ? ` run ${applied.id}` : ''})`)
console.log(`gate-check: ${decision.message}`)
if (env.GITHUB_STEP_SUMMARY) appendFileSync(env.GITHUB_STEP_SUMMARY, summary + '\n')
else console.log('\n' + summary)
return { ...decision, applied }
}

if (process.argv[1] === fileURLToPath(import.meta.url)) {
try { main(); process.exit(0) } catch (e) { console.error(`gate-check: ${e.message}`); process.exit(1) }
}
Loading
Loading