feat(ops98): veto audit/* no safety-net de auto-merge (PR único do audit fica sem automerge)

This commit is contained in:
Francisco Solla 2026-08-25 01:27:32 -03:00
parent 0cea0b7d8f
commit b7ed186237
4 changed files with 44 additions and 3 deletions

View file

@ -5,6 +5,8 @@
# non-`cursor/*` branches are never touched — the draft flag is the actor's
# veto (the CLI skips them). Fork PRs are excluded (the repo is public; a
# merged fork PR would deploy) — same RCE-guard class as the other workflows.
# OPS98: `audit/*` branches (engineering-audit overnight run) are also skipped —
# that single Ready PR merges by human hand only.
#
# GitHub's NATIVE auto-merge replaces the Forgejo-era poll CLI (removed in
# OPS71 Fase 2): `enablePullRequestAutoMerge` (GraphQL, REBASE —

View file

@ -13,7 +13,9 @@
* mergeable. The OPS64 "never trust the rollup" pin is structural here: a
* single sequential job posts one honest check-run, and the server enforces
* it. Draft policy (OPS57) preserved: only `cursor/*` heads are marked
* ready — any other draft is the actor's veto (skip, exit 0).
* ready — any other draft is the actor's veto (skip, exit 0). OPS98: `audit/*`
* heads are vetoed entirely (ready or draft) — the engineering-audit single PR
* merges by human hand only.
*
* Idempotent: already merged / closed / non-main base → no-op success.
* A GraphQL error (e.g. repo auto-merge disabled, PR in conflict) exits 1 so

View file

@ -6,7 +6,8 @@
*
* GitHub's native auto-merge only fires with the required checks green, so
* the safety net's only real decisions are: skip (non-main base, draft veto,
* already merged) or mark-ready (cursor/* draft) before arming auto-merge.
* already merged, audit/* veto) or mark-ready (cursor/* draft) before arming
* auto-merge.
*/
export const CURSOR_HEAD_PREFIX = 'cursor/'
@ -14,6 +15,15 @@ export const CURSOR_HEAD_PREFIX = 'cursor/'
/** OPS57: only `cursor/*` drafts get force-marked ready — any other draft is the actor's veto. */
export const isCursorHead = (ref) => typeof ref === 'string' && ref.startsWith(CURSOR_HEAD_PREFIX)
/**
* OPS98: branches `audit/*` belong to the engineering-audit overnight run —
* its single PR must stay Ready WITHOUT auto-merge (the human is the final
* gate). The veto is dominant: it applies to ready and draft states alike.
*/
export const AUDIT_HEAD_PREFIX = 'audit/'
export const isAuditHead = (ref) => typeof ref === 'string' && ref.startsWith(AUDIT_HEAD_PREFIX)
/**
* OPS71-FLIP: the auto-merge MUST be armed with a real user PAT. Arming with
* the built-in GITHUB_TOKEN makes the merge happen as `github-actions[bot]`,
@ -41,6 +51,7 @@ export const automergeArmingToken = (env = process.env) => {
*/
export const decideAutomergeAction = (pr) => {
if (!pr) return { action: 'skip', reason: 'pr-inexistente' }
if (isAuditHead(pr.head?.ref)) return { action: 'skip', reason: 'audit-veto' }
if (pr.merged) return { action: 'skip', reason: 'ja-mergeada' }
if (pr.state !== 'OPEN') return { action: 'skip', reason: 'pr-nao-aberta' }
if (pr.base?.ref !== 'main') return { action: 'skip', reason: 'base-nao-main' }

View file

@ -1,9 +1,11 @@
import { describe, expect, it } from 'vitest'
import {
AUDIT_HEAD_PREFIX,
CURSOR_HEAD_PREFIX,
automergeArmingToken,
decideAutomergeAction,
isAuditHead,
isCursorHead,
} from '../../scripts/lib/github-pr-flow.mjs'
@ -66,6 +68,29 @@ describe('github-pr-flow automerge decision (OPS71)', () => {
expect(isCursorHead(undefined)).toBe(false)
})
it('skips a ready audit/* PR — the single-PR overnight delivery (OPS98)', () => {
const verdict = decideAutomergeAction(
pr({ head: { ref: `${AUDIT_HEAD_PREFIX}pass-6`, sha: 'abc' } }),
)
expect(verdict).toEqual({ action: 'skip', reason: 'audit-veto' })
})
it('audit/* veto dominates the draft logic — a draft audit/* never becomes mark-ready', () => {
const verdict = decideAutomergeAction(
pr({ draft: true, head: { ref: `${AUDIT_HEAD_PREFIX}pass-6`, sha: 'abc' } }),
)
expect(verdict).toEqual({ action: 'skip', reason: 'audit-veto' })
})
it('isAuditHead only matches the exact audit/ prefix', () => {
expect(isAuditHead('audit/pass-6')).toBe(true)
expect(isAuditHead('OPS71-x')).toBe(false)
expect(isAuditHead('cursor/x')).toBe(false)
expect(isAuditHead('auditoria/x')).toBe(false)
expect(isAuditHead('')).toBe(false)
expect(isAuditHead(undefined)).toBe(false)
})
it('the merge contract has no poll path: ready always arms native auto-merge', () => {
// OPS64 pin, GitHub shape: the Forgejo rollup-lie bug is structural here
// (single job → one honest check-run), and the server only merges with
@ -73,8 +98,9 @@ describe('github-pr-flow automerge decision (OPS71)', () => {
const actions = [
decideAutomergeAction(pr()).action,
decideAutomergeAction(pr({ draft: true, head: { ref: 'cursor/x', sha: 'a' } })).action,
decideAutomergeAction(pr({ head: { ref: 'audit/pass-6', sha: 'a' } })).action,
]
expect(actions).toEqual(['enable-auto-merge', 'mark-ready'])
expect(actions).toEqual(['enable-auto-merge', 'mark-ready', 'skip'])
})
it('automergeArmingToken accepts a non-empty AUTOMERGE_PAT (OPS71-FLIP)', () => {