diff --git a/bin/gstack-ship-queue b/bin/gstack-ship-queue new file mode 100755 index 000000000..97658b38c --- /dev/null +++ b/bin/gstack-ship-queue @@ -0,0 +1,155 @@ +#!/usr/bin/env bun +/** + * gstack-ship-queue — the shared merge queue, one subcommand per protocol + * step (field list 2, W1; docs/ship-policy.md#run-a-shared-merge-queue). + * + * gstack-ship-queue ready --pr [--pregate ] + * gstack-ship-queue stamp --pr --version --expect-gated --expect-base + * [--expect-order ] [--allow-repo-commands] [--gate-exit --gate-log ] + * gstack-ship-queue verify --pr (--gated | --record ) [--receipt ] + * [--expect-head ] [--ref ] [--no-fetch] + * gstack-ship-queue gate-ahead --after --pr [--predecessor-ref ] [--allow-repo-commands] + * gstack-ship-queue gate-ahead --record --pr --attempt --exit --log + * gstack-ship-queue gate-ahead --list --pr + * + * Every subcommand takes --base (default origin/HEAD, else main) and + * ends with GSTACK_RESULT: skill=ship-queue status=complete|refused run=. + * gstack-restamp stays the primitive. Nothing here pushes. Logic: + * lib/ship-queue.ts, lib/ship-queue-gate.ts. + */ +import { spawnSync } from 'node:child_process'; +import * as path from 'node:path'; +import { EXIT, exitTableHelp, formatResult } from '../lib/headless-artifacts'; +import { versionAtRef } from '../lib/restamp'; +import { RESULT_CODES } from '../lib/result-codes'; +import { resolveBaseBranch } from '../lib/ship-policy'; +import { loadBasePolicy, ready, stamp, verify, type QueueContext, type StepOutput } from '../lib/ship-queue'; +import { attemptFile, listAttempts, prepareGateAhead, recordGateAhead, type QueueFailure } from '../lib/ship-queue-gate'; + +const HELP = `gstack-ship-queue — the shared merge queue's protocol steps (docs/ship-policy.md#run-a-shared-merge-queue) + + ready --pr [--pregate ] READY # STAMP_VERSION=, CI at that head, PREGATE, gate state + stamp --pr --version --expect-gated --expect-base [--expect-order ] [--allow-repo-commands] + [--gate-exit --gate-log ] + restamp, commit and prove: QUEUE_STAMP: head= gated= tree= receipt= + verify --pr --gated | --record [--receipt ] [--expect-head ] [--ref ] [--no-fetch] + the coordinator's check from the PR's pushed head: VERIFY: ... and MERGE_CONDITION + gate-ahead --after --pr [--predecessor-ref ] [--allow-repo-commands] + prepare an attempt: merged, stamped worktree, gated sha and the gate command + gate-ahead --record --pr --attempt --exit --log + bind the gate result to that attempt's gated tree; removes its worktree + gate-ahead --list --pr attempts for a PR, newest last (the current one) + +--base : base branch (default: origin/HEAD, else main). Nothing here pushes. +${exitTableHelp()}`; + +function flag(args: string[], name: string): string | undefined { + const i = args.indexOf(name); + return i >= 0 ? args[i + 1] : undefined; +} + +function finish(cmd: string, out: StepOutput): number { + for (const line of out.lines) process.stdout.write(line + '\n'); + if (out.failure) { + const f = out.failure; + process.stderr.write(`gstack-ship-queue ${cmd}: ${f.message}; fix: ${f.fix ?? RESULT_CODES[f.code].fix} (${f.code})\n`); + } + process.stdout.write(formatResult('ship-queue', out.ok ? 'complete' : 'refused', out.run) + '\n'); + if (out.ok) return EXIT.ok; + return out.failure && ['QUEUE_VERIFY_MISMATCH', 'QUEUE_STAMP_NOT_STAMP_ONLY'].includes(out.failure.code) ? EXIT.fail : EXIT.refused; +} + +function usage(message: string): number { + process.stderr.write(`gstack-ship-queue: ${message}\n${HELP}\n`); + return EXIT.usage; +} + +function gateAhead(ctx: QueueContext, args: string[]): number { + const pr = flag(args, '--pr'); + if (!pr) return usage('gate-ahead needs --pr (records are keyed by PR)'); + if (args.includes('--list')) { + const all = listAttempts(ctx.repoRoot, pr); + for (const a of all) process.stdout.write(`ATTEMPT: ${a.attempt} state=${a.state} gated_tree=${a.gated_tree} predecessor=#${a.predecessor_pr}@${a.predecessor_head.slice(0, 12)}${a.result ? ` exit=${a.result.exit}` : ''}${a === all.at(-1) ? ' current' : ''}\n`); + if (!all.length) process.stdout.write(`ATTEMPT: none for #${pr}\n`); + return EXIT.ok; + } + if (args.includes('--record')) { + const attempt = flag(args, '--attempt'); + const exit = flag(args, '--exit'); + const log = flag(args, '--log'); + if (!attempt || exit === undefined || !/^-?\d+$/.test(exit) || !log) return usage('gate-ahead --record needs --attempt --exit --log '); + const r = recordGateAhead({ repoRoot: ctx.repoRoot, pr, attempt, exit: Number(exit), log: path.resolve(log) }); + if ('code' in r) return finish('gate-ahead', { ok: false, lines: [], failure: r, run: attemptFile(ctx.repoRoot, pr, attempt) }); + const cur = r.current && r.current.attempt !== attempt ? `no (superseded by ${r.current.attempt})` : 'yes'; + return finish('gate-ahead', { + ok: true, run: path.relative(ctx.repoRoot, r.file), + lines: [ + `GATE_RECORD: pr=${pr} attempt=${attempt} exit=${r.record.result!.exit} gated_tree=${r.record.gated_tree} log_sha256=${r.record.result!.log_sha256} current=${cur}`, + ...(r.repeated ? ['note: identical retry; the stored result is returned'] : []), + `record: ${r.file}`, + `next: send the record file with the READY report; the coordinator runs gstack-ship-queue verify --pr ${pr} --record once this PR is stamped`, + ], + }); + } + const after = flag(args, '--after'); + if (!after) return usage('gate-ahead needs --after , --record or --list'); + const policy = loadBasePolicy(ctx); + if ('message' in policy) return finish('gate-ahead', { ok: false, lines: [], failure: policy, run: 'none' }); + const at = versionAtRef(ctx.repoRoot, 'HEAD'); + const r = prepareGateAhead({ + repoRoot: ctx.repoRoot, base: ctx.base, pr, after, predecessorRef: flag(args, '--predecessor-ref'), allowRepoCommands: args.includes('--allow-repo-commands'), + policy: policy.policy, versionRel: 'rel' in at ? at.rel : 'VERSION', + }); + if ('code' in r) return finish('gate-ahead', { ok: false, lines: [], failure: r as QueueFailure, run: 'none' }); + const rec = r.record; + const log = path.join(path.dirname(rec.worktree), 'gate.log'); + const cmd = rec.gate_command ?? ''; + const superseded = listAttempts(ctx.repoRoot, pr).filter(a => a.attempt !== rec.attempt && a.state === 'prepared').map(a => a.attempt); + return finish('gate-ahead', { + ok: true, run: path.relative(ctx.repoRoot, r.file), + lines: [ + `GATE_AHEAD: pr=${pr} attempt=${rec.attempt} gated=${rec.gated_head} tree=${rec.gated_tree} merge_tree=${rec.merge_tree} predecessor=#${rec.predecessor_pr}@${rec.predecessor_head.slice(0, 12)} version=${rec.version}`, + `worktree: ${rec.worktree}`, + `record: ${r.file}`, + `GATE_COMMAND: ${rec.gate_command ?? 'none (the policy sets no gate_command; run the repo gate you would run on a PR head)'}`, + ...(superseded.length ? [`note: this attempt is now current; earlier unrecorded attempts (${superseded.join(', ')}) keep their worktrees until recorded`] : []), + `next: (cd ${rec.worktree} && ${cmd}) > ${log} 2>&1; gstack-ship-queue gate-ahead --record --pr ${pr} --attempt ${rec.attempt} --exit $? --log ${log}`, + ], + }); +} + +function main(argv: string[]): number { + const [cmd, ...args] = argv; + if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') { process.stdout.write(HELP + '\n'); return cmd ? EXIT.ok : EXIT.usage; } + const probe = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', timeout: 15_000 }); + if (probe.status !== 0) return usage('not inside a git repository'); + const repoRoot = probe.stdout.trim(); + const gh = (a: string[]) => { const r = spawnSync('gh', a, { cwd: repoRoot, encoding: 'utf8', timeout: 60_000 }); return { ok: r.status === 0, out: r.stdout ?? '', err: r.stderr ?? (r.error ? String(r.error.message) : '') }; }; + const ctx: QueueContext = { repoRoot, base: resolveBaseBranch(repoRoot, flag(args, '--base')), gh }; + const pr = flag(args, '--pr'); + if (cmd === 'gate-ahead') return gateAhead(ctx, args); + if (!pr || !/^\d+$/.test(pr)) return usage(`${cmd} needs --pr `); + if (cmd === 'ready') return finish('ready', ready(ctx, { pr, pregate: flag(args, '--pregate') })); + if (cmd === 'stamp') { + const version = flag(args, '--version'); + const expectGated = flag(args, '--expect-gated'); + const expectBase = flag(args, '--expect-base'); + if (!version || !expectGated || !expectBase) return usage('stamp needs --version --expect-gated --expect-base '); + const gateExit = flag(args, '--gate-exit'); + if (gateExit !== undefined && !/^-?\d+$/.test(gateExit)) return usage('--gate-exit needs an exit code'); + return finish('stamp', stamp(ctx, { + pr, version, expectGated, expectBase, expectOrder: flag(args, '--expect-order')?.split(',').map(s => s.trim()).filter(Boolean), + allowRepoCommands: args.includes('--allow-repo-commands'), gateExit: gateExit === undefined ? undefined : Number(gateExit), gateLog: flag(args, '--gate-log'), + })); + } + if (cmd === 'verify') { + const record = flag(args, '--record'); + return finish('verify', verify(ctx, { + pr, gated: flag(args, '--gated'), record: record ? path.resolve(record) : undefined, receipt: flag(args, '--receipt'), + expectHead: flag(args, '--expect-head'), ref: flag(args, '--ref'), noFetch: args.includes('--no-fetch'), + })); + } + return usage(`unknown subcommand ${cmd}`); +} + +process.exitCode = main(process.argv.slice(2)); diff --git a/lib/restamp.ts b/lib/restamp.ts index ad40f5ab2..8d31957a2 100644 --- a/lib/restamp.ts +++ b/lib/restamp.ts @@ -191,7 +191,7 @@ function planVersions(o: RestampOptions, loaded: LoadedPolicy, policyLabel: stri } /** The version writer's own files: the version source, the manifest (root or pinned) and the npm lockfiles beside it. */ -function versionFiles(root: string, versionRel: string): string[] { +export function versionFiles(root: string, versionRel: string): string[] { const pin = path.join(root, '.gstack', 'package-json-path'); const pkgRel = fs.existsSync(pin) ? (fs.readFileSync(pin, 'utf8').split('\n')[0]?.trim() || 'package.json') : 'package.json'; const dir = path.dirname(pkgRel) === '.' ? '' : path.dirname(pkgRel) + '/'; diff --git a/lib/ship-queue-gate.ts b/lib/ship-queue-gate.ts new file mode 100644 index 000000000..f8b0242c3 --- /dev/null +++ b/lib/ship-queue-gate.ts @@ -0,0 +1,232 @@ +/** + * ship-queue-gate — gate-ahead attempts for bin/gstack-ship-queue (field list + * 2, W1), over gstack-restamp's --after primitive (lib/restamp.ts). + * + * Each preparation is an attempt with an immutable id and its own worktree + * under .gstack/tmp/ship-queue/gate-ahead///, so concurrent + * preparations never share or delete each other's trees. The record binds + * the predecessor and branch heads, the merge tree (recomputed with + * `git merge-tree --write-tree` and refused unless it equals the worktree's + * merge), the gated head and tree, the stamp patch between them and the gate + * command. `--record` binds a gate result to that attempt only after its + * worktree still holds the gated commit, retains the log beside the record, + * then removes the attempt's own worktree. The current attempt for a PR is the + * newest preparation; recording an older one never replaces it. The + * coordinator's verify reproduces merge tree and gated tree from the record + * alone (recomputeMergeTree, applyStampPatch), so the gated commit never has + * to leave the lane's machine. + */ +import { createHash, randomBytes } from 'node:crypto'; +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { mergeChangelogs } from './changelog-check'; +import { ensureTmpExcluded, restamp, versionFiles } from './restamp'; +import type { ResultCodeName } from './result-codes'; +import type { ShipPolicy } from './ship-policy'; + +export const QUEUE_DIR_REL = '.gstack/tmp/ship-queue'; +export const GATE_AHEAD_DIR_REL = `${QUEUE_DIR_REL}/gate-ahead`; + +export interface GateResult { exit: number; log: string; log_sha256: string; recorded_at: string } +export interface AttemptRecord { + schema_version: 1; kind: 'gate-ahead'; attempt: string; pr: string; predecessor_pr: string; predecessor_head: string; branch_head: string; + merge_tree: string; gated_head: string; gated_tree: string; version: string; base: { ref: string; sha: string }; policy: string; + gate_command: string | null; command_sha256: string | null; worktree: string; prepared_at: string; stamp_patch: string; + state: 'prepared' | 'recorded'; result?: GateResult; +} +export interface QueueFailure { code: ResultCodeName; message: string; fix?: string } + +export function sha256(buf: Buffer | string): string { return createHash('sha256').update(buf).digest('hex'); } + +export function git(cwd: string, args: string[], env?: NodeJS.ProcessEnv, input?: string | Buffer): { ok: boolean; out: string; err: string; status: number | null } { + const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 120_000, env: env ?? process.env, input, maxBuffer: 64 * 1024 * 1024 }); + return { ok: r.status === 0, out: (r.stdout ?? '').replace(/\n$/, ''), err: (r.stderr ?? '').trim(), status: r.status }; +} + +export function writeJsonAtomic(file: string, value: unknown): void { + fs.mkdirSync(path.dirname(file), { recursive: true }); + const tmp = `${file}.${process.pid}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n'); + fs.renameSync(tmp, file); +} + +function prDir(repoRoot: string, pr: string): string { return path.join(repoRoot, GATE_AHEAD_DIR_REL, pr); } +export function attemptFile(repoRoot: string, pr: string, attempt: string): string { return path.join(prDir(repoRoot, pr), `${attempt}.json`); } + +export function readAttempt(file: string): AttemptRecord | null { + try { const r = JSON.parse(fs.readFileSync(file, 'utf8')); return r?.kind === 'gate-ahead' && r.schema_version === 1 ? r : null; } catch { return null; } +} + +/** Every attempt recorded for a PR, oldest preparation first. */ +export function listAttempts(repoRoot: string, pr: string): AttemptRecord[] { + const dir = prDir(repoRoot, pr); + if (!fs.existsSync(dir)) return []; + return fs.readdirSync(dir).filter(f => f.endsWith('.json')).map(f => readAttempt(path.join(dir, f))).filter((r): r is AttemptRecord => !!r) + .sort((a, b) => a.prepared_at.localeCompare(b.prepared_at) || a.attempt.localeCompare(b.attempt)); +} + +export function currentAttempt(repoRoot: string, pr: string): AttemptRecord | null { + return listAttempts(repoRoot, pr).at(-1) ?? null; +} + +/** + * The gate-ahead merge as `git merge --no-ff` of the branch and the + * predecessor would leave it after restamp's conflict rules: version files + * take the predecessor's side, the CHANGELOG merges through mergeChangelogs, + * any other conflict is unresolvable. Computed in object storage only. + */ +export function recomputeMergeTree(repoRoot: string, branchHead: string, predecessorHead: string, policy: ShipPolicy, versionRel: string): { tree: string } | { error: string } { + const r = git(repoRoot, ['merge-tree', '--write-tree', branchHead, predecessorHead]); + const lines = r.out.split('\n'); + const tree = lines[0]?.trim() ?? ''; + if (r.status === 0 && /^[0-9a-f]{40,64}$/.test(tree)) return { tree }; + if (r.status !== 1 || !/^[0-9a-f]{40,64}$/.test(tree)) return { error: `git merge-tree failed: ${r.err || r.out.slice(0, 200)}` }; + const stages = new Map>(); + for (const line of lines.slice(1)) { + if (!line.trim()) break; + const m = /^(\d{6}) ([0-9a-f]{40,64}) ([123])\t(.+)$/.exec(line); + if (!m) continue; + const entry = stages.get(m[4]!) ?? new Map(); + entry.set(Number(m[3]), m[2]!); + stages.set(m[4]!, entry); + } + const stampFiles = versionFiles(repoRoot, versionRel); + const resolved: Array<{ path: string; blob: string }> = []; + for (const [file, entry] of stages) { + const ours = entry.get(2); + const theirs = entry.get(3); + if (!ours || !theirs) return { error: `${file} conflicts beyond a content merge (added or deleted on one side)` }; + if (file === policy.changelog) { + const text = (sha: string) => git(repoRoot, ['cat-file', 'blob', sha]).out + '\n'; + const merged = mergeChangelogs(text(ours), text(theirs)); + if (merged === null) return { error: `${file} conflicts beyond a heading move` }; + const blob = git(repoRoot, ['hash-object', '-w', '--stdin'], undefined, merged); + if (!blob.ok) return { error: `cannot write the merged ${file}: ${blob.err}` }; + resolved.push({ path: file, blob: blob.out }); + } else if (stampFiles.includes(file)) resolved.push({ path: file, blob: theirs }); + else return { error: `${file} conflicts outside the version files and the CHANGELOG` }; + } + return withIndex(repoRoot, tree, index => { + for (const { path: p, blob } of resolved) { + const mode = git(repoRoot, ['ls-tree', tree, '--', p], index).out.split(/\s/)[0] || '100644'; + const u = git(repoRoot, ['update-index', '--add', '--cacheinfo', `${mode},${blob},${p}`], index); + if (!u.ok) return { error: `cannot stage the resolved ${p}: ${u.err}` }; + } + const w = git(repoRoot, ['write-tree'], index); + return w.ok ? { tree: w.out } : { error: `write-tree failed: ${w.err}` }; + }); +} + +/** The gated tree from a merge tree and the recorded stamp patch, in a throwaway index. */ +export function applyStampPatch(repoRoot: string, mergeTree: string, patch: string): { tree: string } | { error: string } { + if (!patch.trim()) return { tree: mergeTree }; + return withIndex(repoRoot, mergeTree, index => { + const a = git(repoRoot, ['apply', '--cached', '--binary', '--whitespace=nowarn', '-'], index, patch); + if (!a.ok) return { error: `the recorded stamp patch does not apply to the recomputed merge: ${a.err.slice(0, 200)}` }; + const w = git(repoRoot, ['write-tree'], index); + return w.ok ? { tree: w.out } : { error: `write-tree failed: ${w.err}` }; + }); +} + +function withIndex(repoRoot: string, tree: string, fn: (env: NodeJS.ProcessEnv) => T): T | { error: string } { + const gitDir = git(repoRoot, ['rev-parse', '--absolute-git-dir']).out; + const indexFile = path.join(gitDir, `gstack-ship-queue-${process.pid}-${randomBytes(4).toString('hex')}.index`); + const env = { ...process.env, GIT_INDEX_FILE: indexFile }; + try { + const rt = git(repoRoot, ['read-tree', tree], env); + if (!rt.ok) return { error: `read-tree ${tree} failed: ${rt.err}` }; + return fn(env); + } finally { fs.rmSync(indexFile, { force: true }); } +} + +/** A commit object for a bare tree (fixed identity and date), so tree-receipt can compare it. */ +export function commitForTree(repoRoot: string, tree: string, message: string): string | null { + const env = { ...process.env, GIT_AUTHOR_NAME: 'gstack-ship-queue', GIT_AUTHOR_EMAIL: 'ship-queue@gstack.local', GIT_COMMITTER_NAME: 'gstack-ship-queue', + GIT_COMMITTER_EMAIL: 'ship-queue@gstack.local', GIT_AUTHOR_DATE: '2000-01-01T00:00:00Z', GIT_COMMITTER_DATE: '2000-01-01T00:00:00Z' }; + const c = git(repoRoot, ['commit-tree', tree, '-m', message], env); + return c.ok ? c.out : null; +} + +export interface PrepareOptions { + repoRoot: string; base: string; pr: string; after: string; predecessorRef?: string; allowRepoCommands: boolean; + policy: ShipPolicy; versionRel: string; now?: Date; +} + +/** Prepare one attempt: restamp --after into the attempt's own worktree, then bind and verify its record. */ +export function prepareGateAhead(o: PrepareOptions): { record: AttemptRecord; file: string } | QueueFailure { + const now = o.now ?? new Date(); + const attempt = `${now.toISOString().replace(/[-:.]/g, '')}-${randomBytes(3).toString('hex')}`; + const dir = path.join(prDir(o.repoRoot, o.pr), attempt); + const worktree = path.join(dir, 'worktree'); + ensureTmpExcluded(o.repoRoot); + fs.mkdirSync(dir, { recursive: true }); + const r = restamp({ + repoRoot: o.repoRoot, base: o.base, after: { pr: o.after, ref: o.predecessorRef }, pr: o.pr, policyFrom: 'base', + allowRepoCommands: o.allowRepoCommands, dryRun: false, gateWorktree: worktree, now, + }); + if (r.status !== 'gate-ahead' || !r.gateAhead) { + fs.rmSync(dir, { recursive: true, force: true }); + return { code: r.code ?? 'RESTAMP_CONFLICT', message: `${r.message ?? `gstack-restamp --after returned ${r.status}`}`, fix: r.fix }; + } + const ga = r.gateAhead; + const drop = (code: ResultCodeName, message: string): QueueFailure => { + git(o.repoRoot, ['worktree', 'remove', '--force', worktree]); + fs.rmSync(dir, { recursive: true, force: true }); + git(o.repoRoot, ['worktree', 'prune']); + return { code, message }; + }; + const mergeTree = git(o.repoRoot, ['rev-parse', `${ga.gated_head}^^{tree}`]).out; + const recomputed = recomputeMergeTree(o.repoRoot, ga.branch_head, ga.predecessor_head, o.policy, o.versionRel); + if ('error' in recomputed) return drop('QUEUE_PROOF_MISSING', `the gate-ahead merge cannot be recomputed with git merge-tree (${recomputed.error}); nothing recorded`); + if (recomputed.tree !== mergeTree) return drop('QUEUE_PROOF_MISSING', `git merge-tree gives ${recomputed.tree.slice(0, 12)} but the gate-ahead merge is ${mergeTree.slice(0, 12)}; a coordinator could not reproduce it, so nothing is recorded`); + const patch = git(o.repoRoot, ['diff', '--binary', '--full-index', '--no-color', '--no-ext-diff', '--no-renames', mergeTree, ga.gated_tree]).out; + const replay = applyStampPatch(o.repoRoot, mergeTree, patch ? patch + '\n' : ''); + if ('error' in replay || replay.tree !== ga.gated_tree) return drop('QUEUE_PROOF_MISSING', `the stamp patch does not reproduce the gated tree ${ga.gated_tree.slice(0, 12)}; nothing recorded`); + const record: AttemptRecord = { + schema_version: 1, kind: 'gate-ahead', attempt, pr: o.pr, predecessor_pr: ga.predecessor_pr, predecessor_head: ga.predecessor_head, + branch_head: ga.branch_head, merge_tree: mergeTree, gated_head: ga.gated_head, gated_tree: ga.gated_tree, version: ga.version, + base: { ref: r.base?.ref ?? `origin/${o.base}`, sha: r.base?.sha ?? '' }, policy: r.policy ?? '', gate_command: o.policy.gate_command, + command_sha256: o.policy.gate_command ? sha256(o.policy.gate_command) : null, worktree, prepared_at: now.toISOString(), + stamp_patch: patch ? patch + '\n' : '', state: 'prepared', + }; + const file = attemptFile(o.repoRoot, o.pr, attempt); + writeJsonAtomic(file, record); + return { record, file }; +} + +export interface RecordOptions { repoRoot: string; pr: string; attempt: string; exit: number; log: string; now?: Date } + +/** + * Bind a gate result to one attempt: its worktree must still hold the gated + * commit with no tracked edit; the log is copied beside the record before the + * attempt's own worktree is removed. An identical retry returns the stored + * result; a different result for a recorded attempt is refused. + */ +export function recordGateAhead(o: RecordOptions): { record: AttemptRecord; file: string; current: AttemptRecord | null; repeated: boolean } | QueueFailure { + const file = attemptFile(o.repoRoot, o.pr, o.attempt); + const rec = readAttempt(file); + if (!rec || rec.pr !== o.pr || rec.attempt !== o.attempt) return { code: 'GATE_AHEAD_ATTEMPT_INVALID', message: `no gate-ahead attempt ${o.attempt} for #${o.pr}` }; + if (!fs.existsSync(o.log)) return { code: 'GATE_AHEAD_ATTEMPT_INVALID', message: `log ${o.log} does not exist` }; + const logSha = sha256(fs.readFileSync(o.log)); + const current = currentAttempt(o.repoRoot, o.pr); + if (rec.state === 'recorded') { + if (rec.result?.exit === o.exit && rec.result.log_sha256 === logSha) return { record: rec, file, current, repeated: true }; + return { code: 'GATE_AHEAD_ATTEMPT_INVALID', message: `attempt ${o.attempt} already recorded exit ${rec.result?.exit} (log ${rec.result?.log_sha256.slice(0, 12)}); a different result is refused` }; + } + if (!fs.existsSync(rec.worktree)) return { code: 'GATE_AHEAD_ATTEMPT_INVALID', message: `attempt ${o.attempt}'s worktree ${rec.worktree} is gone` }; + const head = git(rec.worktree, ['rev-parse', 'HEAD']).out; + const dirty = git(rec.worktree, ['status', '--porcelain', '--untracked-files=no']).out; + if (head !== rec.gated_head || dirty) { + return { code: 'GATE_AHEAD_ATTEMPT_INVALID', message: `attempt ${o.attempt}'s worktree no longer holds the gated commit ${rec.gated_head.slice(0, 12)} (HEAD ${head.slice(0, 12)}${dirty ? ', tracked edits' : ''}); the gate result is not bound to the gated tree` }; + } + const kept = path.join(prDir(o.repoRoot, o.pr), `${o.attempt}.log`); + fs.copyFileSync(o.log, kept); + rec.result = { exit: o.exit, log: path.relative(o.repoRoot, kept), log_sha256: logSha, recorded_at: (o.now ?? new Date()).toISOString() }; + rec.state = 'recorded'; + writeJsonAtomic(file, rec); + git(o.repoRoot, ['worktree', 'remove', '--force', rec.worktree]); + fs.rmSync(path.dirname(rec.worktree), { recursive: true, force: true }); + git(o.repoRoot, ['worktree', 'prune']); + return { record: rec, file, current, repeated: false }; +} diff --git a/lib/ship-queue.ts b/lib/ship-queue.ts new file mode 100644 index 000000000..bcb961747 --- /dev/null +++ b/lib/ship-queue.ts @@ -0,0 +1,350 @@ +/** + * ship-queue — the shared merge queue's protocol steps (field list 2, W1), + * behind bin/gstack-ship-queue. gstack-restamp stays the primitive; this + * module strings it together with gstack-tree-receipt and the ship receipt + * so every step ends in a line a coordinator can act on: + * + * ready READY # STAMP_VERSION= plus head, base, + * CI at that exact head, the PREGATE line and the gate state; + * refused when the tree is not the pushed PR head. + * stamp preflight (gated head equals HEAD modulo stamps, base, policy, + * gate record under full_e2e), restamp, commit, tree receipt, + * each stage journaled; QUEUE_STAMP: head= gated= + * tree=same-modulo-stamps receipt=. An identical retry + * resumes or returns the stored outcome. Never pushes. + * verify the coordinator's check: fetch the PR head, rerun the tree + * receipt there (and for a gate-ahead record, recompute the + * merge with git merge-tree and rebuild the gated tree from the + * recorded stamp patch), so QUEUE_STAMP is never a pasted claim. + * gate-ahead lib/ship-queue-gate.ts. + */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { readPregateFile, verifyPregate, PREGATE_OUT_REL } from './pregate/index'; +import { ensureTmpExcluded, restamp, versionAtRef } from './restamp'; +import type { ResultCodeName } from './result-codes'; +import { extractReceipt, receiptSummaryLine } from './ship-receipt'; +import { isUnavailable, loadPolicyFromBase, type LoadedPolicy } from './ship-policy'; +import { + QUEUE_DIR_REL, applyStampPatch, commitForTree, currentAttempt, git, listAttempts, recomputeMergeTree, sha256, writeJsonAtomic, + type AttemptRecord, type QueueFailure, +} from './ship-queue-gate'; +import { buildReceipt, type TreeReceipt } from './tree-receipt'; + +export type Gh = (args: string[]) => { ok: boolean; out: string; err?: string }; +export interface QueueContext { repoRoot: string; base: string; gh: Gh; remote?: string } +export interface StepOutput { ok: boolean; lines: string[]; failure?: QueueFailure; run: string } + +const fail = (failure: QueueFailure, lines: string[], run: string): StepOutput => ({ ok: false, lines, failure, run }); + +export function loadBasePolicy(ctx: QueueContext): LoadedPolicy | QueueFailure { + const loaded = loadPolicyFromBase(ctx.repoRoot, ctx.base); + if (isUnavailable(loaded)) return { code: 'POLICY_SOURCE_UNAVAILABLE', message: `policy source unavailable (${loaded.detail})`, fix: `git fetch origin ${ctx.base} --depth=1` }; + if (loaded.errors.length) return { code: loaded.errors[0]!.code, message: `${loaded.source.label}: ${loaded.errors[0]!.message} at ${loaded.errors[0]!.path}` }; + return loaded; +} + +/** + * The receipt's portable digest: the trees, versions, per-file verdicts and + * stamp diff, never a commit id or the machine identity, so the lane's + * QUEUE_STAMP and the coordinator's verify agree exactly when the trees do. + */ +export function receiptDigest(r: TreeReceipt): string { + const files = [...r.files].sort((a, b) => a.path.localeCompare(b.path)).map(f => ({ path: f.path, rule: f.rule, verdict: f.verdict })); + return sha256(JSON.stringify({ gated_tree: r.gated_tree, current_tree: r.current_tree, old_version: r.old_version, new_version: r.new_version, files, tree: r.tree, stamp_diff: r.stamp_diff })); +} + +function treeReceipt(ctx: QueueContext, policy: LoadedPolicy, gated: string, current: string): TreeReceipt | QueueFailure { + const r = buildReceipt({ repoRoot: ctx.repoRoot, gated, current, policy: policy.policy, policyLabel: policy.source.label }); + return 'error' in r ? { code: 'QUEUE_PROOF_MISSING', message: r.error } : r; +} + +function ciAtHead(ctx: QueueContext, sha: string): { line: string; green: boolean } { + const r = ctx.gh(['api', `repos/{owner}/{repo}/commits/${sha}/check-runs?per_page=100`]); + let runs: Array<{ status?: string; conclusion?: string | null }> | null = null; + try { runs = r.ok ? JSON.parse(r.out).check_runs : null; } catch {} + if (!Array.isArray(runs)) return { line: `unknown at ${sha.slice(0, 12)} (gh: ${(r.err ?? r.out).trim().slice(0, 120) || 'no check runs readable'})`, green: false }; + const count = (pred: (c: { status?: string; conclusion?: string | null }) => boolean) => runs!.filter(pred).length; + const pending = count(c => c.status !== 'completed'); + const passed = count(c => c.status === 'completed' && c.conclusion === 'success'); + const skipped = count(c => c.status === 'completed' && (c.conclusion === 'skipped' || c.conclusion === 'neutral')); + const failed = runs.length - pending - passed - skipped; + const green = runs.length > 0 && pending === 0 && failed === 0; + return { line: `${green ? 'green' : pending ? 'pending' : runs.length ? 'red' : 'none'} at ${sha.slice(0, 12)} (passed=${passed} failed=${failed} pending=${pending} skipped=${skipped})`, green }; +} + +function gateLine(policy: LoadedPolicy, attempt: AttemptRecord | null): string { + const p = policy.policy; + const rec = attempt ? `attempt=${attempt.attempt} gated_tree=${attempt.gated_tree.slice(0, 12)} ${attempt.result ? `exit=${attempt.result.exit}` : 'not recorded'}` : 'none'; + return `gate: command=${p.gate_command ? JSON.stringify(p.gate_command) : 'none'} full_e2e=${p.full_e2e ? 'yes' : 'no'} gate-ahead=${rec}`; +} + +/** READY: one block, identifiers first, about the exact pushed PR head. */ +export function ready(ctx: QueueContext, o: { pr: string; pregate?: string }): StepOutput { + const run = PREGATE_OUT_REL; + const policy = loadBasePolicy(ctx); + if ('message' in policy) return fail(policy, [], run); + const head = git(ctx.repoRoot, ['rev-parse', 'HEAD']).out; + const dirty = git(ctx.repoRoot, ['status', '--porcelain', '--untracked-files=no']).out; + if (dirty) return fail({ code: 'QUEUE_NOT_READY', message: `uncommitted tracked changes: ${dirty.split('\n').map(l => l.slice(3)).slice(0, 8).join(', ')}` }, [], run); + const view = ctx.gh(['pr', 'view', o.pr, '--json', 'headRefOid,url,body']); + let info: { headRefOid?: string; url?: string; body?: string } = {}; + try { info = view.ok ? JSON.parse(view.out) : {}; } catch {} + if (!info.headRefOid) return fail({ code: 'QUEUE_NOT_READY', message: `cannot read PR #${o.pr}'s head through gh (${(view.err ?? view.out).trim().slice(0, 120)})` }, [], run); + if (info.headRefOid !== head) return fail({ code: 'QUEUE_NOT_READY', message: `HEAD ${head.slice(0, 12)} is not PR #${o.pr}'s pushed head ${info.headRefOid.slice(0, 12)}` }, [], run); + const atHead = versionAtRef(ctx.repoRoot, head); + const baseSha = git(ctx.repoRoot, ['rev-parse', `origin/${ctx.base}`]).out; + const atBase = versionAtRef(ctx.repoRoot, baseSha); + const version = 'version' in atHead && 'version' in atBase && atHead.version !== atBase.version ? atHead.version : 'unstamped'; + const ci = ciAtHead(ctx, head); + const pregateFile = path.resolve(ctx.repoRoot, o.pregate ?? PREGATE_OUT_REL); + let pregateLine = `PREGATE: missing (${path.relative(ctx.repoRoot, pregateFile)}; run gstack-pregate run --allow-repo-commands)`; + let pregateOk = false; + if (fs.existsSync(pregateFile)) { + const read = readPregateFile(pregateFile); + if (read.file) { + const v = verifyPregate(ctx.repoRoot, read.file); + pregateOk = v.ok; + pregateLine = `PREGATE: ${read.file.verdict} tree=${read.file.tree.slice(0, 12)} head=${(read.file.head ?? '').slice(0, 12)} checks=${read.file.checks.length}${v.ok ? '' : ` — ${v.message} (${v.code})`}`; + } else pregateLine = `PREGATE: unreadable (${read.errors[0]?.message ?? 'invalid'})`; + } + const receipt = extractReceipt(info.body ?? ''); + const attempt = currentAttempt(ctx.repoRoot, o.pr); + const lines = [ + `READY #${o.pr} ${head} STAMP_VERSION=${version}`, + `pr: ${info.url ?? `#${o.pr}`}`, + `head: ${head}`, + `version: ${version}`, + `base: origin/${ctx.base}@${baseSha}`, + `ci: ${ci.line}`, + pregateLine, + gateLine(policy, attempt), + receipt.found && receipt.receipt && !receipt.errors.length ? receiptSummaryLine(receipt.receipt) : `SHIP_RECEIPT: ${receipt.found ? 'invalid' : 'none'} in the PR body`, + ]; + if (!ci.green) return fail({ code: 'QUEUE_NOT_READY', message: `CI is not green at ${head.slice(0, 12)}: ${ci.line}` }, lines, run); + if (!pregateOk) return fail({ code: 'QUEUE_NOT_READY', message: `the pregate result is not a current pass: ${pregateLine}` }, lines, run); + if (policy.policy.full_e2e && version === 'unstamped' && !(attempt?.result?.exit === 0)) { + lines.push('note: full_e2e is set; the stamp needs a passing gate record (gate-ahead --record) or --gate-exit 0 --gate-log on the gated head'); + } + return { ok: true, lines, run }; +} + +export interface StampOptions { + pr: string; version: string; expectGated: string; expectBase: string; expectOrder?: string[]; allowRepoCommands: boolean; + gateExit?: number; gateLog?: string; +} +interface StageRow { name: 'preflight' | 'restamp' | 'commit' | 'receipt'; status: 'done' | 'failed'; detail: string } +interface StampJournal { + schema_version: 1; key: string; inputs: Record; start_head: string; files: string[]; stages: StageRow[]; + gate?: { source: string; exit: number; log_sha256?: string }; outcome?: { head: string; gated: string; receipt_sha256: string; line: string; committed: boolean }; +} + +export function stampJournalRel(pr: string): string { return `${QUEUE_DIR_REL}/stamp-${pr}.json`; } +const stampMessage = (o: StampOptions, base: string) => `v${o.version} chore(release): queue stamp onto ${base} (#${o.pr})`; + +function readJournal(file: string): StampJournal | null { + try { const j = JSON.parse(fs.readFileSync(file, 'utf8')); return j?.schema_version === 1 ? j : null; } catch { return null; } +} + +/** Preflight: nothing is written unless every proof input is present and the tree to stamp is the gated tree modulo stamps. */ +function stampPreflight(ctx: QueueContext, o: StampOptions, policy: LoadedPolicy, head: string, gated: string): QueueFailure | { detail: string; gate?: StampJournal['gate'] } { + if (!/^\d+(\.\d+){2,3}$/.test(o.version)) return { code: 'QUEUE_PROOF_MISSING', message: `--version ${o.version} is not MAJOR.MINOR.PATCH[.MICRO]` }; + const dirty = git(ctx.repoRoot, ['status', '--porcelain', '--untracked-files=no']).out; + if (dirty) return { code: 'QUEUE_PROOF_MISSING', message: `uncommitted tracked changes (${dirty.split('\n').map(l => l.slice(3)).slice(0, 8).join(', ')}); commit or stash them first` }; + const baseSha = git(ctx.repoRoot, ['rev-parse', '--verify', '--quiet', `origin/${ctx.base}^{commit}`]).out; + if (!baseSha) return { code: 'POLICY_SOURCE_UNAVAILABLE', message: `origin/${ctx.base} is not a resolvable commit`, fix: `git fetch origin ${ctx.base}` }; + if (!baseSha.startsWith(o.expectBase)) return { code: 'QUEUE_STALE', message: `base moved: expected ${o.expectBase}, origin/${ctx.base} is ${baseSha}` }; + const pre = treeReceipt(ctx, policy, gated, head); + if ('message' in pre) return pre; + if (pre.tree !== 'same-modulo-stamps') return { code: 'QUEUE_PROOF_MISSING', message: `HEAD differs from the gated head ${gated.slice(0, 12)} beyond stamps (${pre.residual.join(', ')}); the gate does not cover this tree, regate first` }; + const attempt = listAttempts(ctx.repoRoot, o.pr).filter(a => a.gated_head === gated && a.result).at(-1); + let gate: StampJournal['gate']; + if (attempt?.result) gate = { source: `gate-ahead attempt ${attempt.attempt}`, exit: attempt.result.exit, log_sha256: attempt.result.log_sha256 }; + else if (o.gateExit !== undefined) { + if (!o.gateLog || !fs.existsSync(o.gateLog)) return { code: 'QUEUE_PROOF_MISSING', message: '--gate-exit needs --gate-log from that run' }; + gate = { source: 'lane run on the gated head', exit: o.gateExit, log_sha256: sha256(fs.readFileSync(o.gateLog)) }; + } + if (policy.policy.full_e2e && gate?.exit !== 0) { + return { code: 'QUEUE_GATE_MISSING', message: gate ? `the gate on ${gated.slice(0, 12)} exited ${gate.exit} (${gate.source})` : `no gate result bound to ${gated.slice(0, 12)} (gate-ahead --record, or --gate-exit 0 --gate-log )` }; + } + return { detail: `gated ${gated.slice(0, 12)} = HEAD modulo stamps; base ${baseSha.slice(0, 12)}${gate ? `; gate exit ${gate.exit} (${gate.source})` : ''}`, gate }; +} + +/** Commit the restamp's files, or adopt a stamp commit an interrupted run already made. */ +function stampCommit(ctx: QueueContext, o: StampOptions, j: StampJournal): QueueFailure | string { + const head = git(ctx.repoRoot, ['rev-parse', 'HEAD']).out; + const message = stampMessage(o, ctx.base); + if (head !== j.start_head) { + const parent = git(ctx.repoRoot, ['rev-parse', 'HEAD^']).out; + const subject = git(ctx.repoRoot, ['log', '-1', '--format=%s']).out; + if (parent === j.start_head && subject === message) return `adopted ${head.slice(0, 12)} (committed before the journal saved)`; + return { code: 'QUEUE_PROOF_MISSING', message: `HEAD moved from ${j.start_head.slice(0, 12)} to ${head.slice(0, 12)} during the stamp; inspect it, then revert to ${j.start_head.slice(0, 12)} or start over` }; + } + const changed = git(ctx.repoRoot, ['status', '--porcelain', '--untracked-files=all']).out.split('\n').filter(Boolean).map(l => l.slice(3)); + const expected = new Set(j.files); + const stray = changed.filter(p => !expected.has(p) && !p.startsWith('.gstack/tmp/')); + if (stray.length) return { code: 'QUEUE_PROOF_MISSING', message: `the working tree holds edits beyond the restamp's files: ${stray.slice(0, 8).join(', ')}` }; + const add = git(ctx.repoRoot, ['add', '--', ...j.files]); + const commit = add.ok ? git(ctx.repoRoot, ['commit', '-q', '-m', message, '--', ...j.files]) : add; + if (!commit.ok) return { code: 'QUEUE_PROOF_MISSING', message: `git commit failed: ${commit.err.slice(0, 200)}` }; + return `committed ${git(ctx.repoRoot, ['rev-parse', 'HEAD']).out.slice(0, 12)} (${j.files.length} file(s))`; +} + +/** + * QUEUE_STAMP: preflight, restamp, commit, receipt, journaled in + * .gstack/tmp/ship-queue/stamp-.json. The same inputs resume at the first + * unfinished stage or return the stored outcome. Never pushes. + */ +export function stamp(ctx: QueueContext, o: StampOptions): StepOutput { + const run = stampJournalRel(o.pr); + const file = path.join(ctx.repoRoot, run); + ensureTmpExcluded(ctx.repoRoot); + const inputs = { pr: o.pr, version: o.version, expect_gated: o.expectGated, expect_base: o.expectBase, expect_order: o.expectOrder ?? [], base: ctx.base }; + const key = sha256(JSON.stringify(inputs)); + const head0 = git(ctx.repoRoot, ['rev-parse', 'HEAD']).out; + let j = readJournal(file); + if (j?.key === key && j.outcome) { + if (j.outcome.head === head0) return { ok: true, lines: [j.outcome.line, 'note: identical retry; the stored outcome is returned, nothing ran'], run }; + return fail({ code: 'QUEUE_PROOF_MISSING', message: `this stamp completed at ${j.outcome.head.slice(0, 12)} and HEAD is now ${head0.slice(0, 12)}; stamp again with the current inputs` }, [], run); + } + if (j && j.key !== key) { + if (j.stages.some(s => s.name === 'restamp' && s.status === 'done') && !j.outcome) { + return fail({ code: 'QUEUE_PROOF_MISSING', message: `an earlier stamp with other inputs (${JSON.stringify(j.inputs)}) is unfinished; rerun it identically, or revert its files (${j.files.join(', ') || 'none'}) and delete ${run}` }, [], run); + } + j = null; + } + const journal: StampJournal = j ?? { schema_version: 1, key, inputs, start_head: head0, files: [], stages: [] }; + const save = () => writeJsonAtomic(file, journal); + const done = (name: StageRow['name']) => journal.stages.some(s => s.name === name && s.status === 'done'); + const mark = (name: StageRow['name'], status: StageRow['status'], detail: string) => { journal.stages = journal.stages.filter(s => s.name !== name); journal.stages.push({ name, status, detail }); save(); }; + const refusal = (stage: StageRow['name'], failure: QueueFailure): StepOutput => { + mark(stage, 'failed', failure.message); + const now = git(ctx.repoRoot, ['rev-parse', 'HEAD']).out; + return fail(failure, [`QUEUE_STAMP_REFUSED: ${o.pr} head=${now} committed=${now !== journal.start_head ? `yes ${now}` : 'no'} stage=${stage}`, ...journal.stages.map(s => `stage ${s.name}: ${s.status} (${s.detail})`)], run); + }; + const policy = loadBasePolicy(ctx); + if ('message' in policy) return refusal('preflight', policy); + const gated = git(ctx.repoRoot, ['rev-parse', '--verify', '--quiet', `${o.expectGated}^{commit}`]).out; + if (!gated) return refusal('preflight', { code: 'QUEUE_PROOF_MISSING', message: `gated head ${o.expectGated} is not a commit here (fetch it, or name the gate-ahead record's gated_head)` }); + if (!done('preflight')) { + const pre = stampPreflight(ctx, o, policy, head0, gated); + if ('message' in pre) return refusal('preflight', pre); + journal.gate = pre.gate; + mark('preflight', 'done', pre.detail); + } + if (!done('restamp')) { + const r = restamp({ repoRoot: ctx.repoRoot, base: ctx.base, version: o.version, pr: o.pr, expectBase: o.expectBase, expectOrder: o.expectOrder, policyFrom: 'base', allowRepoCommands: o.allowRepoCommands, dryRun: false }); + if (r.status === 'refused' || r.status === 'failed') return refusal('restamp', { code: r.code ?? 'RESTAMP_CONFLICT', message: r.message ?? r.status, fix: r.recompute ?? r.fix }); + journal.files = r.files ?? []; + mark('restamp', 'done', `${r.status}: ${r.oldVersion} -> ${r.newVersion}, ${journal.files.length} file(s)`); + } + if (!done('commit')) { + const c = journal.files.length ? stampCommit(ctx, o, journal) : 'nothing to commit (restamp was a noop)'; + if (typeof c !== 'string') return refusal('commit', c); + mark('commit', 'done', c); + } + const head = git(ctx.repoRoot, ['rev-parse', 'HEAD']).out; + const rcpt = treeReceipt(ctx, policy, gated, head); + if ('message' in rcpt) return refusal('receipt', rcpt); + if (rcpt.tree !== 'same-modulo-stamps') return refusal('receipt', { code: 'QUEUE_STAMP_NOT_STAMP_ONLY', message: `${head.slice(0, 12)} differs from the gated head beyond stamps: ${rcpt.residual.join(', ')}` }); + if (rcpt.new_version !== o.version) return refusal('receipt', { code: 'QUEUE_PROOF_MISSING', message: `the stamped head carries version ${rcpt.new_version}, not ${o.version}` }); + const digest = receiptDigest(rcpt); + const receiptFile = path.join(ctx.repoRoot, QUEUE_DIR_REL, `tree-receipt-${o.pr}.json`); + writeJsonAtomic(receiptFile, rcpt); + const line = `QUEUE_STAMP: ${o.pr} ${o.version} head=${head} gated=${gated} tree=same-modulo-stamps receipt=${digest}`; + journal.outcome = { head, gated, receipt_sha256: digest, line, committed: head !== journal.start_head }; + mark('receipt', 'done', `same-modulo-stamps (${rcpt.files.length} stamp file(s))`); + return { + ok: true, run, + lines: [line, ...journal.stages.map(s => `stage ${s.name}: ${s.status} (${s.detail})`), `receipt-file: ${path.relative(ctx.repoRoot, receiptFile)}`, + `next: push ${head.slice(0, 12)} (this command never pushes), wait for CI on it, then gstack-ship-queue ready --pr ${o.pr}; the coordinator runs gstack-ship-queue verify --pr ${o.pr} --gated ${gated} --receipt ${digest}`], + }; +} + +export interface VerifyOptions { pr: string; gated?: string; record?: string; receipt?: string; expectHead?: string; ref?: string; noFetch?: boolean } + +/** Rebuild a gate-ahead record's gated tree from the PR's own commits: merge-tree, then the recorded stamp patch. */ +function reproduceGateAhead(ctx: QueueContext, policy: LoadedPolicy, rec: AttemptRecord, head: string): QueueFailure | { gatedCommit: string; lines: string[] } { + const mismatch = (message: string): QueueFailure => ({ code: 'QUEUE_VERIFY_MISMATCH', message }); + for (const [what, sha] of [['branch head', rec.branch_head], ['predecessor head', rec.predecessor_head]] as const) { + if (!git(ctx.repoRoot, ['cat-file', '-e', `${sha}^{commit}`]).ok) return { code: 'QUEUE_PROOF_MISSING', message: `the record's ${what} ${sha.slice(0, 12)} is not here after the fetch` }; + } + const predNow = git(ctx.repoRoot, ['rev-parse', '--verify', '--quiet', `refs/gstack/queue/pr-${rec.predecessor_pr}`]).out; + if (predNow && predNow !== rec.predecessor_head) return mismatch(`PREDECESSOR MOVED: #${rec.predecessor_pr} is at ${predNow.slice(0, 12)}, the gate assumed ${rec.predecessor_head.slice(0, 12)}`); + const at = versionAtRef(ctx.repoRoot, head); + const merged = recomputeMergeTree(ctx.repoRoot, rec.branch_head, rec.predecessor_head, policy.policy, 'rel' in at ? at.rel : 'VERSION'); + if ('error' in merged) return mismatch(`git merge-tree cannot recompute the gate-ahead merge: ${merged.error}`); + if (merged.tree !== rec.merge_tree) return mismatch(`git merge-tree gives ${merged.tree.slice(0, 12)}, the record says ${rec.merge_tree.slice(0, 12)}`); + const gatedTree = applyStampPatch(ctx.repoRoot, merged.tree, rec.stamp_patch); + if ('error' in gatedTree) return mismatch(gatedTree.error); + if (gatedTree.tree !== rec.gated_tree) return mismatch(`the merge plus the recorded stamp patch gives ${gatedTree.tree.slice(0, 12)}, not the recorded gated tree ${rec.gated_tree.slice(0, 12)}`); + const mergeCommit = commitForTree(ctx.repoRoot, merged.tree, `recomputed gate-ahead merge for #${rec.pr}`); + const gatedCommit = commitForTree(ctx.repoRoot, gatedTree.tree, `recomputed gated tree for #${rec.pr}`); + if (!mergeCommit || !gatedCommit) return { code: 'QUEUE_PROOF_MISSING', message: 'cannot write the recomputed commits' }; + const stampOnly = treeReceipt(ctx, policy, mergeCommit, gatedCommit); + if ('message' in stampOnly) return stampOnly; + if (stampOnly.tree !== 'same-modulo-stamps') return mismatch(`the recorded stamp patch changes more than stamps: ${stampOnly.residual.join(', ')}`); + if (rec.result && rec.result.exit !== 0) return { code: 'QUEUE_GATE_MISSING', message: `the recorded gate on ${rec.gated_tree.slice(0, 12)} exited ${rec.result.exit}` }; + if (policy.policy.full_e2e && !rec.result) return { code: 'QUEUE_GATE_MISSING', message: `attempt ${rec.attempt} has no recorded gate result` }; + return { + gatedCommit, + lines: [ + `merge_tree: ${merged.tree} reproduced by git merge-tree --write-tree ${rec.branch_head.slice(0, 12)} ${rec.predecessor_head.slice(0, 12)} (#${rec.predecessor_pr})`, + `gated_tree: ${gatedTree.tree} reproduced from the recorded stamp patch (stamps only, ${stampOnly.files.length} file(s))`, + `gate: ${rec.result ? `exit ${rec.result.exit} log_sha256=${rec.result.log_sha256}` : 'not recorded'} (attempt ${rec.attempt})`, + ], + }; +} + +/** The coordinator's check of a lane's stamp claim, from the PR's pushed commits alone. */ +export function verify(ctx: QueueContext, o: VerifyOptions): StepOutput { + const run = `refs/gstack/queue/pr-${o.pr}`; + const remote = ctx.remote ?? 'origin'; + let rec: AttemptRecord | null = null; + if (o.record) { + try { rec = JSON.parse(fs.readFileSync(o.record, 'utf8')); } catch {} + if (rec?.kind !== 'gate-ahead' || rec.schema_version !== 1) return fail({ code: 'QUEUE_PROOF_MISSING', message: `${o.record} is not a gate-ahead record` }, [], run); + if (rec.pr !== o.pr) return fail({ code: 'QUEUE_VERIFY_MISMATCH', message: `the record is for #${rec.pr}, not #${o.pr}` }, [], run); + } + if (!o.gated && !rec) return fail({ code: 'QUEUE_PROOF_MISSING', message: 'pass --gated (the head the gate ran on) or --record ' }, [], run); + if (!o.noFetch) { + const specs = [`+${o.ref ?? `refs/pull/${o.pr}/head`}:${run}`, `+refs/heads/${ctx.base}:refs/remotes/${remote}/${ctx.base}`]; + if (rec) specs.push(`+refs/pull/${rec.predecessor_pr}/head:refs/gstack/queue/pr-${rec.predecessor_pr}`); + const f = git(ctx.repoRoot, ['fetch', '--no-tags', '-q', remote, ...specs]); + if (!f.ok) return fail({ code: 'QUEUE_PROOF_MISSING', message: `git fetch ${remote} failed: ${f.err.slice(0, 200)}` }, [], run); + } + const head = git(ctx.repoRoot, ['rev-parse', '--verify', '--quiet', run]).out; + if (!head) return fail({ code: 'QUEUE_PROOF_MISSING', message: `PR #${o.pr}'s head is not fetched (${run})` }, [], run); + if (o.expectHead && !head.startsWith(o.expectHead)) return fail({ code: 'QUEUE_VERIFY_MISMATCH', message: `PR #${o.pr}'s head is ${head.slice(0, 12)}, the lane reported ${o.expectHead}` }, [], run); + const policy = loadBasePolicy(ctx); + if ('message' in policy) return fail(policy, [], run); + const lines: string[] = []; + let gatedCommit: string; + let gatedLabel: string; + if (rec) { + if (o.gated && !rec.gated_tree.startsWith(o.gated) && !rec.gated_head.startsWith(o.gated)) return fail({ code: 'QUEUE_VERIFY_MISMATCH', message: `--gated ${o.gated} is neither the record's gated head nor its gated tree` }, [], run); + const g = reproduceGateAhead(ctx, policy, rec, head); + if ('message' in g) return fail(g, [], run); + gatedCommit = g.gatedCommit; + gatedLabel = rec.gated_head; + lines.push(...g.lines); + } else { + let sha = git(ctx.repoRoot, ['rev-parse', '--verify', '--quiet', `${o.gated}^{commit}`]).out; + if (!sha && !o.noFetch) { git(ctx.repoRoot, ['fetch', '--no-tags', '-q', remote, o.gated!]); sha = git(ctx.repoRoot, ['rev-parse', '--verify', '--quiet', `${o.gated}^{commit}`]).out; } + if (!sha) return fail({ code: 'QUEUE_PROOF_MISSING', message: `gated head ${o.gated} is not reachable from ${remote}; a gate-ahead claim needs --record` }, [], run); + gatedCommit = sha; + gatedLabel = sha; + lines.push(`gate: ${policy.policy.full_e2e ? 'lane-reported (a plain stamp carries no gate record to check)' : 'not required by the policy'}`); + } + const rcpt = treeReceipt(ctx, policy, gatedCommit, head); + if ('message' in rcpt) return fail(rcpt, lines, run); + if (rcpt.tree !== 'same-modulo-stamps') return fail({ code: 'QUEUE_VERIFY_MISMATCH', message: `PR #${o.pr}'s head ${head.slice(0, 12)} differs from the gated tree beyond stamps: ${rcpt.residual.join(', ')}` }, lines, run); + const digest = receiptDigest(rcpt); + if (o.receipt && digest !== o.receipt) return fail({ code: 'QUEUE_VERIFY_MISMATCH', message: `the receipt digest here is ${digest}, the lane reported ${o.receipt}` }, lines, run); + return { + ok: true, run, + lines: [`VERIFY: ${o.pr} ${rcpt.new_version ?? '-'} head=${head} gated=${gatedLabel} tree=same-modulo-stamps receipt=${digest}`, ...lines, + ...rcpt.files.map(f => ` ${f.verdict} ${f.path} (${f.rule})`), `MERGE_CONDITION: sha=${head}`], + }; +} +