From 567e794cfeb25ae090905fec66941898aab0dfa6 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 04:13:11 +0000 Subject: [PATCH 01/31] feat(ship-policy): changelog_fragments key; restamp folds fragments; tree receipt accepts the fold Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- lib/changelog-fragments.ts | 125 ++++++++++++++++++++++++++++ lib/restamp.ts | 22 ++++- lib/result-codes-release.ts | 26 ++++++ lib/ship-policy.ts | 15 ++++ lib/tree-receipt.ts | 24 +++++- test/changelog-fragments.test.ts | 138 +++++++++++++++++++++++++++++++ 6 files changed, 344 insertions(+), 6 deletions(-) create mode 100644 lib/changelog-fragments.ts create mode 100644 test/changelog-fragments.test.ts diff --git a/lib/changelog-fragments.ts b/lib/changelog-fragments.ts new file mode 100644 index 000000000..6bde412fb --- /dev/null +++ b/lib/changelog-fragments.ts @@ -0,0 +1,125 @@ +/** + * changelog-fragments — unversioned CHANGELOG fragments (docs/ship-policy.md#changelog-fragments). + * Under the policy key `changelog_fragments: { dir }` a PR adds `/.md` instead of an + * `[Unreleased]` section and leaves its release-version fields at its merge base's values; the + * merge step folds every fragment into the CHANGELOG under the version it allocates and deletes the + * fragment files. Fragments are read as Git blobs, never through the filesystem: only regular files + * (mode 100644) directly under `dir` count, so a symlink, gitlink or nested path cannot pull bytes + * from outside the repository into a stamp. + */ +import { spawnSync } from 'node:child_process'; +import { headingVersion, parseChangelog } from './changelog-check'; +import type { Bump } from './version-source'; + +export const FRAGMENT_README = 'README.md'; +export const MAX_FRAGMENT_BYTES = 64 * 1024; +const BUMP_RANK: Record = { micro: 0, patch: 1, minor: 2, major: 3 }; + +export interface Fragment { path: string; body: string; bump: Bump | null } +export interface FragmentError { code: 'CHANGELOG_FRAGMENT_INVALID'; path: string; message: string } +export interface FragmentSet { fragments: Fragment[]; errors: FragmentError[] } + +function git(cwd: string, args: string[]): { ok: boolean; out: string } { + const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 }); + return { ok: r.status === 0, out: r.stdout ?? '' }; +} + +/** The fragment file name /ship writes for a branch: one flat path segment. */ +export function fragmentSlug(branch: string): string { + const slug = branch.replace(/[\\/]+/g, '-').replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[-.]+|[-.]+$/g, '').slice(0, 100); + return slug || 'change'; +} + +/** One fragment's text: optional `---` front matter holding only `bump:`, then a non-empty body with no release heading. */ +export function parseFragment(rel: string, text: string): Fragment | FragmentError { + const bad = (message: string): FragmentError => ({ code: 'CHANGELOG_FRAGMENT_INVALID', path: rel, message }); + const normalized = text.replace(/\r\n/g, '\n'); + let body = normalized; + let bump: Bump | null = null; + const fm = /^---\n([\s\S]*?)\n---\n?/.exec(normalized); + if (fm) { + body = normalized.slice(fm[0].length); + for (const line of fm[1]!.split('\n').filter(l => l.trim())) { + const kv = /^\s*bump\s*:\s*['"]?(micro|patch|minor|major)['"]?\s*$/.exec(line); + if (!kv) return bad(`front matter may hold only "bump: micro|patch|minor|major", found ${JSON.stringify(line.trim().slice(0, 60))}`); + bump = kv[1] as Bump; + } + } + const lines = body.split('\n'); + while (lines.length && !lines[0]!.trim()) lines.shift(); + while (lines.length && !lines[lines.length - 1]!.trim()) lines.pop(); + if (!lines.length) return bad('the fragment has no entry text'); + const heading = lines.find(l => /^##\s/.test(l) && headingVersion(l)); + if (heading) return bad(`a fragment carries no release heading (the merge step writes it), found ${JSON.stringify(heading.trim())}`); + return { path: rel, body: lines.join('\n'), bump }; +} + +/** + * The fragments in `dir` of a tree-ish (a commit or tree id), or of the index when `treeish` is + * null. `exclude` names a tree-ish whose fragments are not this change's own (the base branch, or a + * stacked pin's parent): a path present there with the same blob is skipped, a path present there + * with other bytes is an error. + */ +export function readFragments(cwd: string, treeish: string | null, dir: string, exclude?: string | null): FragmentSet { + const prefix = dir.replace(/\/+$/, '') + '/'; + const list = (t: string | null) => { + const r = t === null ? git(cwd, ['ls-files', '-s', '-z', '--', prefix]) : git(cwd, ['ls-tree', '-r', '-z', '--full-tree', t, '--', prefix]); + const out: Array<{ mode: string; sha: string; path: string }> = []; + for (const rec of r.out.split('\0').filter(Boolean)) { + const tab = rec.indexOf('\t'); + const meta = rec.slice(0, tab).split(/\s+/); + out.push(t === null ? { mode: meta[0]!, sha: meta[1]!, path: rec.slice(tab + 1) } : { mode: meta[0]!, sha: meta[2]!, path: rec.slice(tab + 1) }); + } + return out; + }; + const excluded = new Map((exclude ? list(exclude) : []).map(e => [e.path, e.sha])); + const fragments: Fragment[] = []; + const errors: FragmentError[] = []; + for (const e of list(treeish).sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))) { + const rest = e.path.slice(prefix.length); + if (rest === FRAGMENT_README) continue; + const err = (message: string) => errors.push({ code: 'CHANGELOG_FRAGMENT_INVALID', path: e.path, message }); + if (rest.includes('/')) { err(`fragments live directly in ${prefix} (one flat file each), not in a subdirectory`); continue; } + if (e.mode !== '100644') { err(`must be a regular file (mode 100644), found mode ${e.mode}${e.mode === '120000' ? ' (symlink)' : e.mode === '160000' ? ' (gitlink)' : ''}`); continue; } + if (!rest.endsWith('.md')) { err('a fragment is a .md file'); continue; } + const prior = excluded.get(e.path); + if (prior === e.sha) continue; + if (prior !== undefined) { err('a fragment of this name already exists on the base with other text; fragments are never edited after they land'); continue; } + const size = git(cwd, ['cat-file', '-s', e.sha]); + if (!size.ok || Number(size.out.trim()) > MAX_FRAGMENT_BYTES) { err(`larger than ${MAX_FRAGMENT_BYTES} bytes or unreadable`); continue; } + const blob = git(cwd, ['cat-file', 'blob', e.sha]); + if (!blob.ok) { err('blob unreadable'); continue; } + const parsed = parseFragment(e.path, blob.out); + if ('code' in parsed) errors.push(parsed); else fragments.push(parsed); + } + return { fragments, errors }; +} + +/** The highest of the policy bump and every fragment's declared bump. */ +export function effectiveBump(level: Bump, fragments: readonly Fragment[]): Bump { + return fragments.reduce((acc, f) => (f.bump && BUMP_RANK[f.bump] > BUMP_RANK[acc] ? f.bump : acc), level); +} + +/** + * Put the fragment bodies, in path order, into the CHANGELOG's `[Unreleased]` section (created at the + * top when absent), so the existing re-heading moves fragments and a legacy entry together under one + * version. Returns the text unchanged when there are no fragments. + */ +export function foldIntoUnreleased(text: string, fragments: readonly Fragment[]): string { + if (!fragments.length) return text; + const parsed = parseChangelog(text); + const bodies = fragments.map(f => f.body).join('\n\n'); + const unreleased = parsed.sections.find(s => s.unreleased); + const lines = text.replace(/\r\n/g, '\n').split('\n'); + if (unreleased) { + const own = lines.slice(unreleased.start + 1, unreleased.end); + while (own.length && !own[0]!.trim()) own.shift(); + while (own.length && !own[own.length - 1]!.trim()) own.pop(); + const merged = [lines[unreleased.start]!, '', ...bodies.split('\n'), ...(own.length ? ['', ...own] : []), '']; + return [...lines.slice(0, unreleased.start), ...merged, ...lines.slice(unreleased.end)].join('\n'); + } + const at = parsed.sections[0]?.start ?? lines.length; + const head = lines.slice(0, at); + while (head.length && !head[head.length - 1]!.trim()) head.pop(); + return [...head, ...(head.length ? [''] : []), '## [Unreleased]', '', ...bodies.split('\n'), '', ...lines.slice(at)].join('\n'); +} diff --git a/lib/restamp.ts b/lib/restamp.ts index 1a4d18e23..f8e1ca942 100644 --- a/lib/restamp.ts +++ b/lib/restamp.ts @@ -26,6 +26,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { bunIsolationArgs } from './bun-isolation'; import { checkChangelog, mergeChangelogs, parseChangelog, reheadChangelog } from './changelog-check'; +import { effectiveBump, foldIntoUnreleased, readFragments, type FragmentSet } from './changelog-fragments'; import { type ResultCodeName } from './result-codes'; import { runScriptSync, wtreeFingerprint } from './script-invocation'; import { @@ -491,7 +492,11 @@ function runStages(o: RestampOptions, plan: Plan, staging: string, since: string if (clPath && fs.existsSync(clPath)) { const date = (o.now ?? new Date()).toISOString().slice(0, 10); const from = o.was ?? plan.branchStamp; - const r = reheadChangelog(fs.readFileSync(clPath, 'utf8'), { from, to: plan.newVersion, date, file: clRel! }); + const dir = plan.policy.changelog_fragments?.dir; + const frags = dir ? readFragments(staging, null, dir, plan.baseSha) : null; + if (frags?.errors.length) { cl.status = 'failed'; cl.detail = `${frags.errors[0]!.path}: ${frags.errors[0]!.message}`; throw new Refusal('CHANGELOG_FRAGMENT_INVALID', `${cl.detail}; nothing written to the branch`); } + for (const f of frags?.fragments ?? []) fs.rmSync(path.join(staging, f.path)); + const r = reheadChangelog(foldIntoUnreleased(fs.readFileSync(clPath, 'utf8'), frags?.fragments ?? []), { from, to: plan.newVersion, date, file: clRel! }); if (r.error) { cl.status = 'failed'; cl.detail = r.error.message; throw new Refusal(r.error.code, `${clRel}: ${r.error.message}; nothing written to the branch`); } if (r.moved) fs.writeFileSync(clPath, r.text); // Inherited duplicates are allowed at the larger count of two base readings: the base tip and @@ -506,7 +511,7 @@ function runStages(o: RestampOptions, plan: Plan, staging: string, since: string : `; base ${clRel} unreadable at ${unreadable.join(', ')}, inherited headings counted at ${readable.map(x => x.sha.slice(0, 12)).join(', ')} only`; const check = checkChangelog(fs.readFileSync(clPath, 'utf8'), plan.newVersion, clRel!, readable.map(x => x.cl.out + '\n')); if (check.length) { cl.status = 'failed'; cl.detail = check[0]!.message + baseNote; throw new Refusal(check[0]!.code, `${check[0]!.path}: ${check[0]!.message}${baseNote}; nothing written to the branch`); } - cl.status = 'done'; cl.detail = (r.moved ? `${r.from} -> ${r.to}` : `already headed ${plan.newVersion}`) + baseNote; + cl.status = 'done'; cl.detail = (r.moved ? `${r.from} -> ${r.to}` : `already headed ${plan.newVersion}`) + (frags?.fragments.length ? `; folded ${frags.fragments.map(f => f.path).join(', ')}` : '') + baseNote; } else { cl.status = 'skipped'; cl.detail = clRel ? `${clRel} absent` : 'changelog: null'; } injected('changelog'); @@ -646,6 +651,15 @@ function applyToBranch(o: RestampOptions, staging: string, wtree: string, files: return writes; } +/** The fragments this change adds (absent from origin/), read from the working-tree fingerprint the staging worktree is seeded from. */ +function fragmentsOf(o: RestampOptions, dir: string): FragmentSet { + const fp = wtreeFingerprint({ cwd: o.repoRoot }); + if ('error' in fp) throw new Refusal('RESTAMP_CONFLICT', `cannot fingerprint the working tree (${fp.error}); nothing written`); + const set = readFragments(o.repoRoot, fp.tree, dir, `origin/${o.base}`); + if (set.errors.length) throw new Refusal('CHANGELOG_FRAGMENT_INVALID', `${set.errors[0]!.path}: ${set.errors[0]!.message}; nothing written`); + return set; +} + function removeWorktree(repoRoot: string, dir: string): void { git(repoRoot, ['worktree', 'remove', '--force', dir]); fs.rmSync(dir, { recursive: true, force: true }); @@ -662,7 +676,9 @@ export function restamp(o: RestampOptions): RestampResult { if (commands.length && !o.allowRepoCommands) { throw new Refusal('REPO_COMMANDS_NOT_ALLOWED', `repo commands not executed (pass --allow-repo-commands): ${commands.map(c => `${c.key}: ${c.command}`).join('; ')}; nothing written`, { commands }); } - const plan = planVersions(o, loaded, label); + const fragDir = loaded.policy.changelog_fragments?.dir ?? null; + const frags = fragDir ? fragmentsOf(o, fragDir) : null; + const plan = planVersions(frags && !o.bump ? { ...o, bump: effectiveBump(loaded.policy.bump === 'patch' ? 'patch' : 'micro', frags.fragments) } : o, loaded, label); const headSha = git(o.repoRoot, ['rev-parse', 'HEAD']).out; const common: Partial = { oldVersion: plan.oldVersion, newVersion: plan.newVersion, base: { ref: plan.baseRef, sha: plan.baseSha }, policy: plan.policyLabel, diff --git a/lib/result-codes-release.ts b/lib/result-codes-release.ts index ee4004adb..c508bc315 100644 --- a/lib/result-codes-release.ts +++ b/lib/result-codes-release.ts @@ -299,6 +299,32 @@ export const RELEASE_RESULT_CODES = { summary: 'gstack-ship-receipt carry needs git 2.39 or later for `git patch-id --verbatim`; the installed git is older, so the product diff could not be compared', fix: 'upgrade git to 2.39 or later (the refusal names the installed version), then rerun carry; otherwise run /ship on the new head', }, + // Changelog fragments and the per-PR merge step (lib/changelog-fragments.ts, lib/ship-queue-land.ts). + CHANGELOG_FRAGMENT_INVALID: { + anchor: 'changelog-fragment-invalid', + summary: 'a CHANGELOG fragment is not one regular .md file directly in the fragment directory with entry text, optional bump front matter and no release heading, or one of that name already landed with other text', + fix: 'rewrite the named fragment as a flat /.md with only the entry text (and optional "bump: minor|major" front matter), rename it if the name already landed, then rerun', + }, + QUEUE_LAND_LOCKED: { + anchor: 'queue-land-locked', + summary: 'another gstack-ship-queue land holds the repository land lock (refs/notes/gstack-land), so this one would race it for the next version', + fix: 'wait for the holder named in the line to finish; if it crashed, gstack-ship-queue land --pr --break-lock (refused while its journal shows a push in flight)', + }, + QUEUE_LAND_CONFLICT: { + anchor: 'queue-land-conflict', + summary: 'the PR conflicts with the base branch in product files (or a release field cannot be resolved alone), so the merge step cannot stamp it', + fix: 'the lane merges the base branch into the PR, resolves the named files, runs /ship and reports READY again', + }, + QUEUE_LAND_DRIFT: { + anchor: 'queue-land-drift', + summary: 'the base branch moved between the merge step\'s last check and the squash merge, so main\'s tree is not the stamped tree land verified', + fix: 'run the printed gstack-ship-queue land --repair to renumber main\'s top entry through a stamp-only PR; the land lock stays held with the record until it merges', + }, + QUEUE_LAND_BASE_BUSY: { + anchor: 'queue-land-base-busy', + summary: 'the base branch moved during the stamp commit\'s CI on each of the allowed restamp attempts, so land stopped instead of restamping again', + fix: 'find what is merging outside land (every merge should go through it), then rerun gstack-ship-queue land --pr ; it resumes from its journal', + }, // gstack-pr-wait (lib/pr-wait.ts): the landing-path PR watcher's two terminal codes. PR_WAIT_BLOCKED: { anchor: 'pr-wait-blocked', diff --git a/lib/ship-policy.ts b/lib/ship-policy.ts index 8575cbdbd..5159d153d 100644 --- a/lib/ship-policy.ts +++ b/lib/ship-policy.ts @@ -41,6 +41,8 @@ export interface ShipPolicy { stamp_paths: string[]; /** The CHANGELOG re-headed on a restamp; null for repos without one. */ changelog: string | null; + /** Unversioned CHANGELOG fragments (`/.md`) a PR adds instead of an `[Unreleased]` section; null keeps `[Unreleased]`. */ + changelog_fragments: { dir: string } | null; /** Path of a paired repo to restamp identically (tier 2). */ mirror: string | null; /** `merge-only` refuses a squash or rebase plan in /ship. */ @@ -102,6 +104,7 @@ export const DEFAULT_POLICY: ShipPolicy = { release_outputs: [], stamp_paths: [], changelog: 'CHANGELOG.md', + changelog_fragments: null, mirror: null, history: 'squash-ok', preregistration_shas: [], @@ -131,6 +134,7 @@ export const POLICY_EXPLANATIONS: Record = { release_outputs: 'Globs the release tool may write beyond the version files, lockfiles, CHANGELOG and stamp_paths. Any other output refuses the restamp.', stamp_paths: 'Globs (repo-relative, no `..`) where exact `(was X)`, `since: X` and `vX` mentions of the old version are rewritten.', changelog: 'CHANGELOG path re-headed on a restamp (the PR’s `[Unreleased]` or old-version section moves to the top under the new version). null to skip.', + changelog_fragments: '{ dir } or null: a PR adds one unversioned `/.md` fragment (optional `bump: minor|major` front matter) instead of an `[Unreleased]` section and leaves VERSION, the manifest version and released CHANGELOG sections at its merge base; the restamp folds every fragment under the allocated version and deletes the files (docs/ship-policy.md#changelog-fragments). null keeps `[Unreleased]`.', mirror: 'Relative path of a paired repo to restamp identically; writes are confined to it. null to skip.', history: 'merge-only | squash-ok. merge-only makes /ship refuse a squash or rebase plan.', preregistration_shas: 'Commits that must be ancestors of HEAD before push (preregistered evals); checked by `gstack-evidence ancestor`.', @@ -176,12 +180,14 @@ export const TRAIN_KEYS_VERSION = '1.91.146.0'; // its key, so a value a stamp did not move fails there. export const REVIEW_SCOPE_KEY_VERSION = '1.91.256.0'; export const DOCS_CAPS_KEY_VERSION = '1.91.270.0'; +export const CHANGELOG_FRAGMENTS_KEY_VERSION = '1.92.0.0'; export const POLICY_KEY_INTRODUCED: Record = { bump: '1.91.73.0', queue_mode: '1.91.73.0', release_tool: '1.91.73.0', release_outputs: '1.91.73.0', stamp_paths: '1.91.73.0', changelog: '1.91.73.0', mirror: '1.91.73.0', history: '1.91.73.0', preregistration_shas: '1.91.73.0', stamp_sensitive_gates: '1.91.73.0', pr_count: '1.91.73.0', maintainers: '1.91.73.0', stamp_files: '1.91.98.0', stamp_patterns: '1.91.98.0', restamp_command: '1.91.98.0', migrations: '1.91.98.0', gate_command: SHIP_QUEUE_KEYS_VERSION, full_e2e: SHIP_QUEUE_KEYS_VERSION, min_gstack_version: SHIP_QUEUE_KEYS_VERSION, train: TRAIN_KEYS_VERSION, review_scope: REVIEW_SCOPE_KEY_VERSION, docs: DOCS_CAPS_KEY_VERSION, + changelog_fragments: CHANGELOG_FRAGMENTS_KEY_VERSION, }; const newer = (a: string, b: string): boolean => cmpVersion(parseVersion(a)!, parseVersion(b)!) > 0; @@ -360,6 +366,7 @@ export function validatePolicy(raw: unknown, opts: { gstackVersion?: string } = if ('full_e2e' in obj && typeof obj.full_e2e !== 'boolean') errors.push({ code: 'POLICY_INVALID', path: 'full_e2e', message: 'must be true or false' }); if ('train' in obj && obj.train !== null) trainErrors(obj.train, errors); if ('docs' in obj && obj.docs !== null) docsErrors(obj.docs, errors); + if ('changelog_fragments' in obj && obj.changelog_fragments !== null) fragmentsErrors(obj.changelog_fragments, errors); if ('stamp_paths' in obj) listErrors('stamp_paths', obj.stamp_paths, errors, globError); for (const key of ['changelog', 'mirror'] as const) { if (!(key in obj) || obj[key] === null) continue; @@ -441,6 +448,14 @@ function docsErrors(raw: unknown, errors: PolicyError[]): void { }); } +function fragmentsErrors(raw: unknown, errors: PolicyError[]): void { + const d = raw as Record | null; + if (!d || typeof d !== 'object' || Array.isArray(d)) { errors.push({ code: 'POLICY_INVALID', path: 'changelog_fragments', message: 'must be { dir } or null' }); return; } + for (const k of Object.keys(d)) if (k !== 'dir') errors.push({ code: 'POLICY_INVALID', path: `changelog_fragments.${k}`, message: 'unknown key' }); + const why = typeof d.dir === 'string' ? containedRelative(d.dir) ?? (/[*?[\]{}]/.test(d.dir) || d.dir.replace(/\/+$/, '') === '.' ? 'must be one directory, not a glob or the repository root' : null) : 'must be a string'; + if (why) errors.push({ code: 'POLICY_CONTAINMENT', path: 'changelog_fragments.dir', message: `${JSON.stringify(d.dir)} ${why}` }); +} + /** Merge a parsed file over the defaults, recording per-key provenance. */ export function mergePolicy(raw: Record | null, sourceLabel: string): { policy: ShipPolicy; values: Record } { const policy: ShipPolicy = JSON.parse(JSON.stringify(DEFAULT_POLICY)); diff --git a/lib/tree-receipt.ts b/lib/tree-receipt.ts index 2451bcae6..9285583f8 100644 --- a/lib/tree-receipt.ts +++ b/lib/tree-receipt.ts @@ -14,6 +14,7 @@ import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { normalizeChangelog } from './changelog-check'; +import { foldIntoUnreleased, parseFragment, type Fragment } from './changelog-fragments'; import { compareIdentity, executionIdentity, normalizeLockfile, readBundle, type EvidenceBundle, type ExecutionIdentity } from './evidence-bundle'; import { parseUnifiedDiff, stampOnlyLine, versionAtRef, type FileDiff, type GateAheadRecord } from './restamp'; import type { ResultCodeName } from './result-codes'; @@ -71,7 +72,7 @@ function jsonEqualModuloVersion(a: string | null, b: string | null, name: string return normalizeLockfile(name, a) === normalizeLockfile(name, b); } -export function classifyFile(repoRoot: string, f: FileDiff, gated: string, current: string, policy: ShipPolicy, versionRel: string, oldV: string | null, newV: string | null): FileVerdict { +export function classifyFile(repoRoot: string, f: FileDiff, gated: string, current: string, policy: ShipPolicy, versionRel: string, oldV: string | null, newV: string | null, folded: readonly Fragment[] = []): FileVerdict { const changed = (rule: string, residual: string, line = f.hunks[0]?.at): FileVerdict => ({ path: f.path, rule, verdict: 'changed', residual, line }); const stamp = (rule: string): FileVerdict => ({ path: f.path, rule, verdict: 'stamp' }); if (f.binary) return changed('binary', 'binary content differs'); @@ -98,7 +99,8 @@ export function classifyFile(repoRoot: string, f: FileDiff, gated: string, curre return changed('manifest', 'a field other than version changed'); } if (policy.changelog && f.path === policy.changelog) { - return before !== null && after !== null && normalizeChangelog(before) === normalizeChangelog(after) ? stamp('changelog-heading') : changed('changelog', 'entry text changed (only a heading move or re-heading is a stamp)'); + if (before !== null && after !== null && normalizeChangelog(foldIntoUnreleased(before, folded)) === normalizeChangelog(after)) return stamp(folded.length ? 'changelog-fragments' : 'changelog-heading'); + return changed('changelog', folded.length ? 'entry text is not exactly the deleted fragments folded under the new heading' : 'entry text changed (only a heading move or re-heading is a stamp)'); } const rule = matchesAny(policy.stamp_paths, f.path) ? 'stamp_paths' : matchesAny(policy.release_outputs, f.path) ? 'release_outputs' : matchesAny(policy.stamp_files, f.path) ? 'stamp_files' : null; if (oldV && newV && oldV !== newV && rule) { @@ -145,7 +147,23 @@ export function buildReceipt(o: ReceiptOptions): TreeReceipt | { error: string; const oldV = 'version' in gv ? gv.version : null; const newV = 'version' in cv ? cv.version : null; const diffText = git(o.repoRoot, ['diff', '--unified=0', '--no-color', '--no-ext-diff', gatedHead, currentHead]) ?? ''; - const files = parseUnifiedDiff(diffText).map(f => classifyFile(o.repoRoot, f, gatedHead, currentHead, o.policy, versionRel, oldV, newV)); + const fileDiffs = parseUnifiedDiff(diffText); + const prefix = o.policy.changelog_fragments ? o.policy.changelog_fragments.dir.replace(/\/+$/, '') + '/' : null; + const folded = new Map(); + for (const f of fileDiffs) { + if (!prefix || f.status !== 'D' || f.binary || f.modeChanged || !f.path.startsWith(prefix) || f.path.slice(prefix.length).includes('/') || !f.path.endsWith('.md')) continue; + const text = blob(o.repoRoot, gatedHead, f.path); + const parsed = text === null ? null : parseFragment(f.path, text); + if (parsed && !('code' in parsed)) folded.set(f.path, parsed); + } + const fragments = [...folded.values()].sort((a, b) => (a.path < b.path ? -1 : 1)); + const changelogVerdict = (f: FileDiff) => classifyFile(o.repoRoot, f, gatedHead, currentHead, o.policy, versionRel, oldV, newV, fragments); + const clDiff = fileDiffs.find(f => o.policy.changelog && f.path === o.policy.changelog); + const clStamp = !!clDiff && changelogVerdict(clDiff).verdict === 'stamp'; + const files = fileDiffs.map((f): FileVerdict => { + if (!folded.has(f.path)) return f === clDiff ? changelogVerdict(f) : classifyFile(o.repoRoot, f, gatedHead, currentHead, o.policy, versionRel, oldV, newV); + return clStamp ? { path: f.path, rule: 'changelog_fragments', verdict: 'stamp' } : { path: f.path, rule: 'changelog_fragments', verdict: 'changed', residual: `deleted without its text folded under the new heading in ${o.policy.changelog ?? 'the CHANGELOG'}` }; + }); const residual = files.filter(f => f.verdict === 'changed').map(f => f.path); const stampFiles = files.filter(f => f.verdict === 'stamp').map(f => f.path); const stampDiff = stampFiles.length ? (git(o.repoRoot, ['diff', '--no-color', '--no-ext-diff', gatedHead, currentHead, '--', ...stampFiles]) ?? '') : ''; diff --git a/test/changelog-fragments.test.ts b/test/changelog-fragments.test.ts new file mode 100644 index 000000000..37aa89e4f --- /dev/null +++ b/test/changelog-fragments.test.ts @@ -0,0 +1,138 @@ +/** + * Changelog fragments (docs/ship-policy.md#changelog-fragments): the reader takes only regular .md + * blobs directly in the fragment directory, the restamp folds them under the allocated version with + * the highest declared bump and deletes them, and the tree receipt accepts exactly that fold as a stamp. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { effectiveBump, foldIntoUnreleased, fragmentSlug, parseFragment, readFragments } from '../lib/changelog-fragments'; +import { reheadChangelog } from '../lib/changelog-check'; +import { mergePolicy, validatePolicy } from '../lib/ship-policy'; +import { buildReceipt } from '../lib/tree-receipt'; +import { BASE_POLICY, cleanup, makeFixtureRepo, readFile, restamp, type FixtureRepo } from './helpers/restamp-fixture'; + +const POLICY = { ...BASE_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.92.0.0' }; +const repos: FixtureRepo[] = []; +const fixture = () => { const r = makeFixtureRepo({ policy: POLICY }); repos.push(r); return r; }; +afterEach(() => { while (repos.length) cleanup(repos.pop()!); }); + +function fragmentPr(repo: FixtureRepo, branch: string, fragments: Record, extra: (work: string) => void = () => {}): string { + repo.git('checkout', '-q', 'origin/main', '-b', branch); + fs.writeFileSync(path.join(repo.work, `${branch}.txt`), `${branch}\n`); + fs.mkdirSync(path.join(repo.work, 'changelog.d'), { recursive: true }); + for (const [name, text] of Object.entries(fragments)) fs.writeFileSync(path.join(repo.work, 'changelog.d', name), text); + extra(repo.work); + repo.git('add', '-A'); + repo.git('commit', '-q', '-m', `feat ${branch}`); + return repo.git('rev-parse', 'HEAD'); +} + +describe('parseFragment', () => { + test('body only, or bump front matter then a body', () => { + expect(parseFragment('changelog.d/a.md', '\n- **Faster slots.** text\n\n')).toEqual({ path: 'changelog.d/a.md', body: '- **Faster slots.** text', bump: null }); + expect(parseFragment('changelog.d/a.md', '---\nbump: minor\n---\n### Added\n- x\n')).toEqual({ path: 'changelog.d/a.md', body: '### Added\n- x', bump: 'minor' }); + }); + test('refuses empty text, unknown front matter and a release heading', () => { + expect(parseFragment('a.md', '\n\n')).toMatchObject({ code: 'CHANGELOG_FRAGMENT_INVALID', message: 'the fragment has no entry text' }); + expect(parseFragment('a.md', '---\nversion: 1.2.3\n---\n- x\n')).toMatchObject({ code: 'CHANGELOG_FRAGMENT_INVALID' }); + expect(parseFragment('a.md', '## [1.2.4.0] - 2026-10-29\n- x\n')).toMatchObject({ code: 'CHANGELOG_FRAGMENT_INVALID' }); + expect(parseFragment('a.md', '## [Unreleased]\n- x\n')).toMatchObject({ code: 'CHANGELOG_FRAGMENT_INVALID' }); + }); + test('slug is one flat path segment', () => { + expect(fragmentSlug('gsta154/changelog-fragments')).toBe('gsta154-changelog-fragments'); + expect(fragmentSlug('../..')).toBe('change'); + }); +}); + +describe('effectiveBump and the fold', () => { + test('the highest declared bump wins over the policy level', () => { + const f = (bump: any) => ({ path: 'x', body: 'b', bump }); + expect(effectiveBump('patch', [f(null)])).toBe('patch'); + expect(effectiveBump('patch', [f('minor'), f(null)])).toBe('minor'); + expect(effectiveBump('micro', [f('patch'), f('major'), f('minor')])).toBe('major'); + }); + test('fragments go above a legacy [Unreleased] entry, and re-heading moves both under one version', () => { + const cl = '# Changelog\n\n## [Unreleased]\n\n- legacy\n\n## [1.2.3.0] - 2026-10-01\n\n- base\n'; + const folded = foldIntoUnreleased(cl, [{ path: 'changelog.d/a.md', body: '- a', bump: null }, { path: 'changelog.d/b.md', body: '- b', bump: null }]); + const r = reheadChangelog(folded, { from: null, to: '1.2.4.0', date: '2026-10-29' }); + expect(r.text).toBe('# Changelog\n\n## [1.2.4.0] - 2026-10-29\n\n- a\n\n- b\n\n- legacy\n\n## [1.2.3.0] - 2026-10-01\n\n- base\n'); + }); + test('no fragments leaves the text unchanged', () => { + expect(foldIntoUnreleased('# C\n', [])).toBe('# C\n'); + }); +}); + +describe('readFragments reads blobs only', () => { + test('a symlink, a nested file and a non-.md file refuse; README.md and base fragments are skipped', () => { + const repo = fixture(); + const outside = path.join(repo.root, 'secret.txt'); + fs.writeFileSync(outside, 'SENTINEL-OUTSIDE\n'); + fragmentPr(repo, 'feat-a', { 'a.md': '- a\n', 'README.md': 'how to write fragments\n', 'notes.txt': 'x\n' }, work => { + fs.symlinkSync(outside, path.join(work, 'changelog.d', 'link.md')); + fs.mkdirSync(path.join(work, 'changelog.d', 'sub')); + fs.writeFileSync(path.join(work, 'changelog.d', 'sub', 'deep.md'), '- deep\n'); + }); + const set = readFragments(repo.work, 'HEAD', 'changelog.d', 'origin/main'); + expect(set.fragments.map(f => f.path)).toEqual(['changelog.d/a.md']); + expect(set.errors.map(e => e.path).sort()).toEqual(['changelog.d/link.md', 'changelog.d/notes.txt', 'changelog.d/sub/deep.md']); + expect(set.errors.find(e => e.path === 'changelog.d/link.md')!.message).toContain('symlink'); + expect(JSON.stringify(set)).not.toContain('SENTINEL-OUTSIDE'); + }); +}); + +describe('policy key', () => { + test('changelog_fragments needs one contained directory and min_gstack_version at its key version', () => { + expect(validatePolicy(POLICY)).toEqual([]); + expect(validatePolicy({ ...POLICY, changelog_fragments: { dir: '../x' } }).map(e => e.path)).toContain('changelog_fragments.dir'); + expect(validatePolicy({ ...POLICY, changelog_fragments: { dir: 'changelog.d/*' } }).map(e => e.path)).toContain('changelog_fragments.dir'); + expect(validatePolicy({ ...POLICY, changelog_fragments: { dir: 'd', glob: '*' } }).map(e => e.path)).toContain('changelog_fragments.glob'); + const { min_gstack_version: _drop, ...noMin } = POLICY; + expect(validatePolicy(noMin).map(e => e.path)).toContain('changelog_fragments'); + }); +}); + +describe('restamp folds fragments', () => { + test('a minor fragment under a patch policy stamps the next minor, folds, deletes the fragment, and the receipt calls it a stamp', () => { + const repo = fixture(); + const pin = fragmentPr(repo, 'feat-a', { 'feat-a.md': '---\nbump: minor\n---\n- **Fragments.** feat-a\n' }); + const r = restamp(repo, ['--next']); + expect(r.err).toBe(''); + expect(r.out).toContain('STAMP_VERSION: 1.3.0.0'); + expect(r.out).toContain('folded changelog.d/feat-a.md'); + expect(fs.existsSync(path.join(repo.work, 'changelog.d', 'feat-a.md'))).toBe(false); + expect(readFile(repo, 'CHANGELOG.md')).toMatch(/^# Changelog\n\n## \[1\.3\.0\.0\] - \d{4}-\d{2}-\d{2}\n\n- \*\*Fragments\.\*\* feat-a\n\n## \[1\.2\.3\.0\]/); + repo.git('add', '-A'); repo.git('commit', '-q', '-m', 'stamp'); + const receipt = buildReceipt({ repoRoot: repo.work, gated: pin, current: 'HEAD', policy: mergePolicy(POLICY, 'test').policy, policyLabel: 'test' }); + if ('error' in receipt) throw new Error(receipt.error); + expect(receipt.residual).toEqual([]); + expect(receipt.files.find(f => f.path === 'changelog.d/feat-a.md')).toMatchObject({ rule: 'changelog_fragments', verdict: 'stamp' }); + expect(receipt.files.find(f => f.path === 'CHANGELOG.md')).toMatchObject({ rule: 'changelog-fragments', verdict: 'stamp' }); + }); + + test('an edited fold, or a fragment deleted without its text, is a change', () => { + const repo = fixture(); + const pin = fragmentPr(repo, 'feat-b', { 'feat-b.md': '- feat-b\n' }); + expect(restamp(repo, ['--next']).out).toContain('STAMP_VERSION: 1.2.4.0'); + fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), readFile(repo, 'CHANGELOG.md').replace('- feat-b', '- feat-b, reworded')); + repo.git('add', '-A'); repo.git('commit', '-q', '-m', 'stamp plus edit'); + const policy = mergePolicy(POLICY, 'test').policy; + const edited = buildReceipt({ repoRoot: repo.work, gated: pin, current: 'HEAD', policy, policyLabel: 'test' }); + if ('error' in edited) throw new Error(edited.error); + expect(edited.residual.sort()).toEqual(['CHANGELOG.md', 'changelog.d/feat-b.md']); + repo.git('checkout', '-q', pin); + fs.rmSync(path.join(repo.work, 'changelog.d', 'feat-b.md')); + repo.git('add', '-A'); repo.git('commit', '-q', '-m', 'drop fragment only'); + const dropped = buildReceipt({ repoRoot: repo.work, gated: pin, current: 'HEAD', policy, policyLabel: 'test' }); + if ('error' in dropped) throw new Error(dropped.error); + expect(dropped.files.find(f => f.path === 'changelog.d/feat-b.md')).toMatchObject({ verdict: 'changed' }); + }); + + test('an invalid fragment refuses before anything is written', () => { + const repo = fixture(); + fragmentPr(repo, 'feat-c', { 'feat-c.md': '## [9.9.9.9]\n- no\n' }); + const r = restamp(repo, ['--next']); + expect(r.err).toContain('(CHANGELOG_FRAGMENT_INVALID)'); + expect(readFile(repo, 'VERSION')).toBe('1.2.3.0\n'); + }); +}); From 6327311141719e53be6dd0db36369598e3ad4160 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 04:22:43 +0000 Subject: [PATCH 02/31] feat(ship-queue): land, the per-PR merge step; Version Gate enforces fragments Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- .github/workflows/version-gate.yml | 6 +- bin/gstack-ship-queue | 16 +- lib/restamp.ts | 2 +- lib/ship-queue-land.ts | 341 ++++++++++++++++++++++++++++ lib/version-gate-train.ts | 21 +- scripts/compare-pr-version.ts | 7 +- test/ship-queue-land.test.ts | 243 ++++++++++++++++++++ test/version-gate-fragments.test.ts | 97 ++++++++ 8 files changed, 719 insertions(+), 14 deletions(-) create mode 100644 lib/ship-queue-land.ts create mode 100644 test/ship-queue-land.test.ts create mode 100644 test/version-gate-fragments.test.ts diff --git a/.github/workflows/version-gate.yml b/.github/workflows/version-gate.yml index 32b36a04f..1be24d4e6 100644 --- a/.github/workflows/version-gate.yml +++ b/.github/workflows/version-gate.yml @@ -1,12 +1,10 @@ name: Version Gate +# Every PR: under changelog_fragments a PR that touches no release file must +# still prove it carries a fragment (docs/ship-policy.md#changelog-fragments). on: pull_request: branches: [main] - paths: - - 'VERSION' - - 'CHANGELOG.md' - - 'package.json' concurrency: group: version-gate-${{ github.event.pull_request.number }} diff --git a/bin/gstack-ship-queue b/bin/gstack-ship-queue index b130a7133..591ed9f77 100755 --- a/bin/gstack-ship-queue +++ b/bin/gstack-ship-queue @@ -14,6 +14,8 @@ * gstack-ship-queue gate-ahead --list --pr * gstack-ship-queue verify --pr --screen (train policy: the head's screen verdict from its run) * gstack-ship-queue verify --post-merge [--timeout ] (after a merge: main's push workflows at that commit until they settle) + * gstack-ship-queue land --pr [--dry-run] [--no-wait] [--break-lock] (the merge step; lib/ship-queue-land.ts) + * gstack-ship-queue land --census * gstack-ship-queue train build|status|verdict|bisect|rebuild|watch|prune ... (merge trains; train --help) * * Every subcommand takes --base (default origin/HEAD, else main) and @@ -52,11 +54,16 @@ const HELP = `gstack-ship-queue — the shared merge queue's protocol steps (doc verify --pr --screen (train policy) the PR head's screen verdict read from its run (SCREEN_VERDICT) verify --post-merge [--timeout ] wait for every check run at a merged commit to settle: POST_MERGE: green|red|pending and one CHECK: line per workflow; resumable, default timeout 2700 s + land --pr [--dry-run] [--no-wait] [--timeout ] [--break-lock] [--allow-repo-commands] + the merge step: lock, merge the base, stamp (fragments folded), push the stamp commit, + wait for its checks, squash with --match-head-commit, confirm the base tree; resumable + land --census what land would do with every open PR (read-only) train build|status|verdict|bisect|rebuild|watch|prune ... merge trains, when the base policy declares a train block (train --help; docs/ship-policy.md#run-a-merge-train); ready then takes --screen|--no-screen ---base : base branch (default: origin/HEAD, else main). Nothing here pushes. +--base : base branch (default: origin/HEAD, else main). Nothing here pushes, except land (the stamp commit and the +refs/notes/gstack-land lock) and merge trains. ${exitTableHelp()}`; function flag(args: string[], name: string): string | undefined { @@ -152,6 +159,13 @@ async function main(argv: string[]): Promise { return train.trainMain(ctx, args); } if (cmd === 'verify' && flag(args, '--post-merge') !== undefined) return (await import('../lib/ship-queue-train-cli')).postMergeMain(ctx, args); + if (cmd === 'land') { + const l = await import('../lib/ship-queue-land'); + if (args.includes('--census')) return finish('land', l.census(ctx)); + if (!pr || !/^\d+$/.test(pr)) return usage('land needs --pr (or --census)'); + const timeout = flag(args, '--timeout'); + return finish('land', l.land(ctx, { pr, dryRun: args.includes('--dry-run'), noWait: args.includes('--no-wait'), breakLock: args.includes('--break-lock'), allowRepoCommands: args.includes('--allow-repo-commands'), waitTimeoutMin: timeout && /^\d+$/.test(timeout) ? Number(timeout) : undefined })); + } if (!pr || !/^\d+$/.test(pr)) return usage(`${cmd} needs --pr `); const gateExit = flag(args, '--gate-exit'); if (gateExit !== undefined && !/^-?\d+$/.test(gateExit)) return usage('--gate-exit needs an exit code'); diff --git a/lib/restamp.ts b/lib/restamp.ts index f8e1ca942..b2c3eca3a 100644 --- a/lib/restamp.ts +++ b/lib/restamp.ts @@ -447,7 +447,7 @@ function linkIgnoredDeps(repoRoot: string, staging: string): void { */ function runRestampCommand(command: string, plan: Plan, staging: string, env: NodeJS.ProcessEnv, before: Set, s: StageRecord): void { const seeded = git(staging, ['write-tree']).out; - const r = spawnSync('sh', ['-c', `${command} "$1"`, 'sh', plan.newVersion], { cwd: staging, encoding: 'utf8', timeout: 600_000, env: { ...env, GSTACK_RESTAMP_VERSION: plan.newVersion } }); + const r = spawnSync('sh', ['-c', `${command} "$1"`, 'sh', plan.newVersion], { cwd: staging, encoding: 'utf8', timeout: 600_000, env: { ...env, GSTACK_RESTAMP_VERSION: plan.newVersion, GSTACK_RESTAMP_BASE_SHA: plan.baseSha } }); git(staging, ['read-tree', seeded]); if (r.status !== 0) { s.status = 'failed'; s.detail = `${r.stderr ?? ''}${r.stdout ?? ''}`.trim().slice(0, 300); throw new Refusal('RESTAMP_RELEASE_TOOL_FAILED', `restamp_command exited ${r.status}: ${s.detail}; nothing written to the branch`); } const outside = changedPaths(staging).filter(p => !before.has(p) && !allowedOutput(plan, staging, p)); diff --git a/lib/ship-queue-land.ts b/lib/ship-queue-land.ts new file mode 100644 index 000000000..798a9d294 --- /dev/null +++ b/lib/ship-queue-land.ts @@ -0,0 +1,341 @@ +/** + * ship-queue-land — the per-PR merge step (docs/ship-policy.md#land), behind + * `gstack-ship-queue land --pr `. The PR carries an unversioned change (a + * changelog fragment, or a legacy `[Unreleased]` section) at a pin /ship + * reviewed; land takes the repository land lock, merges the pin with the base + * branch (resolving release-version fields only), stamps the merged tree with + * gstack-restamp, makes one commit whose parents are [pin, base] and whose tree + * is the stamped tree, pushes it to the PR branch under an exact lease, waits + * for the PR's checks there, merges with --match-head-commit, and confirms the + * base branch's tree is the stamped tree. Every step is journaled under + * .gstack/tmp/ship-queue/land/.json, so a rerun resumes. + */ +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { mergeChangelogs, parseChangelog } from './changelog-check'; +import { fragmentSlug, readFragments } from './changelog-fragments'; +import { restamp, versionAtRef } from './restamp'; +import { matchesAny, type ShipPolicy } from './ship-policy'; +import { ciAtHead, loadBasePolicy, type QueueContext, type StepOutput } from './ship-queue'; +import { QUEUE_DIR_REL, git, writeJsonAtomic, type QueueFailure } from './ship-queue-gate'; +import { bindReceipt } from './ship-receipt'; + +export const LAND_LOCK_REF = 'refs/notes/gstack-land'; +export const MAX_RESTAMPS = 2; +const PUSH_STALE_MS = 10 * 60_000; + +export interface LandJournal { + schema_version: 1; pr: string; branch: string; + reviewed_pin: string; last_pushed_head: string | null; stamp_commit: string | null; stamp_tree: string | null; + base_sha: string | null; version: string | null; date: string | null; restamps: number; + phase: 'locked' | 'stamped' | 'pushed' | 'merged' | 'landed' | 'drift'; +} +export interface LandLock { pr: string; holder: string; started_at: string; phase: 'stamping' | 'pushing' | 'waiting' | 'merging' | 'drift'; journal: string } +export interface LandOptions { pr: string; dryRun?: boolean; noWait?: boolean; breakLock?: boolean; allowRepoCommands?: boolean; waitTimeoutMin?: number; now?: () => Date; holder?: string } + +const fail = (lines: string[], failure: QueueFailure, run: string): StepOutput => ({ ok: false, lines, failure, run }); +export const landJournalRel = (pr: string) => `${QUEUE_DIR_REL}/land/${pr}.json`; + +function readJson(file: string): T | null { try { return JSON.parse(fs.readFileSync(file, 'utf8')) as T; } catch { return null; } } + +/** The remote land lock: a ref whose commit holds lock.json, or a released marker. Updated only by exact lease. */ +function readLock(root: string): { sha: string; lock: LandLock | null } { + git(root, ['fetch', '-q', 'origin', `+${LAND_LOCK_REF}:${LAND_LOCK_REF}`]); + const sha = git(root, ['rev-parse', '--verify', '--quiet', LAND_LOCK_REF]).out; + if (!sha) return { sha: '', lock: null }; + const body = git(root, ['show', `${sha}:lock.json`]); + const lock = body.ok ? (JSON.parse(body.out) as LandLock | { released: true }) : null; + return { sha, lock: lock && 'pr' in lock ? lock : null }; +} + +function writeLock(root: string, expected: string, value: LandLock | { released: true; by: string; at: string }): { ok: boolean; sha: string } { + const blob = git(root, ['hash-object', '-w', '--stdin'], undefined, JSON.stringify(value, null, 2) + '\n').out; + const tree = git(root, ['mktree'], undefined, `100644 blob ${blob}\tlock.json\n`).out; + const commit = git(root, ['commit-tree', tree, ...(expected ? ['-p', expected] : []), '-m', 'released' in value ? `land lock released by ${value.by}` : `land lock #${value.pr} ${value.phase}`]).out; + const push = git(root, ['push', '-q', `--force-with-lease=${LAND_LOCK_REF}:${expected}`, 'origin', `${commit}:${LAND_LOCK_REF}`]); + if (push.ok) git(root, ['update-ref', LAND_LOCK_REF, commit]); + return { ok: push.ok, sha: commit }; +} + +/** + * Release-version conflicts only: the version file and release outputs take the base's side, the + * CHANGELOG merges by section (mergeChangelogs), and a manifest or npm lockfile is re-merged with + * every side's release version set to the base's, so a clean dependency or script edit survives and + * a real dependency conflict still conflicts. Returns the files it could not resolve. + */ +export function resolveReleaseConflicts(wt: string, conflicts: string[], policy: ShipPolicy, versionRel: string): string[] { + const left: string[] = []; + const stage = (n: number, rel: string) => git(wt, ['show', `:${n}:${rel}`]); + for (const rel of conflicts) { + const base = path.basename(rel); + if (policy.changelog && rel === policy.changelog) { + const merged = mergeChangelogs(stage(2, rel).out + '\n', stage(3, rel).out + '\n'); + if (merged === null) { left.push(rel); continue; } + fs.writeFileSync(path.join(wt, rel), merged); + } else if (rel === versionRel || matchesAny(policy.release_outputs, rel)) { + if (!git(wt, ['checkout', '--theirs', '--', rel]).ok) { left.push(rel); continue; } + } else if (['package.json', 'package-lock.json', 'npm-shrinkwrap.json'].includes(base)) { + const sides = [1, 2, 3].map(n => stage(n, rel)); + if (sides.some(s => !s.ok)) { left.push(rel); continue; } + const theirs = JSON.parse(sides[2]!.out); + const pin = (text: string) => { + const o = JSON.parse(text); + if ('version' in o) o.version = theirs.version; + if (o.packages?.[''] && theirs.packages?.['']) o.packages[''].version = theirs.packages[''].version; + const indent = /^\{\n( +|\t)/.exec(text)?.[1] ?? 2; + return JSON.stringify(o, null, indent) + '\n'; + }; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-land-merge-')); + const [b, o, t] = ['base', 'ours', 'theirs'].map((n, i) => { const f = path.join(tmp, n); fs.writeFileSync(f, pin(sides[i === 0 ? 0 : i]!.out)); return f; }); + const m = spawnSync('git', ['merge-file', '-p', o!, b!, t!], { encoding: 'utf8', timeout: 30_000 }); + fs.rmSync(tmp, { recursive: true, force: true }); + if (m.status !== 0) { left.push(rel); continue; } + fs.writeFileSync(path.join(wt, rel), m.stdout); + } else { left.push(rel); continue; } + git(wt, ['add', '--', rel]); + } + return left; +} + +/** + * A legacy per-PR stamp (a claim-at-open repo): the pin moved VERSION past its merge base and headed its + * own entry with that version. Before the merge, put the pin's release fields back to the merge base's + * values and re-head that entry `[Unreleased]`, committed in the scratch worktree, so the base merge + * meets an unstamped change and the restamp allocates the version. Returns a note, or null. + */ +function unstampLegacy(wt: string, policy: ShipPolicy, versionRel: string, mergeBase: string): string | null { + const mb = versionAtRef(wt, mergeBase); + const own = versionAtRef(wt, 'HEAD'); + if (!('version' in mb) || !('version' in own) || mb.version === own.version) return null; + const touched = [versionRel]; + fs.writeFileSync(path.join(wt, versionRel), git(wt, ['show', `${mergeBase}:${versionRel}`]).out + '\n'); + for (const rel of ['package.json', 'package-lock.json', 'npm-shrinkwrap.json']) { + const before = git(wt, ['show', `${mergeBase}:${rel}`]); + if (!before.ok || !fs.existsSync(path.join(wt, rel))) continue; + const text = fs.readFileSync(path.join(wt, rel), 'utf8'); + const b = JSON.parse(before.out); const cur = JSON.parse(text); + if ('version' in cur) cur.version = b.version; + if (cur.packages?.[''] && b.packages?.['']) cur.packages[''].version = b.packages[''].version; + fs.writeFileSync(path.join(wt, rel), JSON.stringify(cur, null, /^\{\n( +|\t)/.exec(text)?.[1] ?? 2) + '\n'); + touched.push(rel); + } + const cl = policy.changelog; + if (cl && fs.existsSync(path.join(wt, cl))) { + const text = fs.readFileSync(path.join(wt, cl), 'utf8'); + const baseKeys = new Set(parseChangelog(git(wt, ['show', `${mergeBase}:${cl}`]).out).sections.map(s => s.version)); + const parsed = parseChangelog(text); + const mine = parsed.sections.find(s => s.version === own.version && !baseKeys.has(s.version)); + if (mine && !parsed.sections.some(s => s.unreleased)) { + const lines = text.split('\n'); lines[mine.start] = '## [Unreleased]'; + fs.writeFileSync(path.join(wt, cl), lines.join('\n')); + touched.push(cl); + } + } + git(wt, ['add', '--', ...touched]); + git(wt, ['commit', '-q', '--no-verify', '-m', `land: put the legacy stamp ${own.version} back to ${mb.version} before the base merge`]); + return `legacy: the pin stamped ${own.version} itself; its release fields go back to ${mb.version} and its entry is re-headed [Unreleased] before the merge`; +} + +/** + * Merge the pin with origin/ in a scratch worktree, resolve release conflicts, give a PR with + * no entry a title-derived fragment, and stamp with gstack-restamp. Returns the stamped tree. + */ +function buildStamp(ctx: QueueContext, policy: ShipPolicy, o: { pin: string; baseSha: string; pr: string; title: string; allowRepoCommands: boolean; now: Date }): { tree: string; version: string; lines: string[] } | QueueFailure { + const root = ctx.repoRoot; + const wt = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-land-')); + fs.rmSync(wt, { recursive: true, force: true }); + if (!git(root, ['worktree', 'add', '--detach', '-q', wt, o.pin]).ok) return { code: 'QUEUE_LAND_CONFLICT', message: `cannot create a worktree at the pin ${o.pin.slice(0, 12)}` }; + try { + const lines: string[] = []; + const at = versionAtRef(root, o.baseSha); + const versionRel = 'rel' in at ? at.rel : 'VERSION'; + const legacy = unstampLegacy(wt, policy, versionRel, git(root, ['merge-base', o.pin, o.baseSha]).out); + if (legacy) lines.push(legacy); + const merge = git(wt, ['merge', '--no-ff', '--no-commit', '-q', o.baseSha]); + if (!merge.ok) { + const conflicts = git(wt, ['diff', '--name-only', '--diff-filter=U']).out.split('\n').filter(Boolean); + if (!conflicts.length) return { code: 'QUEUE_LAND_CONFLICT', message: `git merge failed: ${merge.err.split('\n')[0]}` }; + const left = resolveReleaseConflicts(wt, conflicts, policy, versionRel); + if (left.length) return { code: 'QUEUE_LAND_CONFLICT', message: `#${o.pr} conflicts with origin/${ctx.base} in ${left.join(', ')}` }; + lines.push(`resolved: ${conflicts.join(', ')} (release fields only)`); + } + const dir = policy.changelog_fragments?.dir; + if (dir && policy.changelog) { + const own = readFragments(wt, null, dir, o.baseSha); + const cl = fs.existsSync(path.join(wt, policy.changelog)) ? fs.readFileSync(path.join(wt, policy.changelog), 'utf8') : ''; + if (!own.errors.length && !own.fragments.length && !parseChangelog(cl).sections.some(s => s.unreleased)) { + const rel = `${dir.replace(/\/+$/, '')}/land-${fragmentSlug(o.pr)}.md`; + fs.mkdirSync(path.dirname(path.join(wt, rel)), { recursive: true }); + fs.writeFileSync(path.join(wt, rel), `- ${o.title.replace(/\s+/g, ' ').trim()} (#${o.pr})\n`); + git(wt, ['add', '--', rel]); + lines.push(`entry: none in the PR, so land wrote ${rel} from the title`); + } + } + if (!git(wt, ['commit', '-q', '--no-verify', '--allow-empty', '-m', `land #${o.pr}: merge origin/${ctx.base}`]).ok) return { code: 'QUEUE_LAND_CONFLICT', message: 'cannot commit the merge in the scratch worktree' }; + const r = restamp({ repoRoot: wt, base: ctx.base, next: true, policyFrom: 'base', allowRepoCommands: o.allowRepoCommands, dryRun: false, now: o.now, env: process.env }); + if (r.status !== 'stamped' && r.status !== 'noop') return { code: r.code ?? 'QUEUE_LAND_CONFLICT', message: `restamp ${r.status}: ${r.message ?? ''}`, fix: r.fix }; + lines.push(...r.stages.filter(s => s.status !== 'pending').map(s => `stage ${s.name}: ${s.status}${s.detail ? ` (${s.detail})` : ''}`)); + git(wt, ['add', '-A']); + return { tree: git(wt, ['write-tree']).out, version: r.newVersion!, lines }; + } finally { + git(root, ['worktree', 'remove', '--force', wt]); + fs.rmSync(wt, { recursive: true, force: true }); + } +} + +interface PrInfo { state: string; headRefName: string; headRefOid: string; isCrossRepository: boolean; body: string; title: string } +function prInfo(ctx: QueueContext, pr: string): PrInfo | QueueFailure { + const r = ctx.gh(['pr', 'view', pr, '--json', 'state,headRefName,headRefOid,isCrossRepository,body,title']); + try { return JSON.parse(r.out) as PrInfo; } catch { return { code: 'QUEUE_NOT_READY', message: `gh pr view ${pr} failed: ${(r.err ?? r.out).trim().slice(0, 160)}` }; } +} + +function waitGreen(ctx: QueueContext, pr: string, minutes: number): boolean { + const bin = path.join(path.dirname(new URL(import.meta.url).pathname), '..', 'bin', 'gstack-pr-wait'); + const r = spawnSync(bin, [pr, '--until', 'green', '--timeout', String(minutes)], { cwd: ctx.repoRoot, stdio: ['ignore', 'inherit', 'inherit'], timeout: (minutes + 5) * 60_000 }); + return r.status === 0; +} + +/** `land --census`: what land would do with each open PR before a repository turns fragments on. Reads only. */ +export function census(ctx: QueueContext): StepOutput { + const root = ctx.repoRoot; + const loaded = loadBasePolicy(ctx); + if ('message' in loaded) return fail([], loaded, 'none'); + const policy = loaded.policy; + git(root, ['fetch', '-q', 'origin', ctx.base]); + const baseSha = git(root, ['rev-parse', `origin/${ctx.base}`]).out; + const list = ctx.gh(['pr', 'list', '--state', 'open', '--base', ctx.base, '--limit', '200', '--json', 'number,headRefOid,isCrossRepository']); + let prs: Array<{ number: number; headRefOid: string; isCrossRepository: boolean }> = []; + try { prs = JSON.parse(list.out); } catch { return fail([], { code: 'QUEUE_NOT_READY', message: 'gh pr list failed' }, 'none'); } + const at = versionAtRef(root, baseSha); + const versionRel = 'rel' in at ? at.rel : 'VERSION'; + const lines = [`CENSUS: ${prs.length} open PR(s) against origin/${ctx.base}@${baseSha.slice(0, 12)} (fragments: ${policy.changelog_fragments ? policy.changelog_fragments.dir : 'off'})`]; + for (const p of prs.sort((a, b) => a.number - b.number)) { + if (p.isCrossRepository) { lines.push(`#${p.number} fork: refused (QUEUE_TRAIN_UNTRUSTED)`); continue; } + git(root, ['fetch', '-q', 'origin', `+refs/pull/${p.number}/head:refs/gstack-census/${p.number}`]); + const head = p.headRefOid; + const mt = git(root, ['merge-tree', '--write-tree', '--name-only', '--no-messages', baseSha, head]); + const conflicts = mt.ok ? [] : mt.out.split('\n').slice(1).filter(Boolean); + const product = conflicts.filter(c => c !== policy.changelog && c !== versionRel && !matchesAny(policy.release_outputs, c) && !['package.json', 'package-lock.json', 'npm-shrinkwrap.json'].includes(path.basename(c))); + const frags = policy.changelog_fragments ? readFragments(root, head, policy.changelog_fragments.dir, baseSha) : { fragments: [], errors: [] }; + const cl = policy.changelog ? git(root, ['show', `${head}:${policy.changelog}`]).out : ''; + const legacy = parseChangelog(cl).sections.some(s => s.unreleased); + const hv = versionAtRef(root, head); + const bumped = 'version' in hv && 'version' in at && hv.version !== at.version; + const entry = frags.errors.length ? `invalid fragment ${frags.errors[0]!.path}` : frags.fragments.length ? `fold ${frags.fragments.map(f => path.basename(f.path)).join(', ')}` : legacy ? 're-head legacy [Unreleased]' : 'no entry (land writes one from the title)'; + lines.push(`#${p.number} ${head.slice(0, 12)}: ${product.length ? `refuse (product conflict: ${product.join(', ')})` : entry}${bumped ? `; rewrite its tentative ${'version' in hv ? hv.version : ''} through restamp` : ''}${conflicts.length && !product.length ? `; resolve ${conflicts.join(', ')} (release fields only)` : ''}`); + } + return { ok: true, run: 'none', lines }; +} + +/** `gstack-ship-queue land --pr `: one resumable pass of the merge step. */ +export function land(ctx: QueueContext, o: LandOptions): StepOutput { + const root = ctx.repoRoot; + const run = landJournalRel(o.pr); + const file = path.join(root, run); + const now = o.now ?? (() => new Date()); + const holder = o.holder ?? `${os.hostname()}:${process.pid}`; + const loaded = loadBasePolicy(ctx); + if ('message' in loaded) return fail([], loaded, run); + const policy = loaded.policy; + const info = prInfo(ctx, o.pr); + if ('code' in info) return fail([], info, run); + if (info.isCrossRepository) return fail([], { code: 'QUEUE_TRAIN_UNTRUSTED', message: `#${o.pr} is a fork head; land stamps and pushes only crew branches` }, run); + if (info.state !== 'OPEN') return fail([], { code: 'QUEUE_NOT_READY', message: `#${o.pr} is ${info.state}` }, run); + let j = readJson(file); + if (j && (j.phase === 'landed' || j.reviewed_pin !== info.headRefOid && j.last_pushed_head !== info.headRefOid)) j = null; + const pin = j?.reviewed_pin ?? info.headRefOid; + const lines = [`LAND: #${o.pr} branch=${info.headRefName} pin=${pin}${j ? ` (resuming at ${j.phase})` : ''}`]; + const receipt = bindReceipt(info.body ?? '', o.pr, [pin]); + lines.push(receipt.line); + if (!receipt.ok) return fail(lines, { code: receipt.code, message: receipt.message, fix: 'run /ship at the pinned head first' }, run); + if (!j) { + const ci = ciAtHead(ctx, pin); + lines.push(`CI: ${ci.line}`); + if (!ci.green) return fail(lines, { code: 'QUEUE_NOT_READY', message: `required checks are not green at the pin ${pin.slice(0, 12)}` }, run); + } + if (o.dryRun) { + git(root, ['fetch', '-q', 'origin', ctx.base]); + const s = buildStamp(ctx, policy, { pin, baseSha: git(root, ['rev-parse', `origin/${ctx.base}`]).out, pr: o.pr, title: info.title, allowRepoCommands: !!o.allowRepoCommands, now: now() }); + if ('code' in s) return fail(lines, s, run); + return { ok: true, run, lines: [...lines, ...s.lines, `STAMP_VERSION: ${s.version}`, `STAMP_TREE: ${s.tree}`, 'DRY-RUN: nothing locked, pushed or merged'] }; + } + + let { sha: lockSha, lock } = readLock(root); + if (lock && lock.pr !== o.pr) { + const age = now().getTime() - Date.parse(lock.started_at); + if (!o.breakLock) return fail(lines, { code: 'QUEUE_LAND_LOCKED', message: `#${lock.pr} holds the land lock (${lock.holder}, phase ${lock.phase}, since ${lock.started_at})` }, run); + if (lock.phase === 'pushing' && age < PUSH_STALE_MS) return fail(lines, { code: 'QUEUE_LAND_LOCKED', message: `#${lock.pr}'s holder is mid-push (${Math.round(age / 1000)} s ago); --break-lock refused` }, run); + lines.push(`LOCK: broke #${lock.pr}'s lock (${lock.holder}, phase ${lock.phase})`); + } + const setLock = (phase: LandLock['phase']): QueueFailure | null => { + const w = writeLock(root, lockSha, { pr: o.pr, holder, started_at: lock?.pr === o.pr ? lock.started_at : now().toISOString(), phase, journal: run }); + if (!w.ok) return { code: 'QUEUE_LAND_LOCKED', message: 'the land lock moved under this run (another lander took it)' }; + lockSha = w.sha; lock = { pr: o.pr, holder, started_at: lock?.pr === o.pr ? lock.started_at : now().toISOString(), phase, journal: run }; + return null; + }; + const save = () => writeJsonAtomic(file, j); + const failed = setLock(j?.phase === 'pushed' ? 'waiting' : 'stamping'); + if (failed) return fail(lines, failed, run); + if (!j) { j = { schema_version: 1, pr: o.pr, branch: info.headRefName, reviewed_pin: pin, last_pushed_head: null, stamp_commit: null, stamp_tree: null, base_sha: null, version: null, date: null, restamps: 0, phase: 'locked' }; save(); } + + for (;;) { + git(root, ['fetch', '-q', 'origin', ctx.base]); + const baseNow = git(root, ['rev-parse', `origin/${ctx.base}`]).out; + if (j.phase === 'pushed' && j.base_sha !== baseNow) { + if (j.restamps >= MAX_RESTAMPS) return fail(lines, { code: 'QUEUE_LAND_BASE_BUSY', message: `origin/${ctx.base} moved during CI ${j.restamps + 1} times` }, run); + lines.push(`BASE_MOVED: ${j.base_sha?.slice(0, 12)} -> ${baseNow.slice(0, 12)}; restamping`); + j.restamps += 1; j.phase = 'locked'; save(); + } + if (j.phase === 'locked') { + const s = buildStamp(ctx, policy, { pin, baseSha: baseNow, pr: o.pr, title: info.title, allowRepoCommands: !!o.allowRepoCommands, now: now() }); + if ('code' in s) { setLock('stamping'); return fail([...lines], s, run); } + lines.push(...s.lines); + const commit = git(root, ['commit-tree', s.tree, '-p', pin, '-p', baseNow, '-m', `v${s.version} stamp: merge ${ctx.base} ${baseNow.slice(0, 12)} into #${o.pr} (land)`]).out; + Object.assign(j, { stamp_commit: commit, stamp_tree: s.tree, base_sha: baseNow, version: s.version, date: now().toISOString().slice(0, 10), phase: 'stamped' }); + save(); + lines.push(`STAMP_VERSION: ${s.version}`, `STAMP_COMMIT: ${commit} tree=${s.tree}`); + } + if (j.phase === 'stamped') { + const lease = j.last_pushed_head ?? pin; + const f = setLock('pushing'); + if (f) return fail(lines, f, run); + const push = git(root, ['push', '-q', `--force-with-lease=refs/heads/${j.branch}:${lease}`, 'origin', `${j.stamp_commit}:refs/heads/${j.branch}`]); + if (!push.ok) { + const remote = git(root, ['ls-remote', 'origin', `refs/heads/${j.branch}`]).out.split('\t')[0]; + if (remote !== j.stamp_commit) return fail(lines, { code: 'QUEUE_TRAIN_MOVED', message: `#${o.pr}'s branch is at ${remote?.slice(0, 12) || 'unknown'}, not ${lease.slice(0, 12)}: someone else pushed; land never overwrites it` }, run); + } + j.last_pushed_head = j.stamp_commit; j.phase = 'pushed'; save(); + setLock('waiting'); + lines.push(`PUSHED: ${j.stamp_commit} to ${j.branch} (lease ${lease.slice(0, 12)})`); + if (o.noWait) return { ok: true, run, lines: [...lines, `NEXT: gstack-pr-wait ${o.pr} --until green && gstack-ship-queue land --pr ${o.pr}`] }; + if (!waitGreen(ctx, o.pr, o.waitTimeoutMin ?? 60)) return fail(lines, { code: 'QUEUE_NOT_READY', message: `checks did not go green on ${j.stamp_commit!.slice(0, 12)}`, fix: `fix the failure, then gstack-ship-queue land --pr ${o.pr} (it resumes)` }, run); + continue; + } + if (j.phase === 'pushed') { + if (info.headRefOid !== j.stamp_commit) return fail(lines, { code: 'QUEUE_TRAIN_MOVED', message: `#${o.pr}'s head is ${info.headRefOid.slice(0, 12)}, not the stamp commit ${j.stamp_commit?.slice(0, 12)}` }, run); + const ci = ciAtHead(ctx, j.stamp_commit!); + lines.push(`CI: ${ci.line}`); + if (!ci.green) return { ok: !ci.line.startsWith('red'), run, lines: [...lines, `NEXT: gstack-pr-wait ${o.pr} --until green && gstack-ship-queue land --pr ${o.pr}`], failure: ci.line.startsWith('red') ? { code: 'QUEUE_NOT_READY', message: `a check failed on the stamp commit ${j.stamp_commit!.slice(0, 12)}` } : undefined }; + setLock('merging'); + const m = ctx.gh(['pr', 'merge', o.pr, '--squash', '--match-head-commit', j.stamp_commit!]); + if (!m.ok) return fail(lines, { code: 'QUEUE_NOT_READY', message: `gh pr merge refused: ${(m.err ?? m.out).trim().slice(0, 200)}` }, run); + j.phase = 'merged'; save(); + } + if (j.phase === 'merged') { + git(root, ['fetch', '-q', 'origin', ctx.base]); + const main = git(root, ['rev-parse', `origin/${ctx.base}`]).out; + const tree = git(root, ['rev-parse', `${main}^{tree}`]).out; + const parent = git(root, ['rev-parse', `${main}^1`]).out; + if (tree !== j.stamp_tree || parent !== j.base_sha) { + j.phase = 'drift'; save(); setLock('drift'); + return fail([...lines, `MAIN: ${main} tree=${tree} parent=${parent}`], { code: 'QUEUE_LAND_DRIFT', message: `origin/${ctx.base} ${main.slice(0, 12)} is not the stamped tree on ${j.base_sha?.slice(0, 12)} (a merge outside land slipped in)`, fix: `renumber main's top CHANGELOG entry and VERSION with a stamp-only PR (gstack-restamp --next on a branch from origin/${ctx.base}, merged through land), then release the lock: gstack-ship-queue land --pr ${o.pr} --break-lock` }, run); + } + j.phase = 'landed'; save(); + const rel = writeLock(root, lockSha, { released: true, by: holder, at: now().toISOString() }); + return { ok: true, run, lines: [...lines, `MERGED: #${o.pr} as ${main} (${j.version})`, `LANDED: origin/${ctx.base} tree ${tree} equals the stamped tree`, `LOCK: ${rel.ok ? 'released' : 'release push failed (the next land takes it over)'}`] }; + } + if (j.phase === 'drift') return fail(lines, { code: 'QUEUE_LAND_DRIFT', message: 'a previous land of this PR found drift; repair main first' }, run); + } +} diff --git a/lib/version-gate-train.ts b/lib/version-gate-train.ts index e809d9408..604b17990 100644 --- a/lib/version-gate-train.ts +++ b/lib/version-gate-train.ts @@ -12,11 +12,12 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { parseChangelog } from './changelog-check'; +import { effectiveBump, readFragments } from './changelog-fragments'; import { matchesAny } from './ship-policy'; export type TrainHead = | { kind: 'off' } - | { kind: 'unstamped' } + | { kind: 'unstamped'; fragments?: number; entry?: 'fragment' | 'unreleased' | 'none-bot' } | { kind: 'stamped'; expected: string } | { kind: 'invalid'; reason: string }; @@ -89,12 +90,13 @@ export function regeneratedAtBase(o: { repoRoot: string; head: string; baseVersi } } -export function classifyTrainHead(o: { repoRoot: string; baseRef: string; head: string }): TrainHead { +export function classifyTrainHead(o: { repoRoot: string; baseRef: string; head: string; author?: string }): TrainHead { const base = `origin/${o.baseRef.replace(/^origin\//, '')}`; const show = (rev: string, file: string) => git(o.repoRoot, ['show', `${rev}:${file}`]); let policy: any = null; try { policy = JSON.parse(show(base, '.gstack/ship-policy.json') ?? 'null'); } catch { policy = null; } - if (!policy || typeof policy.train !== 'object' || policy.train === null) return { kind: 'off' }; + const fragDir: string | null = typeof policy?.changelog_fragments?.dir === 'string' ? policy.changelog_fragments.dir : null; + if (!policy || ((typeof policy.train !== 'object' || policy.train === null) && !fragDir)) return { kind: 'off' }; const mergeBase = git(o.repoRoot, ['merge-base', base, o.head])?.trim(); if (!mergeBase) return { kind: 'invalid', reason: `no merge base between ${base} and ${o.head}` }; @@ -113,7 +115,10 @@ export function classifyTrainHead(o: { repoRoot: string; baseRef: string; head: if (regen.kind === 'differs') touched.push(...regen.files); } else touched.push(...outputs); if (touched.length) { - const expected = bumpVersion(baseVersion, policy.bump ?? 'patch'); + // A merge-step stamp commit has parents [pin, base]: the fragments it folded are the pin's own. + const pin = git(o.repoRoot, ['rev-parse', '--verify', '--quiet', `${o.head}^2`]) ? `${o.head}^1` : mergeBase; + const level = policy.bump === 'patch' ? 'patch' : 'micro'; + const expected = bumpVersion(baseVersion, fragDir ? effectiveBump(level, readFragments(o.repoRoot, pin, fragDir, base).fragments) : (policy.bump ?? 'patch')); return expected ? { kind: 'stamped', expected } : { kind: 'invalid', reason: `${base}:VERSION is not a 4-part version` }; } @@ -136,5 +141,11 @@ export function classifyTrainHead(o: { repoRoot: string; baseRef: string; head: if (was.length !== now.length || was.some((text, i) => text !== now[i])) { return { kind: 'invalid', reason: `${file} changes text outside [Unreleased]; an unstamped head may only add or edit its [Unreleased] section` }; } - return { kind: 'unstamped' }; + if (!fragDir) return { kind: 'unstamped' }; + const frags = readFragments(o.repoRoot, o.head, fragDir, mergeBase); + if (frags.errors.length) return { kind: 'invalid', reason: `${frags.errors[0]!.path}: ${frags.errors[0]!.message}` }; + if (frags.fragments.length) return { kind: 'unstamped', fragments: frags.fragments.length, entry: 'fragment' }; + if (unreleased.length) return { kind: 'unstamped', fragments: 0, entry: 'unreleased' }; + if (/\[bot\]$/.test(o.author ?? '')) return { kind: 'unstamped', fragments: 0, entry: 'none-bot' }; + return { kind: 'invalid', reason: `add a CHANGELOG fragment: ${fragDir.replace(/\/+$/, '')}/.md with the entry text (the merge step folds it under the version it allocates)` }; } diff --git a/scripts/compare-pr-version.ts b/scripts/compare-pr-version.ts index 281736f61..aa6c37273 100644 --- a/scripts/compare-pr-version.ts +++ b/scripts/compare-pr-version.ts @@ -39,9 +39,10 @@ const prVersionEnv = (process.env.PR_VERSION ?? "").trim(); // head is unstamped and the lane stamps at its slot (docs/ship-policy.md#run-a-merge-train). // The workflow passes BASE_REF and HEAD_SHA with the PR head checked out as cwd. if (process.env.BASE_REF && process.env.HEAD_SHA) { - const train = classifyTrainHead({ repoRoot: process.cwd(), baseRef: process.env.BASE_REF, head: process.env.HEAD_SHA }); + const train = classifyTrainHead({ repoRoot: process.cwd(), baseRef: process.env.BASE_REF, head: process.env.HEAD_SHA, author: process.env.PR_AUTHOR }); if (train.kind === "unstamped") { - console.log(`✓ ${tag} is unstamped under the base's train policy: VERSION and package.json match its merge base, any changed release output is what the release tool generates at the merge base's VERSION, and the CHANGELOG change is confined to [Unreleased]. The lane stamps at its slot.`); + const entry = train.entry === "fragment" ? `its entry is ${train.fragments} CHANGELOG fragment(s)` : train.entry === "none-bot" ? "it is a bot PR with no entry (the merge step writes one from its title)" : "the CHANGELOG change is confined to [Unreleased]"; + console.log(`✓ ${tag} is unstamped under the base's ship policy: VERSION and package.json match its merge base, any changed release output is what the release tool generates at the merge base's VERSION, and ${entry}. The merge step stamps it.`); process.exit(0); } if (train.kind === "invalid") { @@ -49,7 +50,7 @@ if (process.env.BASE_REF && process.env.HEAD_SHA) { process.exit(1); } if (train.kind === "stamped" && prVersionEnv !== train.expected) { - console.log(`::error::${tag} claims v${prVersionEnv}, but under the base's train policy a stamped head must carry v${train.expected} (base + bump, what gstack-restamp --next allocates at its slot). Leave VERSION, package.json and the release outputs at the base's values until the slot is called.`); + console.log(`::error::${tag} claims v${prVersionEnv}, but under the base's ship policy a stamped head must carry v${train.expected} (base + the effective bump, what gstack-restamp --next allocates at the merge step). Leave VERSION, package.json and the release outputs at the base's values until the merge step stamps them.`); process.exit(1); } } diff --git a/test/ship-queue-land.test.ts b/test/ship-queue-land.test.ts new file mode 100644 index 000000000..b4d8a7c12 --- /dev/null +++ b/test/ship-queue-land.test.ts @@ -0,0 +1,243 @@ +/** + * gstack-ship-queue land (docs/ship-policy.md#land): the merge step over real fixture repos (bare origin, + * lane clone) and a gh shim that answers pr view, check runs, statuses and a squash merge against the + * bare origin. Covers the fold-and-merge happy path, two PRs from one base landing without a collision, + * release-field-only conflict resolution that keeps a clean dependency edit, product-conflict refusal, + * the lock, the base moving during CI (restamp under the last-pushed lease), a foreign push, a missing + * receipt and drift after the merge. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { EXIT } from '../lib/headless-artifacts'; +import { BASE_POLICY, cleanup, makeFixtureRepo, readFile, runBin, type FixtureRepo } from './helpers/restamp-fixture'; +import { shippedBody } from './helpers/ship-queue-train-fixture'; +import { resolveReleaseConflicts } from '../lib/ship-queue-land'; +import { mergePolicy } from '../lib/ship-policy'; + +const POLICY = { ...BASE_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.92.0.0' }; +const repos: FixtureRepo[] = []; +afterEach(() => { while (repos.length) cleanup(repos.pop()!); }); + +interface Fx { repo: FixtureRepo; data: string; env: Record; write: (fn: (d: any) => void) => void } +function setup(policy: Record = POLICY): Fx { + const repo = makeFixtureRepo({ policy, prefix: 'gstack-land-fx-' }); + repos.push(repo); + const dir = path.join(repo.root, 'gh-shim'); + fs.mkdirSync(dir, { recursive: true }); + const data = path.join(dir, 'data.json'); + fs.writeFileSync(data, JSON.stringify({ prs: {}, red: [], driftOnMerge: false })); + fs.writeFileSync(path.join(dir, 'gh'), [ + '#!/usr/bin/env node', + "const fs=require('fs');const {execFileSync}=require('child_process');", + 'const D=process.env.LAND_SHIM_DATA, O=process.env.LAND_SHIM_ORIGIN;', + "const d=JSON.parse(fs.readFileSync(D,'utf8'));const a=process.argv.slice(2);", + "const g=(...x)=>execFileSync('git',['--git-dir',O,...x],{encoding:'utf8'}).trim();", + "const save=()=>fs.writeFileSync(D,JSON.stringify(d));", + "if(a[0]==='pr'&&a[1]==='view'){const p=d.prs[a[2]];if(!p){console.error('no pr');process.exit(1)}", + " let head='';try{head=g('rev-parse','refs/heads/'+p.headRefName)}catch{};", + " console.log(JSON.stringify({...p,headRefOid:head}));process.exit(0)}", + "if(a[0]==='pr'&&a[1]==='list'){console.log(JSON.stringify(Object.entries(d.prs).filter(([,p])=>p.state==='OPEN').map(([n,p])=>({number:Number(n),headRefOid:g('rev-parse','refs/heads/'+p.headRefName),isCrossRepository:false}))));process.exit(0)}", + "if(a[0]==='api'){const m=/commits\\/([0-9a-f]+)\\/(check-runs|status)/.exec(a[1]);if(!m)process.exit(1);", + " if(m[2]==='status'){console.log(JSON.stringify({total_count:0,statuses:[]}));process.exit(0)}", + " const red=d.red.includes(m[1]);console.log(JSON.stringify({total_count:1,check_runs:[{id:1,name:'free-tests',status:'completed',conclusion:red?'failure':'success',completed_at:'2026-10-29T00:00:00Z',app:{id:1}}]}));process.exit(0)}", + "if(a[0]==='pr'&&a[1]==='merge'){const p=d.prs[a[2]];const want=a[a.indexOf('--match-head-commit')+1];const head=g('rev-parse','refs/heads/'+p.headRefName);", + " if(head!==want){console.error('Head branch was modified');process.exit(1)}", + " if(d.driftOnMerge){const m=g('rev-parse','refs/heads/main');const t=g('rev-parse',m+'^{tree}');g('update-ref','refs/heads/main',g('commit-tree',t,'-p',m,'-m','outside merge'));d.driftOnMerge=false}", + " const main=g('rev-parse','refs/heads/main');const sq=g('commit-tree',g('rev-parse',want+'^{tree}'),'-p',main,'-m','squash #'+a[2]);g('update-ref','refs/heads/main',sq);p.state='MERGED';save();process.exit(0)}", + "console.error('gh shim: unsupported '+a.join(' '));process.exit(1);", + '', + ].join('\n'), { mode: 0o755 }); + const env = { PATH: dir + ':' + (process.env.PATH ?? ''), LAND_SHIM_DATA: data, LAND_SHIM_ORIGIN: repo.origin }; + return { repo, data, env, write: fn => { const d = JSON.parse(fs.readFileSync(data, 'utf8')); fn(d); fs.writeFileSync(data, JSON.stringify(d)); } }; +} + +/** A PR branch from origin/main, pushed, with a fragment (or none), and a /ship receipt for its head in the shim. */ +function openPr(fx: Fx, pr: string, opts: { fragment?: string | null; edit?: (work: string) => void; shipped?: boolean } = {}): string { + const { repo } = fx; + repo.git('checkout', '-q', 'origin/main', '-B', 'pr-' + pr); + fs.writeFileSync(path.join(repo.work, 'pr-' + pr + '.txt'), pr + '\n'); + if (opts.fragment !== null) { + fs.mkdirSync(path.join(repo.work, 'changelog.d'), { recursive: true }); + fs.writeFileSync(path.join(repo.work, 'changelog.d', 'pr-' + pr + '.md'), opts.fragment ?? '- #' + pr + ' change\n'); + } + opts.edit?.(repo.work); + repo.git('add', '-A'); + repo.git('commit', '-q', '-m', 'pr ' + pr); + repo.git('push', '-q', '-f', 'origin', 'HEAD:refs/heads/pr-' + pr); + const head = repo.git('rev-parse', 'HEAD'); + fx.write(d => { d.prs[pr] = { state: 'OPEN', headRefName: 'pr-' + pr, isCrossRepository: false, title: 'PR ' + pr + ' title', body: opts.shipped === false ? '' : shippedBody(pr, head) }; }); + repo.git('checkout', '-q', '--detach', 'origin/main'); + return head; +} +const land = (fx: Fx, args: string[]) => runBin(fx.repo, 'gstack-ship-queue', ['land', ...args, '--base', 'main', '--no-wait', '--allow-repo-commands'], fx.env); +const remote = (fx: Fx, ref: string) => { fx.repo.git('fetch', '-q', 'origin', '+refs/heads/*:refs/remotes/origin/*'); return fx.repo.git('rev-parse', 'origin/' + ref); }; +const show = (fx: Fx, rev: string) => fx.repo.git('show', rev); + +describe('land: fold, push, merge', () => { + test('happy path: a stamp commit with parents [pin, main] and the folded tree, then a squash whose tree is that tree', () => { + const fx = setup(); + const base = remote(fx, 'main'); + const pin = openPr(fx, '7', { fragment: '---\nbump: minor\n---\n- **Fragments.** #7\n' }); + const first = land(fx, ['--pr', '7']); + expect(first.err).toBe(''); + expect(first.status).toBe(EXIT.ok); + expect(first.out).toContain('STAMP_VERSION: 1.3.0.0'); + expect(first.out).toMatch(/^NEXT: gstack-pr-wait 7 --until green && gstack-ship-queue land --pr 7$/m); + const stamp = remote(fx, 'pr-7'); + expect(fx.repo.git('rev-list', '--parents', '-n', '1', stamp).split(' ').slice(1)).toEqual([pin, base]); + expect(show(fx, stamp + ':VERSION')).toBe('1.3.0.0'); + expect(fx.repo.git('ls-tree', '--name-only', stamp, 'changelog.d/')).toBe(''); + expect(show(fx, stamp + ':CHANGELOG.md')).toMatch(/^# Changelog\n\n## \[1\.3\.0\.0\] - \d{4}-\d{2}-\d{2}\n\n- \*\*Fragments\.\*\* #7\n\n## \[1\.2\.3\.0\]/); + const second = land(fx, ['--pr', '7']); + expect(second.err).toBe(''); + expect(second.out).toContain('MERGED: #7'); + expect(second.out).toMatch(/^LANDED: origin\/main tree [0-9a-f]+ equals the stamped tree$/m); + expect(second.out).toContain('LOCK: released'); + const main = remote(fx, 'main'); + expect(fx.repo.git('rev-parse', main + '^{tree}')).toBe(fx.repo.git('rev-parse', stamp + '^{tree}')); + }, 90_000); + + test('two PRs from one base land one after the other with no collision and no lane round trip', () => { + const fx = setup(); + openPr(fx, '1'); + openPr(fx, '2'); + for (const pr of ['1', '2']) { expect(land(fx, ['--pr', pr]).status).toBe(EXIT.ok); expect(land(fx, ['--pr', pr]).out).toContain('LANDED:'); } + const main = remote(fx, 'main'); + expect(show(fx, main + ':VERSION')).toBe('1.2.5.0'); + const cl = show(fx, main + ':CHANGELOG.md'); + expect(cl.indexOf('## [1.2.5.0]')).toBeLessThan(cl.indexOf('- #2 change')); + expect(cl.indexOf('- #2 change')).toBeLessThan(cl.indexOf('## [1.2.4.0]')); + expect(cl.indexOf('## [1.2.4.0]')).toBeLessThan(cl.indexOf('- #1 change')); + }, 90_000); + + test('a legacy PR with its own VERSION bump and a clean dependency edit: release fields resolve to main, the dependency edit survives', () => { + const fx = setup(); + openPr(fx, '1'); + expect(land(fx, ['--pr', '1']).status).toBe(EXIT.ok); land(fx, ['--pr', '1']); + fx.repo.git('fetch', '-q', 'origin'); + fx.repo.git('checkout', '-q', '-B', 'pr-3', fx.repo.git('rev-parse', 'origin/main') + '~1'); + const pkg = JSON.parse(readFile(fx.repo, 'package.json')); + pkg.version = '1.2.9'; pkg.dependencies['right-pad'] = '2.0.0'; + fs.writeFileSync(path.join(fx.repo.work, 'package.json'), JSON.stringify(pkg, null, 2) + '\n'); + fs.writeFileSync(path.join(fx.repo.work, 'VERSION'), '1.2.9.0\n'); + fs.writeFileSync(path.join(fx.repo.work, 'CHANGELOG.md'), readFile(fx.repo, 'CHANGELOG.md').replace('# Changelog\n', '# Changelog\n\n## [1.2.9.0] - 2026-10-28\n\n- legacy #3\n')); + fx.repo.git('add', '-A'); fx.repo.git('commit', '-q', '-m', 'legacy stamped pr'); + fx.repo.git('push', '-q', '-f', 'origin', 'HEAD:refs/heads/pr-3'); + const head = fx.repo.git('rev-parse', 'HEAD'); + fx.write(d => { d.prs['3'] = { state: 'OPEN', headRefName: 'pr-3', isCrossRepository: false, title: 'legacy', body: shippedBody('3', head) }; }); + const r = land(fx, ['--pr', '3']); + expect(r.err).toBe(''); + expect(r.out).toMatch(/^legacy: the pin stamped 1\.2\.9\.0 itself/m); + expect(r.out).toMatch(/^resolved: .*CHANGELOG\.md/m); + const stamp = remote(fx, 'pr-3'); + expect(show(fx, stamp + ':VERSION')).toBe('1.2.5.0'); + const merged = JSON.parse(show(fx, stamp + ':package.json')); + expect(merged.dependencies).toEqual({ 'left-pad': '1.2.3', 'right-pad': '2.0.0' }); + expect(show(fx, stamp + ':CHANGELOG.md')).toMatch(/## \[1\.2\.5\.0\][^\n]*\n\n- legacy #3\n\n## \[1\.2\.4\.0\][^\n]*\n\n- #1 change/); + }, 90_000); + + test('a PR with no entry gets one from its title; a product conflict refuses with nothing pushed', () => { + const fx = setup(); + openPr(fx, '4', { fragment: null, edit: w => fs.writeFileSync(path.join(w, 'shared.txt'), 'four\n') }); + openPr(fx, '5', { edit: w => fs.writeFileSync(path.join(w, 'shared.txt'), 'five\n') }); + const r = land(fx, ['--pr', '4']); + expect(r.out).toContain('entry: none in the PR, so land wrote changelog.d/land-4.md from the title'); + expect(show(fx, remote(fx, 'pr-4') + ':CHANGELOG.md')).toContain('- PR 4 title (#4)'); + land(fx, ['--pr', '4']); + const pin5 = remote(fx, 'pr-5'); + const c = land(fx, ['--pr', '5']); + expect(c.status).toBe(EXIT.refused); + expect(c.err).toMatch(/#5 conflicts with origin\/main in shared\.txt.*\(QUEUE_LAND_CONFLICT\)/); + expect(remote(fx, 'pr-5')).toBe(pin5); + }, 90_000); +}); + +describe('land: lock, races and refusals', () => { + test('a held lock refuses; --break-lock takes over a lock that is not mid-push', () => { + const fx = setup(); + openPr(fx, '1'); openPr(fx, '2'); + expect(land(fx, ['--pr', '1']).status).toBe(EXIT.ok); + const held = land(fx, ['--pr', '2']); + expect(held.err).toMatch(/#1 holds the land lock .*phase waiting.*\(QUEUE_LAND_LOCKED\)/); + const broke = land(fx, ['--pr', '2', '--break-lock']); + expect(broke.out).toContain("LOCK: broke #1's lock"); + }, 90_000); + + test('the base moving during CI restamps and pushes under the last-pushed lease; a third move stops', () => { + const fx = setup(); + openPr(fx, '1'); openPr(fx, '2'); + const pin1 = remote(fx, 'pr-1'); + expect(land(fx, ['--pr', '1']).status).toBe(EXIT.ok); + const firstStamp = remote(fx, 'pr-1'); + const bump = (n: number) => { fx.repo.git('fetch', '-q', 'origin'); fx.repo.git('checkout', '-q', '--detach', 'origin/main'); fs.writeFileSync(path.join(fx.repo.work, 'outside-' + n + '.txt'), 'x\n'); fx.repo.git('add', '-A'); fx.repo.git('commit', '-q', '-m', 'outside ' + n); fx.repo.git('push', '-q', 'origin', 'HEAD:refs/heads/main'); }; + bump(1); + const again = land(fx, ['--pr', '1']); + expect(again.err).toBe(''); + expect(again.out).toMatch(/^BASE_MOVED: /m); + const secondStamp = remote(fx, 'pr-1'); + expect(secondStamp).not.toBe(firstStamp); + expect(fx.repo.git('rev-list', '--parents', '-n', '1', secondStamp).split(' ')[1]).toBe(pin1); + bump(2); expect(land(fx, ['--pr', '1']).status).toBe(EXIT.ok); + bump(3); + expect(land(fx, ['--pr', '1']).err).toContain('(QUEUE_LAND_BASE_BUSY)'); + }, 90_000); + + test('a missing receipt refuses before the lock; a foreign push after the stamp is never overwritten', () => { + const fx = setup(); + openPr(fx, '6', { shipped: false }); + expect(land(fx, ['--pr', '6']).err).toContain('(QUEUE_RECEIPT_MISSING)'); + openPr(fx, '8'); + expect(land(fx, ['--pr', '8']).status).toBe(EXIT.ok); + fx.repo.git('fetch', '-q', 'origin'); + fx.repo.git('checkout', '-q', '--detach', 'origin/pr-8'); + fs.writeFileSync(path.join(fx.repo.work, 'late.txt'), 'late\n'); fx.repo.git('add', '-A'); fx.repo.git('commit', '-q', '-m', 'late'); + fx.repo.git('push', '-q', 'origin', 'HEAD:refs/heads/pr-8'); + const late = fx.repo.git('rev-parse', 'HEAD'); + const r = land(fx, ['--pr', '8', '--break-lock']); + expect(r.err).toMatch(/QUEUE_RECEIPT_STALE/); + expect(remote(fx, 'pr-8')).toBe(late); + }, 90_000); + + test('drift: a merge outside land between the check and the squash fails loudly and keeps the lock', () => { + const fx = setup(); + openPr(fx, '9'); + expect(land(fx, ['--pr', '9']).status).toBe(EXIT.ok); + fx.write(d => { d.driftOnMerge = true; }); + const r = land(fx, ['--pr', '9']); + expect(r.err).toContain('(QUEUE_LAND_DRIFT)'); + openPr(fx, '10'); + expect(land(fx, ['--pr', '10']).err).toMatch(/#9 holds the land lock .*phase drift.*\(QUEUE_LAND_LOCKED\)/); + }, 90_000); + + test('census lists what land would do, writing nothing', () => { + const fx = setup(); + openPr(fx, '1'); openPr(fx, '2', { fragment: null }); + const c = runBin(fx.repo, 'gstack-ship-queue', ['land', '--census', '--base', 'main'], fx.env); + expect(c.status).toBe(EXIT.ok); + expect(c.out).toMatch(/^#1 [0-9a-f]{12}: fold pr-1\.md$/m); + expect(c.out).toMatch(/^#2 [0-9a-f]{12}: no entry \(land writes one from the title\)$/m); + }, 90_000); +}); + +describe('resolveReleaseConflicts', () => { + test('a manifest whose version conflicts keeps the clean dependency edit and takes the base version; a real dependency conflict stays a conflict', () => { + const fx = setup(); + const { repo } = fx; + const write = (v: string, deps: Record) => fs.writeFileSync(path.join(repo.work, 'package.json'), JSON.stringify({ name: 'fixture', version: v, dependencies: deps }, null, 2) + '\n'); + repo.git('checkout', '-q', '-B', 'base-x', 'origin/main'); + write('1.2.3', { a: '1' }); repo.git('commit', '-qam', 'base'); + repo.git('checkout', '-q', '-B', 'ours-x'); write('1.2.9', { a: '1', b: '2' }); repo.git('commit', '-qam', 'ours'); + repo.git('checkout', '-q', '-B', 'theirs-x', 'base-x'); write('1.2.4', { a: '1' }); repo.git('commit', '-qam', 'theirs'); + repo.git('checkout', '-q', 'ours-x'); + try { repo.git('merge', '--no-commit', 'theirs-x'); } catch {} + const policy = mergePolicy(POLICY, 'test').policy; + expect(resolveReleaseConflicts(repo.work, ['package.json'], policy, 'VERSION')).toEqual([]); + expect(JSON.parse(readFile(repo, 'package.json'))).toEqual({ name: 'fixture', version: '1.2.4', dependencies: { a: '1', b: '2' } }); + repo.git('merge', '--abort'); + repo.git('checkout', '-q', 'theirs-x'); write('1.2.4', { a: '3' }); repo.git('commit', '-qam', 'theirs dep'); + repo.git('checkout', '-q', 'ours-x'); write('1.2.9', { a: '2', b: '2' }); repo.git('commit', '-qam', 'ours dep'); + try { repo.git('merge', '--no-commit', 'theirs-x'); } catch {} + expect(resolveReleaseConflicts(repo.work, ['package.json'], policy, 'VERSION')).toEqual(['package.json']); + }, 90_000); +}); diff --git a/test/version-gate-fragments.test.ts b/test/version-gate-fragments.test.ts new file mode 100644 index 000000000..fbd42d8f0 --- /dev/null +++ b/test/version-gate-fragments.test.ts @@ -0,0 +1,97 @@ +/** + * Version Gate under changelog_fragments (docs/ship-policy.md#changelog-fragments): an unstamped head + * keeps its release-version fields at its merge base and carries a fragment (or a legacy [Unreleased] + * entry, or is a bot PR); other manifest edits pass; a merge-step stamp commit must carry base plus the + * effective bump of the fragments its pin folds. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { BASE_POLICY, cleanup, makeFixtureRepo, ROOT, type FixtureRepo } from './helpers/restamp-fixture'; + +const SCRIPT = path.join(ROOT, 'scripts', 'compare-pr-version.ts'); +const POLICY = { ...BASE_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.92.0.0' }; +const repos: FixtureRepo[] = []; +afterEach(() => { while (repos.length) cleanup(repos.pop()!); }); +const fixture = () => { const r = makeFixtureRepo({ policy: POLICY, prefix: 'version-gate-frag-' }); repos.push(r); return r; }; +const write = (r: FixtureRepo, rel: string, text: string) => { fs.mkdirSync(path.dirname(path.join(r.work, rel)), { recursive: true }); fs.writeFileSync(path.join(r.work, rel), text); }; +const commit = (r: FixtureRepo, msg: string) => { r.git('add', '-A'); r.git('commit', '-q', '-m', msg); return r.git('rev-parse', 'HEAD'); }; +function run(r: FixtureRepo, head: string, extra: Record = {}) { + const next = path.join(r.root, 'next.json'); + fs.writeFileSync(next, JSON.stringify({ version: '1.2.4.0', base_version: '1.2.3.0', claimed: [] })); + const prVersion = r.git('show', head + ':VERSION').trim(); + const out = Bun.spawnSync([process.execPath, 'run', SCRIPT, next, '3600'], { cwd: r.work, env: { ...process.env, ...r.env, PR_VERSION: prVersion, BASE_REF: 'main', HEAD_SHA: head, PR_AUTHOR: 'garrytan', ...extra }, timeout: 30_000, stdout: 'pipe', stderr: 'pipe' }); + return { code: out.exitCode, out: out.stdout.toString() + out.stderr.toString() }; +} + +describe('version gate: changelog fragments', () => { + test('a fragment head passes, a dependency or script edit beside it too', () => { + const r = fixture(); + r.git('checkout', '-q', '-b', 'f'); + write(r, 'changelog.d/f.md', '- f\n'); + const pkg = JSON.parse(fs.readFileSync(path.join(r.work, 'package.json'), 'utf8')); + pkg.dependencies['right-pad'] = '2.0.0'; pkg.scripts.build = 'node build.js'; + write(r, 'package.json', JSON.stringify(pkg, null, 2) + '\n'); + const res = run(r, commit(r, 'f')); + expect(res.code).toBe(0); + expect(res.out).toContain('its entry is 1 CHANGELOG fragment(s)'); + }); + + test('a premature version change fails; no entry fails for a person and passes for a bot', () => { + const r = fixture(); + r.git('checkout', '-q', '-b', 'g'); + write(r, 'changelog.d/g.md', '- g\n'); + write(r, 'VERSION', '1.2.9.0\n'); + const early = run(r, commit(r, 'g')); + expect(early.code).toBe(1); + expect(early.out).toContain('must carry v1.2.4.0'); + r.git('checkout', '-q', '-B', 'h', 'origin/main'); + write(r, 'h.txt', 'h\n'); + const head = commit(r, 'h'); + const none = run(r, head); + expect(none.code).toBe(1); + expect(none.out).toContain('add a CHANGELOG fragment: changelog.d/.md'); + expect(run(r, head, { PR_AUTHOR: 'dependabot[bot]' }).code).toBe(0); + }); + + test('an unchanged branch on an advanced base passes; an invalid fragment fails', () => { + const r = fixture(); + r.git('checkout', '-q', '-b', 'k'); + write(r, 'changelog.d/k.md', '- k\n'); + const head = commit(r, 'k'); + r.git('checkout', '-q', 'main'); + write(r, 'VERSION', '1.2.4.0\n'); write(r, 'other.txt', 'x\n'); + commit(r, 'main moves'); r.git('push', '-q', 'origin', 'main'); + expect(run(r, head).code).toBe(0); + r.git('checkout', '-q', '-B', 'bad', 'origin/main'); + write(r, 'changelog.d/bad.md', '## [9.9.9.9]\n- bad\n'); + const bad = run(r, commit(r, 'bad')); + expect(bad.code).toBe(1); + expect(bad.out).toContain('changelog.d/bad.md: a fragment carries no release heading'); + }); + + test('a merge-step stamp commit [pin, base] carries base plus its fragments\' effective bump', () => { + const r = fixture(); + r.git('checkout', '-q', '-b', 'm'); + write(r, 'changelog.d/m.md', '---\nbump: minor\n---\n- m\n'); + const pin = commit(r, 'm'); + const stamp = (version: string) => { + r.git('checkout', '-q', '-f', '--detach', pin); + fs.rmSync(path.join(r.work, 'changelog.d', 'm.md')); + write(r, 'VERSION', version + '\n'); + write(r, 'CHANGELOG.md', fs.readFileSync(path.join(r.work, 'CHANGELOG.md'), 'utf8').replace('# Changelog\n\n', '# Changelog\n\n## [' + version + '] - 2026-10-29\n\n- m\n\n')); + r.git('add', '-A'); + return r.git('commit-tree', r.git('write-tree'), '-p', pin, '-p', r.git('rev-parse', 'origin/main'), '-m', 'v' + version + ' stamp'); + }; + expect(run(r, stamp('1.3.0.0')).code).toBe(0); + const wrong = run(r, stamp('1.2.4.0')); + expect(wrong.code).toBe(1); + expect(wrong.out).toContain('must carry v1.3.0.0'); + }); + + test('the workflow runs on every PR (no path filter), so a PR without a release file still proves its fragment', () => { + const workflow = Bun.YAML.parse(fs.readFileSync(path.join(ROOT, '.github', 'workflows', 'version-gate.yml'), 'utf8')) as any; + expect(workflow.on.pull_request.paths).toBeUndefined(); + expect(workflow.on.pull_request.branches).toEqual(['main']); + }); +}); From 859938d7ee50b0ceb70268551b1083df79a7ef71 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 04:30:39 +0000 Subject: [PATCH 03/31] feat(ship-queue): land --repair after drift; hook base sha; docs and CHANGELOG Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 20 ++++ bin/gstack-ship-queue | 5 +- docs/ship-policy.md | 127 ++++++++++++++++++++++++++ docs/troubleshooting.md | 59 ++++++++++++ lib/result-codes-release.ts | 2 +- lib/ship-queue-land.ts | 67 ++++++++++++-- test/restamp-command-boundary.test.ts | 14 +++ test/ship-queue-land.test.ts | 18 +++- 8 files changed, 302 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7263d2c3a..62b9f8da8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## [Unreleased] + +**A repository can now assign its version when a PR merges instead of inside the PR: PRs carry unversioned CHANGELOG fragments and `gstack-ship-queue land` stamps, pushes and merges.** Today every PR picks a version before it merges, so each merge makes the next PR's version stale and its lane redoes the stamp (a median 14.5 minutes from stamp commit to merge over 30 slots, Oct 27-29; the queue froze on hand-made stamps in #3422 and #3487). With `changelog_fragments` on, two PRs cut from one base cannot collide, and nobody stamps by hand. gstack's own policy does not turn it on in this release (docs/ship-policy.md#changelog-fragments). + +### Added +- **`changelog_fragments: { dir }`** (`lib/ship-policy.ts`, `lib/changelog-fragments.ts`): a PR adds `/.md` with its entry text and optional `bump: minor|major` front matter. Fragments are read as Git blobs only; a symlink, gitlink, nested path, empty or oversized fragment, one with a release heading, or a reused name is `CHANGELOG_FRAGMENT_INVALID` before any version is allocated. +- **`gstack-restamp` folds fragments**: their bodies go above any legacy `[Unreleased]` section under one new heading, the files are deleted, and the highest declared bump wins over the policy's. The tree receipt's `changelog-fragments` rule accepts exactly that fold and nothing else. +- **`gstack-ship-queue land --pr `** (`lib/ship-queue-land.ts`): the per-PR merge step. It takes the land lock (`refs/notes/gstack-land`), checks the /ship receipt and checks at the pin, merges the base resolving release fields only (a clean dependency edit survives; a product conflict refuses), stamps, pushes a commit with parents [pin, base] under an exact lease, waits for checks, restamps if the base moved (at most twice), squashes with `--match-head-commit` and confirms the base tree. Every step is journaled and resumable. `--repair` opens the follow-up PR after `QUEUE_LAND_DRIFT`; `--census` prints what land would do with every open PR, writing nothing. +- **`restamp_command` gets `GSTACK_RESTAMP_BASE_SHA`**, the release predecessor, so a repo counter such as a SAVE_VERSION computes predecessor + 1 from the base and never from the branch. +- New result codes with troubleshooting anchors: `CHANGELOG_FRAGMENT_INVALID`, `QUEUE_LAND_LOCKED`, `QUEUE_LAND_CONFLICT`, `QUEUE_LAND_DRIFT`, `QUEUE_LAND_BASE_BUSY`. + +### Changed +- **Version Gate runs on every PR** (the path filter is gone), so under `changelog_fragments` a PR that touches no release file still proves it carries a fragment. An unstamped head keeps its release fields at its merge base and adds a valid fragment (legacy `[Unreleased]` passes; a `[bot]` author needs none); a merge-step stamp commit must carry the base plus its fragments' effective bump. Without the key nothing changes. + +### Tests +- `test/changelog-fragments.test.ts`: blob-only reads and refusals, bump precedence, fold order with a legacy section, the restamp fold and its tree receipt, an edited fold refused. +- `test/ship-queue-land.test.ts` (bare origin and a gh shim): happy path; two PRs from one base without a collision; a legacy stamp plus a dependency edit; a title-derived entry; a product conflict; the lock and `--break-lock`; the base moving during CI; a foreign push never overwritten; a missing receipt; drift, `--repair` and the lock release; the census. +- `test/version-gate-fragments.test.ts`: fragment heads, premature version changes, a bot author, an advanced base, an invalid fragment, the stamp commit's effective bump, and the workflow's missing path filter. +- `test/restamp-command-boundary.test.ts`: the hook reads its counter from `GSTACK_RESTAMP_BASE_SHA`, not the branch. + ## [1.91.312.0] - 2026-10-29 **An install check run from inside /ship no longer upgrades gstack under it.** /ship's docs step runs `scripts/install.sh --for document-release`. That run fast-forwarded the checkout from 1.91.298 to 1.91.299 in the middle of a lane's /ship, so the receipt writer's rules changed partway through the run (#3571). diff --git a/bin/gstack-ship-queue b/bin/gstack-ship-queue index 591ed9f77..b05dc7e4a 100755 --- a/bin/gstack-ship-queue +++ b/bin/gstack-ship-queue @@ -15,6 +15,7 @@ * gstack-ship-queue verify --pr --screen (train policy: the head's screen verdict from its run) * gstack-ship-queue verify --post-merge [--timeout ] (after a merge: main's push workflows at that commit until they settle) * gstack-ship-queue land --pr [--dry-run] [--no-wait] [--break-lock] (the merge step; lib/ship-queue-land.ts) + * gstack-ship-queue land --pr --repair (after QUEUE_LAND_DRIFT: open the outside merge's release PR) * gstack-ship-queue land --census * gstack-ship-queue train build|status|verdict|bisect|rebuild|watch|prune ... (merge trains; train --help) * @@ -57,6 +58,8 @@ const HELP = `gstack-ship-queue — the shared merge queue's protocol steps (doc land --pr [--dry-run] [--no-wait] [--timeout ] [--break-lock] [--allow-repo-commands] the merge step: lock, merge the base, stamp (fragments folded), push the stamp commit, wait for its checks, squash with --match-head-commit, confirm the base tree; resumable + land --pr --repair after QUEUE_LAND_DRIFT: open the PR that gives the merge outside land its own + release; /ship and land it, which releases the lock land --census what land would do with every open PR (read-only) train build|status|verdict|bisect|rebuild|watch|prune ... merge trains, when the base policy declares a train block (train --help; @@ -164,7 +167,7 @@ async function main(argv: string[]): Promise { if (args.includes('--census')) return finish('land', l.census(ctx)); if (!pr || !/^\d+$/.test(pr)) return usage('land needs --pr (or --census)'); const timeout = flag(args, '--timeout'); - return finish('land', l.land(ctx, { pr, dryRun: args.includes('--dry-run'), noWait: args.includes('--no-wait'), breakLock: args.includes('--break-lock'), allowRepoCommands: args.includes('--allow-repo-commands'), waitTimeoutMin: timeout && /^\d+$/.test(timeout) ? Number(timeout) : undefined })); + return finish('land', l.land(ctx, { pr, repair: args.includes('--repair'), dryRun: args.includes('--dry-run'), noWait: args.includes('--no-wait'), breakLock: args.includes('--break-lock'), allowRepoCommands: args.includes('--allow-repo-commands'), waitTimeoutMin: timeout && /^\d+$/.test(timeout) ? Number(timeout) : undefined })); } if (!pr || !/^\d+$/.test(pr)) return usage(`${cmd} needs --pr `); const gateExit = flag(args, '--gate-exit'); diff --git a/docs/ship-policy.md b/docs/ship-policy.md index c08973c92..6fe54954d 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -61,6 +61,7 @@ refuses the file (`POLICY_GSTACK_TOO_OLD`). `validate` ends with | `min_gstack_version` | version or `null` | `null` | The oldest gstack that may read the file; see the compatibility rule above. | | `docs` | `{ caps: [{ path, max_bytes }] }` or `null` | `null` | Documentation files with a byte cap. `/ship` Step 14.5 runs `gstack-ship-receipt docs-caps --base ` (the caps come from `origin/`'s policy, never the branch's copy; `--policy ` is for a repository bootstrapping its policy) before the verification gate and prints one `DOCS_CAP: / ok\|over` line per cap; any `over` (an absent file counts) exits 1 and blocks the ship until the file is trimmed or the cap raised on the base branch (`DOCS_AUDIT_BLOCKED`). At most 64 caps, each path contained. See [the ship receipt](#ship-receipt). Needs `min_gstack_version`. | | `train` | `{ gate_workflow, free_workflow, full_gate_check, record_artifact, max_size, fill_hours, screen_selection, trial_reservation_usd }` or `null` | `null` | Turns on [merge trains](#run-a-merge-train), read from the base revision only. Every field is required and an unknown field is `POLICY_INVALID`: `max_size` 1 to 6, `fill_hours` above 0 and at most 24, `screen_selection` `gstack-paid` \| `none`, `trial_reservation_usd` above 0 and at most 100. `null` keeps the per-PR queue exactly as it is. Needs `min_gstack_version`. | +| `changelog_fragments` | `{ dir }` or `null` | `null` | PRs carry unversioned [CHANGELOG fragments](#changelog-fragments) in `dir` (one flat, repo-relative directory, no `..`) and the merge step ([`land`](#land)) assigns the version. `null` keeps `[Unreleased]` sections and per-PR stamps. Needs `min_gstack_version`. | Containment, checked by `validate` and again at write time: paths and globs are repo-relative with no `..` segment, under 256 characters, at most 64 per list, @@ -1142,6 +1143,132 @@ Read from the base branch and run only with `--allow-repo-commands`, like of its own choosing cannot run here; give it a mode that takes the version and writes only stamps. +The command also gets `GSTACK_RESTAMP_BASE_SHA`, the release predecessor: the +base branch commit the version is allocated after (origin/ for a per-PR +restamp and for `land`). A repo counter that must be predecessor + 1 (a +SAVE_VERSION, a rules number) reads its old value from that commit +(`git show "$GSTACK_RESTAMP_BASE_SHA:"`), never from the branch, so two PRs +cut from one base never both claim the same number. Regenerated goldens go in +`release_outputs`. + + +## `changelog_fragments`: entries without versions, versioned at merge + +With `changelog_fragments: { "dir": "changelog.d" }` on the base branch, a PR +never edits VERSION, the manifest `version` key or a released CHANGELOG +section. It adds one or more fragments, and the merge step assigns the +version, so two PRs cut from one base cannot collide and no lane redoes a +stamp after another PR merges. + +A fragment is `/.md`, one regular file directly in `dir` +(`README.md` there is skipped). Its body is the entry text, exactly what the +CHANGELOG section will hold, with no `## [x]` heading. It may open with front +matter that raises the bump: + +```markdown +--- +bump: minor +--- +**What changed, in one sentence.** + +### Fixed +- ... +``` + +The effective bump of a stamp is the highest of the policy bump and every +folded fragment's `bump:` (`micro` < `patch` < `minor` < `major`). Fragments +are read as Git blobs at the commit being stamped (`git ls-tree` and +`git cat-file`, never the working tree), so a symlink, a gitlink, a nested +path, a non-`.md` file, an empty fragment, one over 64 KiB, one carrying a +release heading, or one whose name already landed on the base with other text +is `CHANGELOG_FRAGMENT_INVALID`, and nothing is allocated. Only fragments the +branch adds against the base count. + +**The fold.** `gstack-restamp` (and so `land`) concatenates the fragment +bodies in path order above any legacy `[Unreleased]` section, re-heads both as +one `## [] - ` section at the top, and deletes the fragment +files, in one stamp. The tree receipt's `changelog-fragments` rule calls a +deleted fragment a stamp only when its body appears verbatim, in order, among +the lines added under the new heading; an edited fold, or a fragment deleted +without its text, is a change. + +**Version Gate.** The workflow runs on every PR (no path filter), so a PR +that touches no release file still has to prove its entry. Under the key, an +unstamped head must keep VERSION, the manifest `version` key and every +released CHANGELOG section at its merge base's values, and add at least one +valid fragment (a legacy `[Unreleased]`-only entry passes during migration; +a `[bot]` author, such as Dependabot, needs none). Other manifest edits, a +dependency or a script, pass. A head made by the merge step, a commit whose +parents are [pin, base], must carry the base's version plus the effective +bump of the fragments its pin adds. `gstack-ship-queue ready` applies the same +rule. + + +## `gstack-ship-queue land`: the per-PR merge step + +`land --pr ` merges one READY PR whose head is unstamped. It replaces the +lane's stamp round trip: nobody stamps by hand, and the stamp commit is made +and pushed by the tool. Steps, each journaled in +`.gstack/tmp/ship-queue/land/.json` and resumable by rerunning the same +command: + +1. **Refuse** a fork head, a closed PR, a /ship receipt that is missing or + bound to another head (`QUEUE_RECEIPT_MISSING`, `QUEUE_RECEIPT_STALE`), and + required checks that are not green at the pin. +2. **Lock.** Take the repository land lock, `refs/notes/gstack-land` (its own + ref, never the trains notes ref), by exact lease. It records the PR, holder, + phase and journal. A held lock is `QUEUE_LAND_LOCKED` naming the holder; + `--break-lock` takes over a crashed holder's lock and is refused while that + holder is mid-push. +3. **Merge and stamp.** In a scratch worktree, merge the base into the pin. + Conflicts in release fields only (the version file and `release_outputs` + take the base's side, the CHANGELOG merges by section, a manifest or npm + lockfile is re-merged with every side's release version set to the base's, + so a clean dependency edit survives) are resolved; anything else is + `QUEUE_LAND_CONFLICT`, nothing pushed, back to the lane. A legacy pin that + bumped VERSION itself has its release fields put back to its merge base + first and its entry re-headed `[Unreleased]`. A PR with no entry gets a + fragment written from its title. Then `gstack-restamp --next` folds and + allocates (`restamp_command` and `release_tool` need + `--allow-repo-commands`, as everywhere). +4. **Push.** `commit-tree` the stamped tree with parents [pin, base] and push + it to the PR branch under `--force-with-lease=:`. The journal + keeps the reviewed pin apart from the last pushed head, so a rerun leases + on what it pushed and never on a head someone else pushed + (`QUEUE_TRAIN_MOVED`, never overwritten). +5. **Wait** for the PR's checks on the stamp commit with `gstack-pr-wait` + (`--no-wait` prints the command instead; `--timeout `, default 60). + CI runs for real on the stamp commit in this mode. +6. **Merge.** Re-read the base first: if it moved, restamp from the new base + and push again under the last-pushed lease, at most twice + (`QUEUE_LAND_BASE_BUSY` on a third move). Then + `gh pr merge --squash --match-head-commit ` and confirm the base's + tree is the stamped tree on the base land stamped. The stamp date is taken + once and reused by every restamp. +7. **Drift.** GitHub's merge API cannot bind the base, so a merge made outside + `land` can slip in between the check and the squash. The post-merge check + then fails with `QUEUE_LAND_DRIFT` and keeps the lock. + `land --pr --repair` opens a PR from the base whose only change is a + fragment for what the outside merge brought (its unfolded fragments, moved, + or one entry naming its commits); /ship and land that PR, and its land takes + over the drift lock and releases it. + +`--dry-run` merges and stamps in a scratch worktree and prints the version and +tree, taking no lock and pushing nothing. + + +### Migrating to fragments + +Before turning the key on, run `gstack-ship-queue land --census` (read-only). +It prints one line per open PR: fold its fragments, re-head a legacy +`[Unreleased]`, rewrite a tentative per-PR VERSION bump, resolve a +release-field-only conflict, write an entry from the title, or refuse on a +product conflict. Then set `changelog_fragments` and `min_gstack_version` in +one policy PR; a claim-at-open repository switches `queue_mode` to +`stamp-at-merge` in the same PR. Open PRs need no change: `land` handles each +census case. Rollback is removing the key; `gstack-restamp` still folds any +fragment it finds, so no entry is lost. + ## The tree receipt diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 1d20996f2..8bf5d4b30 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -4208,6 +4208,65 @@ reason and dispatches nothing. **Fix.** Run the printed `train build --prs `; the fork PR stays on the trusted-branch path. + +### `CHANGELOG_FRAGMENT_INVALID` + +**Meaning.** A CHANGELOG fragment under the policy's `changelog_fragments` +directory is not one regular `.md` file directly in that directory with entry +text, optional `bump:` front matter and no release heading, or it is over +64 KiB, or a fragment of that name already landed with other text. Nothing was +allocated or written. See [changelog fragments](ship-policy.md#changelog-fragments). + +**Fix.** Rewrite the named fragment as a flat `/.md` holding only +the entry text (and optional `bump: minor|major` front matter); rename it if +the name already landed. Then rerun. + + +### `QUEUE_LAND_LOCKED` + +**Meaning.** Another `gstack-ship-queue land` holds the repository land lock +(`refs/notes/gstack-land`), or took it from under this run, so this one would +race it for the next version. The line names the holder, its PR and phase. + +**Fix.** Wait for the holder to finish. If it crashed, rerun with +`--break-lock`; that is refused while the holder's last phase is a push less +than 10 minutes old. A lock in phase `drift` is released by landing its +repair PR ([QUEUE_LAND_DRIFT](#queue-land-drift)). + + +### `QUEUE_LAND_CONFLICT` + +**Meaning.** The PR conflicts with the base branch in a product file (or a +release field that cannot be resolved alone, such as a real dependency +conflict in a manifest), so the merge step cannot stamp it. Nothing was +pushed. + +**Fix.** The lane merges the base into the PR, resolves the named files, runs +/ship and reports READY again. + + +### `QUEUE_LAND_DRIFT` + +**Meaning.** After the squash merge, the base branch's tree is not the +stamped tree on the base `land` stamped: a merge made outside `land` slipped +in between the last check and the merge. The lock stays held, in phase +`drift`, so no other land allocates on a release record that is now wrong. + +**Fix.** Run `gstack-ship-queue land --pr --repair`. It opens a PR whose +only change is a fragment for what the outside merge brought. Run /ship on it +and land it; that land takes over the lock and releases it. Then find what +merged outside `land`. + + +### `QUEUE_LAND_BASE_BUSY` + +**Meaning.** The base branch moved during the stamp commit's CI on each of +the two allowed restamps, so `land` stopped instead of restamping again. + +**Fix.** Find what is merging outside `land` (every merge should go through +it), then rerun `gstack-ship-queue land --pr `; it resumes from its +journal. + ### `QUEUE_TRAIN_MOVED` diff --git a/lib/result-codes-release.ts b/lib/result-codes-release.ts index c508bc315..e9551f80a 100644 --- a/lib/result-codes-release.ts +++ b/lib/result-codes-release.ts @@ -318,7 +318,7 @@ export const RELEASE_RESULT_CODES = { QUEUE_LAND_DRIFT: { anchor: 'queue-land-drift', summary: 'the base branch moved between the merge step\'s last check and the squash merge, so main\'s tree is not the stamped tree land verified', - fix: 'run the printed gstack-ship-queue land --repair to renumber main\'s top entry through a stamp-only PR; the land lock stays held with the record until it merges', + fix: 'gstack-ship-queue land --pr --repair opens a PR whose only change is a fragment for what the outside merge brought; /ship and land it, which releases the lock', }, QUEUE_LAND_BASE_BUSY: { anchor: 'queue-land-base-busy', diff --git a/lib/ship-queue-land.ts b/lib/ship-queue-land.ts index 798a9d294..c245f19e7 100644 --- a/lib/ship-queue-land.ts +++ b/lib/ship-queue-land.ts @@ -30,10 +30,11 @@ export interface LandJournal { schema_version: 1; pr: string; branch: string; reviewed_pin: string; last_pushed_head: string | null; stamp_commit: string | null; stamp_tree: string | null; base_sha: string | null; version: string | null; date: string | null; restamps: number; + drift_main?: string; repair_pr?: string; phase: 'locked' | 'stamped' | 'pushed' | 'merged' | 'landed' | 'drift'; } export interface LandLock { pr: string; holder: string; started_at: string; phase: 'stamping' | 'pushing' | 'waiting' | 'merging' | 'drift'; journal: string } -export interface LandOptions { pr: string; dryRun?: boolean; noWait?: boolean; breakLock?: boolean; allowRepoCommands?: boolean; waitTimeoutMin?: number; now?: () => Date; holder?: string } +export interface LandOptions { pr: string; repair?: boolean; dryRun?: boolean; noWait?: boolean; breakLock?: boolean; allowRepoCommands?: boolean; waitTimeoutMin?: number; now?: () => Date; holder?: string } const fail = (lines: string[], failure: QueueFailure, run: string): StepOutput => ({ ok: false, lines, failure, run }); export const landJournalRel = (pr: string) => `${QUEUE_DIR_REL}/land/${pr}.json`; @@ -229,6 +230,54 @@ export function census(ctx: QueueContext): StepOutput { return { ok: true, run: 'none', lines }; } +/** + * `land --repair --pr ` after QUEUE_LAND_DRIFT: a merge outside land slipped in beside #n, so what it + * brought has no release of its own. Open a PR from the base whose only change is a fragment for it: each + * fragment the outside merge left unfolded on the base moves to repair--.md (so it counts as new), + * or, with none, one entry naming the outside commits. That PR goes through /ship and land like any other; + * its land takes over #n's drift lock and releases it. + */ +function repair(ctx: QueueContext, policy: ShipPolicy, j: LandJournal | null, file: string, run: string): StepOutput { + const root = ctx.repoRoot; + if (!j || j.phase !== 'drift' || !j.drift_main || !j.base_sha) return fail([], { code: 'QUEUE_NOT_READY', message: `#${j?.pr ?? '?'} has no drift record; --repair runs only after QUEUE_LAND_DRIFT` }, run); + const lines = [`REPAIR: #${j.pr} landed on ${j.drift_main.slice(0, 12)} beside a merge outside land`]; + if (j.repair_pr) return { ok: true, run, lines: [...lines, `REPAIR_PR: #${j.repair_pr} (already open)`, `NEXT: run /ship on #${j.repair_pr}, then gstack-ship-queue land --pr ${j.repair_pr}`] }; + const dir = policy.changelog_fragments?.dir?.replace(/\/+$/, ''); + if (!dir) return fail(lines, { code: 'QUEUE_NOT_READY', message: 'changelog_fragments is off: write the [Unreleased] entry for the outside merge by hand and land it' }, run); + git(root, ['fetch', '-q', 'origin', ctx.base]); + const main = git(root, ['rev-parse', `origin/${ctx.base}`]).out; + const outside = git(root, ['log', '--first-parent', '--format=%h %s', `${j.base_sha}..${j.drift_main}^1`]).out.split('\n').filter(Boolean); + const stray = readFragments(root, main, dir, null); + const branch = `gstack-land-repair/${j.pr}`; + const wt = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-land-repair-')); + fs.rmSync(wt, { recursive: true, force: true }); + if (!git(root, ['worktree', 'add', '--detach', '-q', wt, main]).ok) return fail(lines, { code: 'QUEUE_NOT_READY', message: `cannot create a worktree at ${main.slice(0, 12)}` }, run); + try { + for (const f of stray.fragments) { + fs.renameSync(path.join(wt, f.path), path.join(wt, dir, `repair-${j.pr}-${path.basename(f.path)}`)); + lines.push(`moved: ${f.path} (left unfolded by the outside merge)`); + } + if (!stray.fragments.length) { + fs.mkdirSync(path.join(wt, dir), { recursive: true }); + fs.writeFileSync(path.join(wt, dir, `repair-${j.pr}.md`), `- Changes merged outside land beside #${j.pr}: ${outside.map(c => c.slice(c.indexOf(' ') + 1)).join('; ') || 'unknown'}\n`); + lines.push(`entry: ${dir}/repair-${j.pr}.md names ${outside.length} outside commit(s)`); + } + git(wt, ['add', '-A', '--', dir]); + if (!git(wt, ['commit', '-q', '--no-verify', '-m', `land: release entry for the merge outside land beside #${j.pr}`]).ok) return fail(lines, { code: 'QUEUE_NOT_READY', message: 'cannot commit the repair' }, run); + const head = git(wt, ['rev-parse', 'HEAD']).out; + const push = git(root, ['push', '-q', `--force-with-lease=refs/heads/${branch}:`, 'origin', `${head}:refs/heads/${branch}`]); + if (!push.ok) return fail(lines, { code: 'QUEUE_TRAIN_MOVED', message: `${branch} already exists on origin; open or land its PR` }, run); + const pr = ctx.gh(['pr', 'create', '--base', ctx.base, '--head', branch, '--title', `Release entry for the merge outside land beside #${j.pr}`, '--body', `QUEUE_LAND_DRIFT repair (docs/troubleshooting.md#queue-land-drift). Outside commits: ${outside.join(', ') || 'unknown'}.`]); + const n = /\/pull\/(\d+)/.exec(pr.out)?.[1]; + if (!pr.ok || !n) return fail(lines, { code: 'QUEUE_NOT_READY', message: `gh pr create failed: ${(pr.err ?? pr.out).trim().slice(0, 160)}` }, run); + j.repair_pr = n; writeJsonAtomic(file, j); + return { ok: true, run, lines: [...lines, `REPAIR_PR: #${n} ${branch} ${head}`, `NEXT: run /ship on #${n}, then gstack-ship-queue land --pr ${n} (it takes over #${j.pr}'s drift lock and releases it)`] }; + } finally { + git(root, ['worktree', 'remove', '--force', wt]); + fs.rmSync(wt, { recursive: true, force: true }); + } +} + /** `gstack-ship-queue land --pr `: one resumable pass of the merge step. */ export function land(ctx: QueueContext, o: LandOptions): StepOutput { const root = ctx.repoRoot; @@ -239,6 +288,7 @@ export function land(ctx: QueueContext, o: LandOptions): StepOutput { const loaded = loadBasePolicy(ctx); if ('message' in loaded) return fail([], loaded, run); const policy = loaded.policy; + if (o.repair) return repair(ctx, policy, readJson(file), file, run); const info = prInfo(ctx, o.pr); if ('code' in info) return fail([], info, run); if (info.isCrossRepository) return fail([], { code: 'QUEUE_TRAIN_UNTRUSTED', message: `#${o.pr} is a fork head; land stamps and pushes only crew branches` }, run); @@ -263,7 +313,9 @@ export function land(ctx: QueueContext, o: LandOptions): StepOutput { } let { sha: lockSha, lock } = readLock(root); - if (lock && lock.pr !== o.pr) { + const repairOf = lock && lock.pr !== o.pr && lock.phase === 'drift' ? readJson(path.join(root, lock.journal)) : null; + if (repairOf?.repair_pr === o.pr) lines.push(`LOCK: taking over #${lock!.pr}'s drift lock for its repair`); + else if (lock && lock.pr !== o.pr) { const age = now().getTime() - Date.parse(lock.started_at); if (!o.breakLock) return fail(lines, { code: 'QUEUE_LAND_LOCKED', message: `#${lock.pr} holds the land lock (${lock.holder}, phase ${lock.phase}, since ${lock.started_at})` }, run); if (lock.phase === 'pushing' && age < PUSH_STALE_MS) return fail(lines, { code: 'QUEUE_LAND_LOCKED', message: `#${lock.pr}'s holder is mid-push (${Math.round(age / 1000)} s ago); --break-lock refused` }, run); @@ -289,11 +341,11 @@ export function land(ctx: QueueContext, o: LandOptions): StepOutput { j.restamps += 1; j.phase = 'locked'; save(); } if (j.phase === 'locked') { - const s = buildStamp(ctx, policy, { pin, baseSha: baseNow, pr: o.pr, title: info.title, allowRepoCommands: !!o.allowRepoCommands, now: now() }); + const s = buildStamp(ctx, policy, { pin, baseSha: baseNow, pr: o.pr, title: info.title, allowRepoCommands: !!o.allowRepoCommands, now: j.date ? new Date(`${j.date}T12:00:00Z`) : now() }); if ('code' in s) { setLock('stamping'); return fail([...lines], s, run); } lines.push(...s.lines); const commit = git(root, ['commit-tree', s.tree, '-p', pin, '-p', baseNow, '-m', `v${s.version} stamp: merge ${ctx.base} ${baseNow.slice(0, 12)} into #${o.pr} (land)`]).out; - Object.assign(j, { stamp_commit: commit, stamp_tree: s.tree, base_sha: baseNow, version: s.version, date: now().toISOString().slice(0, 10), phase: 'stamped' }); + Object.assign(j, { stamp_commit: commit, stamp_tree: s.tree, base_sha: baseNow, version: s.version, date: j.date ?? now().toISOString().slice(0, 10), phase: 'stamped' }); save(); lines.push(`STAMP_VERSION: ${s.version}`, `STAMP_COMMIT: ${commit} tree=${s.tree}`); } @@ -329,13 +381,14 @@ export function land(ctx: QueueContext, o: LandOptions): StepOutput { const tree = git(root, ['rev-parse', `${main}^{tree}`]).out; const parent = git(root, ['rev-parse', `${main}^1`]).out; if (tree !== j.stamp_tree || parent !== j.base_sha) { - j.phase = 'drift'; save(); setLock('drift'); - return fail([...lines, `MAIN: ${main} tree=${tree} parent=${parent}`], { code: 'QUEUE_LAND_DRIFT', message: `origin/${ctx.base} ${main.slice(0, 12)} is not the stamped tree on ${j.base_sha?.slice(0, 12)} (a merge outside land slipped in)`, fix: `renumber main's top CHANGELOG entry and VERSION with a stamp-only PR (gstack-restamp --next on a branch from origin/${ctx.base}, merged through land), then release the lock: gstack-ship-queue land --pr ${o.pr} --break-lock` }, run); + j.phase = 'drift'; j.drift_main = main; save(); setLock('drift'); + return fail([...lines, `MAIN: ${main} tree=${tree} parent=${parent}`], { code: 'QUEUE_LAND_DRIFT', message: `origin/${ctx.base} ${main.slice(0, 12)} is not the stamped tree on ${j.base_sha?.slice(0, 12)} (a merge outside land slipped in)`, fix: `gstack-ship-queue land --pr ${o.pr} --repair opens the PR that gives the outside merge its own release; /ship and land it, which releases the lock` }, run); } j.phase = 'landed'; save(); + if (repairOf?.repair_pr === o.pr) writeJsonAtomic(path.join(root, landJournalRel(repairOf.pr)), { ...repairOf, phase: 'landed' }); const rel = writeLock(root, lockSha, { released: true, by: holder, at: now().toISOString() }); return { ok: true, run, lines: [...lines, `MERGED: #${o.pr} as ${main} (${j.version})`, `LANDED: origin/${ctx.base} tree ${tree} equals the stamped tree`, `LOCK: ${rel.ok ? 'released' : 'release push failed (the next land takes it over)'}`] }; } - if (j.phase === 'drift') return fail(lines, { code: 'QUEUE_LAND_DRIFT', message: 'a previous land of this PR found drift; repair main first' }, run); + if (j.phase === 'drift') return fail(lines, { code: 'QUEUE_LAND_DRIFT', message: 'a previous land of this PR found drift', fix: `gstack-ship-queue land --pr ${o.pr} --repair` }, run); } } diff --git a/test/restamp-command-boundary.test.ts b/test/restamp-command-boundary.test.ts index 475a0d665..40f47d3b9 100644 --- a/test/restamp-command-boundary.test.ts +++ b/test/restamp-command-boundary.test.ts @@ -145,6 +145,7 @@ describe('restamp_command (field wave CD3): the repo owns the stamp inside the s "fs.writeFileSync('VERSION', (process.env.FIXTURE_WRONG_VERSION ? '9.9.9.9' : v) + '\\n');", "const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); pkg.version = v.split('.').slice(0, 3).join('.'); fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\\n');", "fs.writeFileSync('generated/digest.md', '# digest v' + v + '\\nbody\\n');", + "if (process.env.FIXTURE_SAVE) { const prev = Number(execSync('git show ' + process.env.GSTACK_RESTAMP_BASE_SHA + ':generated/save.txt', { encoding: 'utf8', timeout: 30000 })); fs.writeFileSync('generated/save.txt', String(prev + 1) + '\\n'); }", "if (process.env.FIXTURE_STRAY) fs.writeFileSync('stray.txt', 'x\\n');", "execSync('git add -A', { timeout: 30000 }); execSync('git -c user.name=r -c user.email=r@example.com -c commit.gpgsign=false commit -q -m restamp', { timeout: 30000 });", ].join('\n') + '\n'; @@ -196,6 +197,19 @@ describe('restamp_command (field wave CD3): the repo owns the stamp inside the s expect(fs.existsSync(journal(dirty))).toBe(false); }); + test('it gets the release predecessor in GSTACK_RESTAMP_BASE_SHA, so a repo counter (a SAVE_VERSION) computes predecessor + 1', () => { + const repo = fixture({ policy: POLICY }); + fs.writeFileSync(path.join(repo.work, 'restamp.js'), RESTAMP_JS); + fs.writeFileSync(path.join(repo.work, 'generated', 'save.txt'), '41\n'); + repo.git('add', '-A'); repo.git('commit', '-q', '-m', 'repo restamp tool and counter'); repo.git('push', '-q', 'origin', 'main'); + makePr(repo, 'feat-save'); + fs.writeFileSync(path.join(repo.work, 'generated', 'save.txt'), '99\n'); + repo.git('commit', '-q', '-am', 'a branch-side counter edit the hook must not read'); + const r = runBin(repo, 'gstack-restamp', ['--next', '--base', 'main', '--allow-repo-commands'], { FIXTURE_SAVE: '1' }); + expect(r.status).toBe(EXIT.ok); + expect(fs.readFileSync(path.join(repo.work, 'generated', 'save.txt'), 'utf8')).toBe('42\n'); + }); + test('without --allow-repo-commands it is listed and refused like release_tool', () => { const repo = delegated(); const r = runBin(repo, 'gstack-restamp', ['--next', '--base', 'main']); diff --git a/test/ship-queue-land.test.ts b/test/ship-queue-land.test.ts index b4d8a7c12..5a0cb8c07 100644 --- a/test/ship-queue-land.test.ts +++ b/test/ship-queue-land.test.ts @@ -45,6 +45,7 @@ function setup(policy: Record = POLICY): Fx { " if(head!==want){console.error('Head branch was modified');process.exit(1)}", " if(d.driftOnMerge){const m=g('rev-parse','refs/heads/main');const t=g('rev-parse',m+'^{tree}');g('update-ref','refs/heads/main',g('commit-tree',t,'-p',m,'-m','outside merge'));d.driftOnMerge=false}", " const main=g('rev-parse','refs/heads/main');const sq=g('commit-tree',g('rev-parse',want+'^{tree}'),'-p',main,'-m','squash #'+a[2]);g('update-ref','refs/heads/main',sq);p.state='MERGED';save();process.exit(0)}", + "if(a[0]==='pr'&&a[1]==='create'){const n=String(Math.max(0,...Object.keys(d.prs).map(Number))+1);d.prs[n]={state:'OPEN',headRefName:a[a.indexOf('--head')+1],isCrossRepository:false,title:a[a.indexOf('--title')+1],body:''};save();console.log('https://github.com/o/r/pull/'+n);process.exit(0)}", "console.error('gh shim: unsupported '+a.join(' '));process.exit(1);", '', ].join('\n'), { mode: 0o755 }); @@ -208,7 +209,22 @@ describe('land: lock, races and refusals', () => { expect(r.err).toContain('(QUEUE_LAND_DRIFT)'); openPr(fx, '10'); expect(land(fx, ['--pr', '10']).err).toMatch(/#9 holds the land lock .*phase drift.*\(QUEUE_LAND_LOCKED\)/); - }, 90_000); + const rep = land(fx, ['--pr', '9', '--repair']); + expect(rep.status).toBe(EXIT.ok); + expect(rep.out).toContain('entry: changelog.d/repair-9.md names 1 outside commit(s)'); + const n = /REPAIR_PR: #(\d+) gstack-land-repair\/9 ([0-9a-f]{40})/.exec(rep.out)!; + expect(show(fx, n[2] + ':changelog.d/repair-9.md')).toBe('- Changes merged outside land beside #9: outside merge'); + expect(land(fx, ['--pr', n[1]!]).err).toContain('(QUEUE_RECEIPT_MISSING)'); + fx.write(d => { d.prs[n[1]!].body = shippedBody(n[1]!, n[2]!); }); + const pushed = land(fx, ['--pr', n[1]!]); + expect(pushed.out).toContain("LOCK: taking over #9's drift lock for its repair"); + const merged = land(fx, ['--pr', n[1]!]); + expect(merged.status).toBe(EXIT.ok); + expect(merged.out).toContain('LOCK: released'); + expect(fx.repo.git('ls-tree', '-r', '--name-only', 'origin/main', '--', 'changelog.d')).not.toContain('repair-9'); + expect(show(fx, 'origin/main:CHANGELOG.md')).toContain('- Changes merged outside land beside #9: outside merge'); + expect(land(fx, ['--pr', '10']).status).toBe(EXIT.ok); + }, 120_000); test('census lists what land would do, writing nothing', () => { const fx = setup(); From db8f5bdb827d2a515788c5e2c824dc7b2086769a Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 04:50:20 +0000 Subject: [PATCH 04/31] test: timeout on the land gh shim's git spawn Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- test/ship-queue-land.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/ship-queue-land.test.ts b/test/ship-queue-land.test.ts index 5a0cb8c07..2ff4225bf 100644 --- a/test/ship-queue-land.test.ts +++ b/test/ship-queue-land.test.ts @@ -32,7 +32,7 @@ function setup(policy: Record = POLICY): Fx { "const fs=require('fs');const {execFileSync}=require('child_process');", 'const D=process.env.LAND_SHIM_DATA, O=process.env.LAND_SHIM_ORIGIN;', "const d=JSON.parse(fs.readFileSync(D,'utf8'));const a=process.argv.slice(2);", - "const g=(...x)=>execFileSync('git',['--git-dir',O,...x],{encoding:'utf8'}).trim();", + "const g=(...x)=>execFileSync('git',['--git-dir',O,...x],{encoding:'utf8',timeout:30000}).trim();", "const save=()=>fs.writeFileSync(D,JSON.stringify(d));", "if(a[0]==='pr'&&a[1]==='view'){const p=d.prs[a[2]];if(!p){console.error('no pr');process.exit(1)}", " let head='';try{head=g('rev-parse','refs/heads/'+p.headRefName)}catch{};", From d053d1d54d87271d88b935b314badff00613b3ef Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 05:02:13 +0000 Subject: [PATCH 05/31] feat(ship): /ship writes a CHANGELOG fragment under changelog_fragments; land checks the depth-checked receipt (#3567) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 3 ++- bin/gstack-doctor-components.sh | 5 +++++ docs/ship-policy.md | 5 +++++ lib/ship-policy.ts | 2 ++ lib/ship-queue-land.ts | 7 ++++--- ship/SKILL.md | 2 +- ship/SKILL.md.tmpl | 2 +- test/fixtures/golden/agy-ship-SKILL.md | 2 +- test/fixtures/golden/claude-ship-SKILL.md | 2 +- test/fixtures/golden/codex-ship-SKILL.md | 2 +- test/fixtures/golden/factory-ship-SKILL.md | 2 +- test/gstack-ship-policy.test.ts | 3 +++ test/ship-queue-land.test.ts | 10 ++++++++++ test/size-ledger/ship/gsta154-changelog-fragments.json | 4 ++++ 14 files changed, 41 insertions(+), 10 deletions(-) create mode 100644 test/size-ledger/ship/gsta154-changelog-fragments.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 62b9f8da8..048a4deff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - **`changelog_fragments: { dir }`** (`lib/ship-policy.ts`, `lib/changelog-fragments.ts`): a PR adds `/.md` with its entry text and optional `bump: minor|major` front matter. Fragments are read as Git blobs only; a symlink, gitlink, nested path, empty or oversized fragment, one with a release heading, or a reused name is `CHANGELOG_FRAGMENT_INVALID` before any version is allocated. - **`gstack-restamp` folds fragments**: their bodies go above any legacy `[Unreleased]` section under one new heading, the files are deleted, and the highest declared bump wins over the policy's. The tree receipt's `changelog-fragments` rule accepts exactly that fold and nothing else. - **`gstack-ship-queue land --pr `** (`lib/ship-queue-land.ts`): the per-PR merge step. It takes the land lock (`refs/notes/gstack-land`), checks the /ship receipt and checks at the pin, merges the base resolving release fields only (a clean dependency edit survives; a product conflict refuses), stamps, pushes a commit with parents [pin, base] under an exact lease, waits for checks, restamps if the base moved (at most twice), squashes with `--match-head-commit` and confirms the base tree. Every step is journaled and resumable. `--repair` opens the follow-up PR after `QUEUE_LAND_DRIFT`; `--census` prints what land would do with every open PR, writing nothing. +- **/ship writes the fragment** (`lib/ship-policy.ts`, `ship/SKILL.md.tmpl`): under the key, `show-effective` prints a `ship: changelog_fragments` line, so Step 12 writes no version and Step 13 writes `/.md` (linted with `gstack-prose-lint --gate`) instead of an `[Unreleased]` section; the skill's policy paragraph now says its `ship:` lines bind Steps 12–16. - **`restamp_command` gets `GSTACK_RESTAMP_BASE_SHA`**, the release predecessor, so a repo counter such as a SAVE_VERSION computes predecessor + 1 from the base and never from the branch. - New result codes with troubleshooting anchors: `CHANGELOG_FRAGMENT_INVALID`, `QUEUE_LAND_LOCKED`, `QUEUE_LAND_CONFLICT`, `QUEUE_LAND_DRIFT`, `QUEUE_LAND_BASE_BUSY`. @@ -16,7 +17,7 @@ ### Tests - `test/changelog-fragments.test.ts`: blob-only reads and refusals, bump precedence, fold order with a legacy section, the restamp fold and its tree receipt, an edited fold refused. -- `test/ship-queue-land.test.ts` (bare origin and a gh shim): happy path; two PRs from one base without a collision; a legacy stamp plus a dependency edit; a title-derived entry; a product conflict; the lock and `--break-lock`; the base moving during CI; a foreign push never overwritten; a missing receipt; drift, `--repair` and the lock release; the census. +- `test/ship-queue-land.test.ts` (bare origin and a gh shim): happy path; two PRs from one base without a collision; a legacy stamp plus a dependency edit; a title-derived entry; a product conflict; the lock and `--break-lock`; the base moving during CI; a foreign push never overwritten; a missing receipt; drift, `--repair` and the lock release; the census; a receipt that fails #3567's depth check refuses. - `test/version-gate-fragments.test.ts`: fragment heads, premature version changes, a bot author, an advanced base, an invalid fragment, the stamp commit's effective bump, and the workflow's missing path filter. - `test/restamp-command-boundary.test.ts`: the hook reads its counter from `GSTACK_RESTAMP_BASE_SHA`, not the branch. diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh index dda793216..d02a688e8 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -315,6 +315,11 @@ gstack_result_anchor() { RECEIPT_PROSE_MISMATCH) printf '%s' 'receipt-prose-mismatch' ;; RECEIPT_CARRY_PREGATE_STALE) printf '%s' 'receipt-carry-pregate-stale' ;; RECEIPT_GIT_TOO_OLD) printf '%s' 'receipt-git-too-old' ;; + CHANGELOG_FRAGMENT_INVALID) printf '%s' 'changelog-fragment-invalid' ;; + QUEUE_LAND_LOCKED) printf '%s' 'queue-land-locked' ;; + QUEUE_LAND_CONFLICT) printf '%s' 'queue-land-conflict' ;; + QUEUE_LAND_DRIFT) printf '%s' 'queue-land-drift' ;; + QUEUE_LAND_BASE_BUSY) printf '%s' 'queue-land-base-busy' ;; PR_WAIT_BLOCKED) printf '%s' 'pr-wait-blocked' ;; PR_WAIT_REFUSED) printf '%s' 'pr-wait-refused' ;; APPROVAL_RECORD_INVALID) printf '%s' 'approval-record-invalid' ;; diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 6fe54954d..8f255beb0 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -1184,6 +1184,11 @@ release heading, or one whose name already landed on the base with other text is `CHANGELOG_FRAGMENT_INVALID`, and nothing is allocated. Only fragments the branch adds against the base count. +**/ship.** `show-effective` prints a `ship: changelog_fragments` line, so /ship +writes no version in Step 12 and writes Step 13's entry as +`/.md`, linted with `gstack-prose-lint --gate`, never an +`[Unreleased]` section. + **The fold.** `gstack-restamp` (and so `land`) concatenates the fragment bodies in path order above any legacy `[Unreleased]` section, re-heads both as one `## [] - ` section at the top, and deletes the fragment diff --git a/lib/ship-policy.ts b/lib/ship-policy.ts index 5159d153d..62ece4e94 100644 --- a/lib/ship-policy.ts +++ b/lib/ship-policy.ts @@ -571,6 +571,8 @@ export function shipConsequences(policy: ShipPolicy, base: string): string[] { out.push(`shared merge queue: when a coordinator runs the queue, \`gstack-ship-queue ready --pr \`, \`gate-ahead --after --pr \` and \`stamp --pr --version --expect-gated --expect-base \` replace the calls above; stamp only when your slot is called, send the READY block and the QUEUE_STAMP: line; the coordinator runs \`gstack-ship-queue verify\` (docs/ship-policy.md#run-a-shared-merge-queue)${policy.gate_command ? `; gate command: \`${policy.gate_command}\`` : ''}`); out.push('gate-ahead: with .gstack/tmp/gate-ahead.json present, Step 16 reuses only the lanes `gstack-tree-receipt --from .gstack/tmp/gate-ahead.json --predecessor-pr --bundle --json > .gstack/tmp/tree-receipt.json` marks reusable; stamp-sensitive and not-eligible lanes run live; `--force-gate ` is the coordinator\'s call'); } else out.push('queue_mode claim-at-open: Step 12 runs as written (claims through gstack-next-version)'); + const frag = policy.changelog_fragments?.dir.replace(/\/+$/, ''); + if (frag) out.push(`changelog_fragments ${frag}: Step 12 writes no version and Step 13 writes the entry as ${frag}/.md (the entry text only, no heading; \`bump: minor\` or \`bump: major\` front matter only when the change warrants more than the policy bump), never an [Unreleased] section; lint it with \`gstack-prose-lint ${frag}/.md --gate\`; \`gstack-ship-queue land\` assigns the version at merge (docs/ship-policy.md#changelog-fragments)`); out.push(policy.bump === 'patch' ? 'bump patch: Step 12 never asks for MINOR or MAJOR; PATCH or MICRO by size' : 'bump ask: Step 12 asks for MINOR and MAJOR as written'); out.push(policy.review_scope === 'all' ? 'review_scope all: Step 9.4 fixes every finding on every pass (gstack-review-scope classify --policy all)' diff --git a/lib/ship-queue-land.ts b/lib/ship-queue-land.ts index c245f19e7..2e1bc3061 100644 --- a/lib/ship-queue-land.ts +++ b/lib/ship-queue-land.ts @@ -20,7 +20,7 @@ import { restamp, versionAtRef } from './restamp'; import { matchesAny, type ShipPolicy } from './ship-policy'; import { ciAtHead, loadBasePolicy, type QueueContext, type StepOutput } from './ship-queue'; import { QUEUE_DIR_REL, git, writeJsonAtomic, type QueueFailure } from './ship-queue-gate'; -import { bindReceipt } from './ship-receipt'; +import { bindShippedReceipt } from './ship-receipt-depth'; export const LAND_LOCK_REF = 'refs/notes/gstack-land'; export const MAX_RESTAMPS = 2; @@ -297,8 +297,9 @@ export function land(ctx: QueueContext, o: LandOptions): StepOutput { if (j && (j.phase === 'landed' || j.reviewed_pin !== info.headRefOid && j.last_pushed_head !== info.headRefOid)) j = null; const pin = j?.reviewed_pin ?? info.headRefOid; const lines = [`LAND: #${o.pr} branch=${info.headRefName} pin=${pin}${j ? ` (resuming at ${j.phase})` : ''}`]; - const receipt = bindReceipt(info.body ?? '', o.pr, [pin]); - lines.push(receipt.line); + git(root, ['fetch', '-q', 'origin', ctx.base]); + const receipt = bindShippedReceipt(root, `origin/${ctx.base}`, info.body ?? '', o.pr, [pin]); + lines.push(receipt.line, ...(receipt.depthLine ? [receipt.depthLine] : [])); if (!receipt.ok) return fail(lines, { code: receipt.code, message: receipt.message, fix: 'run /ship at the pinned head first' }, run); if (!j) { const ci = ciAtHead(ctx, pin); diff --git a/ship/SKILL.md b/ship/SKILL.md index 5846b96d3..af3321709 100644 --- a/ship/SKILL.md +++ b/ship/SKILL.md @@ -473,7 +473,7 @@ git merge origin/ --no-edit **If already up to date:** Continue silently. **Ship policy:** `~/.claude/skills/gstack/bin/gstack-ship-policy show-effective --base ` -(docs/ship-policy.md); its `ship:` lines bind Steps 12, 16 and history. +(docs/ship-policy.md); its `ship:` lines bind Steps 12–16 and history. If integration changes the artifact or distribution configuration inspected in Step 2, repeat Step 2 (decisions included) on the merged content, then Step 4. diff --git a/ship/SKILL.md.tmpl b/ship/SKILL.md.tmpl index 810bbd990..60b353f16 100644 --- a/ship/SKILL.md.tmpl +++ b/ship/SKILL.md.tmpl @@ -204,7 +204,7 @@ git merge origin/ --no-edit **If already up to date:** Continue silently. **Ship policy:** `~/.claude/skills/gstack/bin/gstack-ship-policy show-effective --base ` -(docs/ship-policy.md); its `ship:` lines bind Steps 12, 16 and history. +(docs/ship-policy.md); its `ship:` lines bind Steps 12–16 and history. If integration changes the artifact or distribution configuration inspected in Step 2, repeat Step 2 (decisions included) on the merged content, then Step 4. diff --git a/test/fixtures/golden/agy-ship-SKILL.md b/test/fixtures/golden/agy-ship-SKILL.md index 59c5baac2..89f7b3e99 100644 --- a/test/fixtures/golden/agy-ship-SKILL.md +++ b/test/fixtures/golden/agy-ship-SKILL.md @@ -473,7 +473,7 @@ git merge origin/ --no-edit **If already up to date:** Continue silently. **Ship policy:** `~/.gemini/antigravity-cli/skills/gstack/bin/gstack-ship-policy show-effective --base ` -(docs/ship-policy.md); its `ship:` lines bind Steps 12, 16 and history. +(docs/ship-policy.md); its `ship:` lines bind Steps 12–16 and history. If integration changes the artifact or distribution configuration inspected in Step 2, repeat Step 2 (decisions included) on the merged content, then Step 4. diff --git a/test/fixtures/golden/claude-ship-SKILL.md b/test/fixtures/golden/claude-ship-SKILL.md index 5846b96d3..af3321709 100644 --- a/test/fixtures/golden/claude-ship-SKILL.md +++ b/test/fixtures/golden/claude-ship-SKILL.md @@ -473,7 +473,7 @@ git merge origin/ --no-edit **If already up to date:** Continue silently. **Ship policy:** `~/.claude/skills/gstack/bin/gstack-ship-policy show-effective --base ` -(docs/ship-policy.md); its `ship:` lines bind Steps 12, 16 and history. +(docs/ship-policy.md); its `ship:` lines bind Steps 12–16 and history. If integration changes the artifact or distribution configuration inspected in Step 2, repeat Step 2 (decisions included) on the merged content, then Step 4. diff --git a/test/fixtures/golden/codex-ship-SKILL.md b/test/fixtures/golden/codex-ship-SKILL.md index afd639848..8e3efeb73 100644 --- a/test/fixtures/golden/codex-ship-SKILL.md +++ b/test/fixtures/golden/codex-ship-SKILL.md @@ -472,7 +472,7 @@ git merge origin/ --no-edit **If already up to date:** Continue silently. **Ship policy:** `$GSTACK_ROOT/bin/gstack-ship-policy show-effective --base ` -(docs/ship-policy.md); its `ship:` lines bind Steps 12, 16 and history. +(docs/ship-policy.md); its `ship:` lines bind Steps 12–16 and history. If integration changes the artifact or distribution configuration inspected in Step 2, repeat Step 2 (decisions included) on the merged content, then Step 4. diff --git a/test/fixtures/golden/factory-ship-SKILL.md b/test/fixtures/golden/factory-ship-SKILL.md index e2c3e30bc..c8172cdcd 100644 --- a/test/fixtures/golden/factory-ship-SKILL.md +++ b/test/fixtures/golden/factory-ship-SKILL.md @@ -474,7 +474,7 @@ git merge origin/ --no-edit **If already up to date:** Continue silently. **Ship policy:** `$GSTACK_ROOT/bin/gstack-ship-policy show-effective --base ` -(docs/ship-policy.md); its `ship:` lines bind Steps 12, 16 and history. +(docs/ship-policy.md); its `ship:` lines bind Steps 12–16 and history. If integration changes the artifact or distribution configuration inspected in Step 2, repeat Step 2 (decisions included) on the merged content, then Step 4. diff --git a/test/gstack-ship-policy.test.ts b/test/gstack-ship-policy.test.ts index 983491aa5..20c0e3343 100644 --- a/test/gstack-ship-policy.test.ts +++ b/test/gstack-ship-policy.test.ts @@ -160,6 +160,9 @@ describe('gstack-ship-policy (bin)', () => { expect(r.out).toMatch(/^ship: bump patch: Step 12 never asks/m); expect(r.out).toMatch(/^ship: release_tool: `node gen\.js` runs only inside gstack-restamp/m); expect(r.out).not.toMatch(/^ship: history/m); + expect(r.out).not.toMatch(/^ship: changelog_fragments/m); + const frag = fixture({ policy: { ...BASE_POLICY, changelog_fragments: { dir: 'changelog.d/' }, min_gstack_version: '1.92.0.0' } }); + expect(runBin(frag, 'gstack-ship-policy', ['show-effective', '--base', 'main']).out).toMatch(/^ship: changelog_fragments changelog\.d: Step 12 writes no version and Step 13 writes the entry as changelog\.d\/\.md .*never an \[Unreleased\] section; lint it with `gstack-prose-lint changelog\.d\/\.md --gate`/m); const strict = fixture({ policy: { ...BASE_POLICY, history: 'merge-only', queue_mode: 'claim-at-open', bump: 'ask', release_tool: null, preregistration_shas: ['0123456789abcdef0123456789abcdef01234567'] } }); const lines = runBin(strict, 'gstack-ship-policy', ['show-effective', '--base', 'main']).out.split('\n').filter(l => l.startsWith('ship: ')); expect(lines.map(l => l.split(':')[1]!.trim())).toEqual(['history merge-only', 'queue_mode claim-at-open', 'bump ask', 'review_scope frozen', 'preregistration']); diff --git a/test/ship-queue-land.test.ts b/test/ship-queue-land.test.ts index 2ff4225bf..335a19f58 100644 --- a/test/ship-queue-land.test.ts +++ b/test/ship-queue-land.test.ts @@ -200,6 +200,16 @@ describe('land: lock, races and refusals', () => { expect(remote(fx, 'pr-8')).toBe(late); }, 90_000); + test('the receipt check is the depth-checked one READY uses: a receipt whose writer records depth but which records none refuses', () => { + const fx = setup(); + const head = openPr(fx, '31'); + fx.write(d => { d.prs['31'].body = shippedBody('31', head, { gstack_version: '1.91.307.0' }); }); + const r = land(fx, ['--pr', '31']); + expect(r.err).toContain('(QUEUE_RECEIPT_DEPTH_RAISED)'); + expect(r.out).toMatch(/^REVIEW_DEPTH: receipt=none required=/m); + expect(remote(fx, 'pr-31')).toBe(head); + }, 60_000); + test('drift: a merge outside land between the check and the squash fails loudly and keeps the lock', () => { const fx = setup(); openPr(fx, '9'); diff --git a/test/size-ledger/ship/gsta154-changelog-fragments.json b/test/size-ledger/ship/gsta154-changelog-fragments.json new file mode 100644 index 000000000..69cbc7434 --- /dev/null +++ b/test/size-ledger/ship/gsta154-changelog-fragments.json @@ -0,0 +1,4 @@ +{ + "bytes": 1, + "why": "GSTA-154: the ship-policy paragraph says its ship: lines bind Steps 12–16, so the changelog_fragments line reaches Step 13's entry" +} From 0ebf4bd849840a7c20bbd2c1063e65ee53a0c7be Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 05:38:39 +0000 Subject: [PATCH 06/31] test(land): keeps the base's four-part package.json (#3557) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 2 +- test/ship-queue-land.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 048a4deff..370cb89b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ ### Tests - `test/changelog-fragments.test.ts`: blob-only reads and refusals, bump precedence, fold order with a legacy section, the restamp fold and its tree receipt, an edited fold refused. -- `test/ship-queue-land.test.ts` (bare origin and a gh shim): happy path; two PRs from one base without a collision; a legacy stamp plus a dependency edit; a title-derived entry; a product conflict; the lock and `--break-lock`; the base moving during CI; a foreign push never overwritten; a missing receipt; drift, `--repair` and the lock release; the census; a receipt that fails #3567's depth check refuses. +- `test/ship-queue-land.test.ts` (bare origin and a gh shim): happy path; two PRs from one base without a collision; a legacy stamp plus a dependency edit; a title-derived entry; a product conflict; the lock and `--break-lock`; the base moving during CI; a foreign push never overwritten; a missing receipt; drift, `--repair` and the lock release; the census; a receipt that fails #3567's depth check refuses; a base with a four-part package.json in `stamp_files` lands four parts (#3557), gstack's own shape stays three. - `test/version-gate-fragments.test.ts`: fragment heads, premature version changes, a bot author, an advanced base, an invalid fragment, the stamp commit's effective bump, and the workflow's missing path filter. - `test/restamp-command-boundary.test.ts`: the hook reads its counter from `GSTACK_RESTAMP_BASE_SHA`, not the branch. diff --git a/test/ship-queue-land.test.ts b/test/ship-queue-land.test.ts index 335a19f58..3773239ec 100644 --- a/test/ship-queue-land.test.ts +++ b/test/ship-queue-land.test.ts @@ -246,6 +246,32 @@ describe('land: lock, races and refusals', () => { }, 90_000); }); +describe('land: the base\'s manifest part count', () => { + test('a repo whose base holds a four-part package.json listed in stamp_files lands four parts (gbrain#7878 wrote three)', () => { + const fx = setup({ ...POLICY, stamp_files: ['VERSION', 'package.json', 'CHANGELOG.md', 'changelog.d/*.md'], stamp_patterns: ['"version": "{version}"'] }); + const { repo } = fx; + repo.git('checkout', '-q', 'main'); + const pkg = JSON.parse(readFile(repo, 'package.json')); + fs.writeFileSync(path.join(repo.work, 'package.json'), JSON.stringify({ ...pkg, version: '1.2.3.0' }, null, 2) + '\n'); + fs.rmSync(path.join(repo.work, 'package-lock.json')); + repo.git('add', '-A'); repo.git('commit', '-q', '-m', 'four-part manifest'); repo.git('push', '-q', 'origin', 'main'); + openPr(fx, '21'); + expect(land(fx, ['--pr', '21']).status).toBe(EXIT.ok); + const merged = land(fx, ['--pr', '21']); + expect(merged.status, merged.err).toBe(EXIT.ok); + expect(JSON.parse(show(fx, 'origin/main:package.json')).version).toBe('1.2.4.0'); + expect(show(fx, 'origin/main:VERSION')).toBe('1.2.4.0'); + }, 90_000); + + test('control: gstack\'s shape (a three-part manifest outside stamp_files) stays three parts', () => { + const fx = setup(); + openPr(fx, '22'); + land(fx, ['--pr', '22']); + expect(land(fx, ['--pr', '22']).status).toBe(EXIT.ok); + expect(JSON.parse(show(fx, 'origin/main:package.json')).version).toBe('1.2.4'); + }, 90_000); +}); + describe('resolveReleaseConflicts', () => { test('a manifest whose version conflicts keeps the clean dependency edit and takes the base version; a real dependency conflict stays a conflict', () => { const fx = setup(); From 1e02f6e69924c77e14cefd930510898d9d1847b1 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 06:02:05 +0000 Subject: [PATCH 07/31] fix(ship-queue): land waits for CI through runScriptSync (script-invocation tripwire) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- lib/ship-queue-land.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/ship-queue-land.ts b/lib/ship-queue-land.ts index 2e1bc3061..14e96a9aa 100644 --- a/lib/ship-queue-land.ts +++ b/lib/ship-queue-land.ts @@ -17,6 +17,7 @@ import * as path from 'node:path'; import { mergeChangelogs, parseChangelog } from './changelog-check'; import { fragmentSlug, readFragments } from './changelog-fragments'; import { restamp, versionAtRef } from './restamp'; +import { BIN_DIR, runScriptSync } from './script-invocation'; import { matchesAny, type ShipPolicy } from './ship-policy'; import { ciAtHead, loadBasePolicy, type QueueContext, type StepOutput } from './ship-queue'; import { QUEUE_DIR_REL, git, writeJsonAtomic, type QueueFailure } from './ship-queue-gate'; @@ -193,8 +194,7 @@ function prInfo(ctx: QueueContext, pr: string): PrInfo | QueueFailure { } function waitGreen(ctx: QueueContext, pr: string, minutes: number): boolean { - const bin = path.join(path.dirname(new URL(import.meta.url).pathname), '..', 'bin', 'gstack-pr-wait'); - const r = spawnSync(bin, [pr, '--until', 'green', '--timeout', String(minutes)], { cwd: ctx.repoRoot, stdio: ['ignore', 'inherit', 'inherit'], timeout: (minutes + 5) * 60_000 }); + const r = runScriptSync(path.join(BIN_DIR, 'gstack-pr-wait'), [pr, '--until', 'green', '--timeout', String(minutes)], { cwd: ctx.repoRoot, stdio: ['ignore', 'inherit', 'inherit'], timeout: (minutes + 5) * 60_000 }); return r.status === 0; } From b6e9790294c4a31f7169fd32c396eb13b5591205 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 06:02:05 +0000 Subject: [PATCH 08/31] test: land, fragment and Version Gate cases from /ship's coverage audit Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- test/changelog-fragments.test.ts | 41 +++++++ test/ship-queue-land.test.ts | 166 +++++++++++++++++++++++++++- test/version-gate-fragments.test.ts | 16 +++ 3 files changed, 222 insertions(+), 1 deletion(-) diff --git a/test/changelog-fragments.test.ts b/test/changelog-fragments.test.ts index 37aa89e4f..dab553f81 100644 --- a/test/changelog-fragments.test.ts +++ b/test/changelog-fragments.test.ts @@ -136,3 +136,44 @@ describe('restamp folds fragments', () => { expect(readFile(repo, 'VERSION')).toBe('1.2.3.0\n'); }); }); + +// Generated by /ship Step 7 coverage audit (GSTA-154 PR A, pass 1). +describe('readFragments against landed base fragments', () => { + // Value: protects=a landed fragment is skipped when unchanged and refused when the branch edits it, and an oversized or + // gitlink fragment refuses; fails_when=the same-name/other-bytes check, the size cap or the mode check is dropped; + // why_new=the existing reader test has no fragment on the base, no oversize and no gitlink; seam=none; no_claim=none beyond protects + test('an unchanged base fragment is skipped; an edited base fragment, an oversized one and a gitlink refuse', () => { + const repo = fixture(); + repo.git('checkout', '-q', '--detach', 'origin/main'); + fs.mkdirSync(path.join(repo.work, 'changelog.d'), { recursive: true }); + fs.writeFileSync(path.join(repo.work, 'changelog.d', 'keep.md'), '- landed keep\n'); + fs.writeFileSync(path.join(repo.work, 'changelog.d', 'old.md'), '- landed old\n'); + repo.git('add', '-A'); repo.git('commit', '-q', '-m', 'landed fragments'); repo.git('push', '-q', 'origin', 'HEAD:main'); + fragmentPr(repo, 'feat-e', { 'old.md': '- rewritten old\n', 'big.md': '- ' + 'x'.repeat(70 * 1024) + '\n', 'new.md': '- new\n' }); + repo.git('update-index', '--add', '--cacheinfo', '160000,' + repo.git('rev-parse', 'HEAD') + ',changelog.d/mod.md'); + repo.git('commit', '-q', '-m', 'gitlink fragment'); + const set = readFragments(repo.work, 'HEAD', 'changelog.d', 'origin/main'); + expect(set.fragments.map(f => f.path)).toEqual(['changelog.d/new.md']); + expect(set.errors.map(e => e.path)).toEqual(['changelog.d/big.md', 'changelog.d/mod.md', 'changelog.d/old.md']); + expect(set.errors.find(e => e.path === 'changelog.d/old.md')!.message).toContain('already exists on the base with other text'); + expect(set.errors.find(e => e.path === 'changelog.d/big.md')!.message).toContain('larger than 65536 bytes'); + expect(set.errors.find(e => e.path === 'changelog.d/mod.md')!.message).toContain('(gitlink)'); + }); +}); + +// Generated by /ship Step 7 coverage audit (GSTA-154 PR A, pass 2). +describe('restamp: an explicit --bump', () => { + // Value: protects=an explicit --bump is the caller's decision and wins over fragment front matter, while the fold still happens; + // fails_when=restamp applies effectiveBump even when --bump is passed; why_new=every fragment restamp test omits --bump; + // seam=none; no_claim=none beyond protects + test('--bump patch with a "bump: major" fragment stamps the next patch and still folds the fragment', () => { + const repo = fixture(); + fragmentPr(repo, 'feat-d', { 'feat-d.md': '---\nbump: major\n---\n- feat-d\n' }); + const r = restamp(repo, ['--next', '--bump', 'patch']); + expect(r.err).toBe(''); + expect(r.out).toContain('STAMP_VERSION: 1.2.4.0'); + expect(r.out).toContain('folded changelog.d/feat-d.md'); + expect(readFile(repo, 'VERSION')).toBe('1.2.4.0\n'); + expect(fs.existsSync(path.join(repo.work, 'changelog.d', 'feat-d.md'))).toBe(false); + }); +}); diff --git a/test/ship-queue-land.test.ts b/test/ship-queue-land.test.ts index 3773239ec..68d8b70bd 100644 --- a/test/ship-queue-land.test.ts +++ b/test/ship-queue-land.test.ts @@ -7,6 +7,7 @@ * receipt and drift after the merge. */ import { afterEach, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { EXIT } from '../lib/headless-artifacts'; @@ -37,12 +38,13 @@ function setup(policy: Record = POLICY): Fx { "if(a[0]==='pr'&&a[1]==='view'){const p=d.prs[a[2]];if(!p){console.error('no pr');process.exit(1)}", " let head='';try{head=g('rev-parse','refs/heads/'+p.headRefName)}catch{};", " console.log(JSON.stringify({...p,headRefOid:head}));process.exit(0)}", - "if(a[0]==='pr'&&a[1]==='list'){console.log(JSON.stringify(Object.entries(d.prs).filter(([,p])=>p.state==='OPEN').map(([n,p])=>({number:Number(n),headRefOid:g('rev-parse','refs/heads/'+p.headRefName),isCrossRepository:false}))));process.exit(0)}", + "if(a[0]==='pr'&&a[1]==='list'){console.log(JSON.stringify(Object.entries(d.prs).filter(([,p])=>p.state==='OPEN').map(([n,p])=>({number:Number(n),headRefOid:g('rev-parse','refs/heads/'+p.headRefName),isCrossRepository:!!p.isCrossRepository}))));process.exit(0)}", "if(a[0]==='api'){const m=/commits\\/([0-9a-f]+)\\/(check-runs|status)/.exec(a[1]);if(!m)process.exit(1);", " if(m[2]==='status'){console.log(JSON.stringify({total_count:0,statuses:[]}));process.exit(0)}", " const red=d.red.includes(m[1]);console.log(JSON.stringify({total_count:1,check_runs:[{id:1,name:'free-tests',status:'completed',conclusion:red?'failure':'success',completed_at:'2026-10-29T00:00:00Z',app:{id:1}}]}));process.exit(0)}", "if(a[0]==='pr'&&a[1]==='merge'){const p=d.prs[a[2]];const want=a[a.indexOf('--match-head-commit')+1];const head=g('rev-parse','refs/heads/'+p.headRefName);", " if(head!==want){console.error('Head branch was modified');process.exit(1)}", + " if(d.mergeRefuse){console.error('Pull request is not mergeable: the base branch policy prohibits the merge');process.exit(1)}", " if(d.driftOnMerge){const m=g('rev-parse','refs/heads/main');const t=g('rev-parse',m+'^{tree}');g('update-ref','refs/heads/main',g('commit-tree',t,'-p',m,'-m','outside merge'));d.driftOnMerge=false}", " const main=g('rev-parse','refs/heads/main');const sq=g('commit-tree',g('rev-parse',want+'^{tree}'),'-p',main,'-m','squash #'+a[2]);g('update-ref','refs/heads/main',sq);p.state='MERGED';save();process.exit(0)}", "if(a[0]==='pr'&&a[1]==='create'){const n=String(Math.max(0,...Object.keys(d.prs).map(Number))+1);d.prs[n]={state:'OPEN',headRefName:a[a.indexOf('--head')+1],isCrossRepository:false,title:a[a.indexOf('--title')+1],body:''};save();console.log('https://github.com/o/r/pull/'+n);process.exit(0)}", @@ -293,3 +295,165 @@ describe('resolveReleaseConflicts', () => { expect(resolveReleaseConflicts(repo.work, ['package.json'], policy, 'VERSION')).toEqual(['package.json']); }, 90_000); }); + +// Generated by /ship Step 7 coverage audit (GSTA-154 PR A, pass 1). +describe('land: dry run and refusals before the lock', () => { + const lockRef = (fx: Fx) => fx.repo.git('ls-remote', 'origin', 'refs/notes/gstack-land'); + const journal = (fx: Fx, pr: string) => path.join(fx.repo.work, '.gstack', 'tmp', 'ship-queue', 'land', pr + '.json'); + + // Value: protects=land --dry-run prints the stamp it would make and writes nothing: no lock ref, no push, no journal; + // fails_when=dry-run falls through to the lock/push path or saves a journal; why_new=no land test passes --dry-run; + // seam=none; no_claim=gh shim stands in for GitHub (fixture repos only) + test('--dry-run prints the stamp version and tree but takes no lock, pushes nothing and saves no journal', () => { + const fx = setup(); + const pin = openPr(fx, '11', { fragment: '---\nbump: minor\n---\n- dry #11\n' }); + const r = land(fx, ['--pr', '11', '--dry-run']); + expect(r.err).toBe(''); + expect(r.status).toBe(EXIT.ok); + expect(r.out).toContain('STAMP_VERSION: 1.3.0.0'); + expect(r.out).toMatch(/^STAMP_TREE: [0-9a-f]{40}$/m); + expect(r.out).toContain('DRY-RUN: nothing locked, pushed or merged'); + expect(remote(fx, 'pr-11')).toBe(pin); + expect(lockRef(fx)).toBe(''); + expect(fs.existsSync(journal(fx, '11'))).toBe(false); + }, 90_000); + + // Value: protects=land refuses a red pin, a fork head and a closed PR before it locks or pushes anything; + // fails_when=the pin CI check, the isCrossRepository guard or the OPEN-state guard is dropped or moved after the lock; + // why_new=existing land tests only use green, open, same-repo PRs; seam=none; no_claim=GitHub check semantics (gh shim) + test('a red pin, a fork head and a closed PR refuse with no lock taken and the branch untouched', () => { + const fx = setup(); + const pin = openPr(fx, '12'); + fx.write(d => { d.red = [pin]; }); + const red = land(fx, ['--pr', '12']); + expect(red.err).toMatch(/required checks are not green at the pin .*\(QUEUE_NOT_READY\)/); + fx.write(d => { d.red = []; d.prs['12'].isCrossRepository = true; }); + expect(land(fx, ['--pr', '12']).err).toContain('(QUEUE_TRAIN_UNTRUSTED)'); + fx.write(d => { d.prs['12'].isCrossRepository = false; d.prs['12'].state = 'CLOSED'; }); + expect(land(fx, ['--pr', '12']).err).toMatch(/#12 is CLOSED.*\(QUEUE_NOT_READY\)/); + expect(remote(fx, 'pr-12')).toBe(pin); + expect(lockRef(fx)).toBe(''); + }, 90_000); + + // Value: protects=--break-lock is refused while another holder's lock is in phase pushing and under 10 minutes old, + // and allowed once that push lock is stale; fails_when=the PUSH_STALE_MS guard is removed or inverted; + // why_new=the existing break-lock test only breaks a waiting lock; seam=none; no_claim=a real concurrent lander (lock ref crafted) + test('--break-lock refuses a fresh mid-push lock and breaks a stale one', () => { + const fx = setup(); + const pin = openPr(fx, '13'); + const pushLock = (startedAt: string) => { + const sh = (args: string[], input?: string) => { + const r = spawnSync('git', args, { cwd: fx.repo.work, input, encoding: 'utf8', timeout: 30_000 }); + expect(r.status, r.stderr).toBe(0); + return r.stdout.trim(); + }; + const blob = sh(['hash-object', '-w', '--stdin'], JSON.stringify({ pr: '99', holder: 'other:1', started_at: startedAt, phase: 'pushing', journal: '.gstack/tmp/ship-queue/land/99.json' })); + const tree = sh(['mktree'], '100644 blob ' + blob + '\tlock.json\n'); + const commit = sh(['commit-tree', tree, '-m', 'land lock #99 pushing']); + sh(['push', '-q', '-f', 'origin', commit + ':refs/notes/gstack-land']); + }; + pushLock(new Date().toISOString()); + const fresh = land(fx, ['--pr', '13', '--break-lock']); + expect(fresh.err).toMatch(/#99's holder is mid-push .*--break-lock refused.*\(QUEUE_LAND_LOCKED\)/); + expect(remote(fx, 'pr-13')).toBe(pin); + pushLock(new Date(Date.now() - 60 * 60_000).toISOString()); + const stale = land(fx, ['--pr', '13', '--break-lock']); + expect(stale.err).toBe(''); + expect(stale.out).toContain("LOCK: broke #99's lock (other:1, phase pushing)"); + expect(remote(fx, 'pr-13')).not.toBe(pin); + }, 90_000); +}); + +// Generated by /ship Step 7 coverage audit (GSTA-154 PR A, pass 2). +describe('land: failures after the push, repair branches and census rows', () => { + const journal = (fx: Fx, pr: string) => JSON.parse(fs.readFileSync(path.join(fx.repo.work, '.gstack', 'tmp', 'ship-queue', 'land', pr + '.json'), 'utf8')); + const pushMain = (fx: Fx, msg: string, edit: (work: string) => void) => { + fx.repo.git('fetch', '-q', 'origin'); fx.repo.git('checkout', '-q', '--detach', 'origin/main'); + edit(fx.repo.work); + fx.repo.git('add', '-A'); fx.repo.git('commit', '-q', '-m', msg); fx.repo.git('push', '-q', 'origin', 'HEAD:refs/heads/main'); + }; + + // Value: protects=a red stamp commit stops land with QUEUE_NOT_READY and a refused gh pr merge stops it too, both keeping + // the journal at phase pushed so a rerun resumes and lands; fails_when=land merges on red, or a failure resets or loses the journal; + // why_new=the shim never reds a stamp commit or refuses a merge; seam=none; no_claim=GitHub check and merge semantics (gh shim) + test('a red stamp commit and a refused merge both stop with the journal at pushed, and a rerun lands; a non-numeric --pr is a usage error', () => { + const fx = setup(); + openPr(fx, '14'); + expect(land(fx, ['--pr', '14']).status).toBe(EXIT.ok); + const stamp = remote(fx, 'pr-14'); + fx.write(d => { d.red = [stamp]; }); + const red = land(fx, ['--pr', '14']); + expect(red.err).toMatch(/a check failed on the stamp commit .*\(QUEUE_NOT_READY\)/); + expect(red.out).toMatch(/^CI: red at /m); + expect(journal(fx, '14')).toMatchObject({ phase: 'pushed', stamp_commit: stamp }); + fx.write(d => { d.red = []; d.mergeRefuse = true; }); + const refused = land(fx, ['--pr', '14']); + expect(refused.err).toMatch(/gh pr merge refused: Pull request is not mergeable.*\(QUEUE_NOT_READY\)/); + expect(journal(fx, '14')).toMatchObject({ phase: 'pushed', stamp_commit: stamp }); + expect(remote(fx, 'pr-14')).toBe(stamp); + fx.write(d => { d.mergeRefuse = false; }); + const done = land(fx, ['--pr', '14']); + expect(done.err).toBe(''); + expect(done.out).toContain('MERGED: #14'); + expect(journal(fx, '14').phase).toBe('landed'); + const usage = runBin(fx.repo, 'gstack-ship-queue', ['land', '--pr', 'x14', '--base', 'main'], fx.env); + expect(usage.status).toBe(EXIT.usage); + expect(usage.err).toContain('land needs --pr (or --census)'); + }, 90_000); + + // Value: protects=--repair refuses without a drift record and with fragments off, moves fragments the outside merge left + // on the base to repair--.md, and a second --repair reports the open PR instead of opening another; fails_when=repair + // opens duplicate PRs, drops a stray fragment, or runs without a drift record; why_new=the drift test covers only the no-stray path; + // seam=none; no_claim=gh pr create (gh shim) + test('--repair: no drift record and fragments off refuse; stray base fragments move; a second run reports the open PR', () => { + const fx = setup(); + openPr(fx, '15'); + expect(land(fx, ['--pr', '15', '--repair']).err).toMatch(/has no drift record; --repair runs only after QUEUE_LAND_DRIFT.*\(QUEUE_NOT_READY\)/); + expect(land(fx, ['--pr', '15']).status).toBe(EXIT.ok); + fx.write(d => { d.driftOnMerge = true; }); + expect(land(fx, ['--pr', '15']).err).toContain('(QUEUE_LAND_DRIFT)'); + pushMain(fx, 'fragments off', w => fs.writeFileSync(path.join(w, '.gstack', 'ship-policy.json'), JSON.stringify({ ...POLICY, changelog_fragments: null }, null, 2) + '\n')); + expect(land(fx, ['--pr', '15', '--repair']).err).toMatch(/changelog_fragments is off.*\(QUEUE_NOT_READY\)/); + pushMain(fx, 'fragments on, outside fragment', w => { + fs.writeFileSync(path.join(w, '.gstack', 'ship-policy.json'), JSON.stringify(POLICY, null, 2) + '\n'); + fs.mkdirSync(path.join(w, 'changelog.d'), { recursive: true }); + fs.writeFileSync(path.join(w, 'changelog.d', 'stray.md'), '- outside stray\n'); + }); + const rep = land(fx, ['--pr', '15', '--repair']); + expect(rep.err).toBe(''); + expect(rep.out).toContain('moved: changelog.d/stray.md (left unfolded by the outside merge)'); + expect(rep.out).not.toContain('entry: changelog.d/repair-15.md'); + const m = /REPAIR_PR: #(\d+) gstack-land-repair\/15 ([0-9a-f]{40})/.exec(rep.out)!; + expect(fx.repo.git('ls-tree', '-r', '--name-only', m[2]!, '--', 'changelog.d')).toBe('changelog.d/repair-15-stray.md'); + expect(show(fx, m[2] + ':changelog.d/repair-15-stray.md')).toBe('- outside stray'); + const again = land(fx, ['--pr', '15', '--repair']); + expect(again.out).toContain('REPAIR_PR: #' + m[1] + ' (already open)'); + expect(Object.keys(JSON.parse(fs.readFileSync(fx.data, 'utf8')).prs)).toEqual(['15', m[1]]); + }, 90_000); + + // Value: protects=census reports a fork, a product conflict, an invalid fragment and a legacy [Unreleased] PR with a tentative + // version as distinct rows and writes nothing; fails_when=a row is misclassified (e.g. a product conflict shown as foldable); + // why_new=the census test covers only fold and no-entry rows; seam=none; no_claim=gh pr list (gh shim) + test('census rows: fork refused, product conflict refused, invalid fragment, legacy entry with a tentative version', () => { + const fx = setup(); + openPr(fx, '1'); + fx.write(d => { d.prs['1'].isCrossRepository = true; }); + openPr(fx, '2', { edit: w => fs.writeFileSync(path.join(w, 'shared.txt'), 'two\n') }); + openPr(fx, '3', { fragment: '## [9.9.9.9]\n- bad\n' }); + openPr(fx, '4', { fragment: null, edit: w => { + fs.writeFileSync(path.join(w, 'VERSION'), '1.2.9.0\n'); + fs.writeFileSync(path.join(w, 'CHANGELOG.md'), fs.readFileSync(path.join(w, 'CHANGELOG.md'), 'utf8').replace('# Changelog\n', '# Changelog\n\n## [Unreleased]\n\n- legacy #4\n')); + } }); + pushMain(fx, 'main edits shared', w => fs.writeFileSync(path.join(w, 'shared.txt'), 'main\n')); + const mainBefore = remote(fx, 'main'); + const c = runBin(fx.repo, 'gstack-ship-queue', ['land', '--census', '--base', 'main'], fx.env); + expect(c.status, c.err).toBe(EXIT.ok); + expect(c.out).toMatch(/^CENSUS: 4 open PR\(s\) against origin\/main@[0-9a-f]{12} \(fragments: changelog\.d\)$/m); + expect(c.out).toMatch(/^#1 fork: refused \(QUEUE_TRAIN_UNTRUSTED\)$/m); + expect(c.out).toMatch(/^#2 [0-9a-f]{12}: refuse \(product conflict: shared\.txt\)$/m); + expect(c.out).toMatch(/^#3 [0-9a-f]{12}: invalid fragment changelog\.d\/pr-3\.md$/m); + expect(c.out).toMatch(/^#4 [0-9a-f]{12}: re-head legacy \[Unreleased\]; rewrite its tentative 1\.2\.9\.0 through restamp$/m); + expect(remote(fx, 'main')).toBe(mainBefore); + expect(fx.repo.git('ls-remote', 'origin', 'refs/notes/gstack-land')).toBe(''); + }, 90_000); +}); diff --git a/test/version-gate-fragments.test.ts b/test/version-gate-fragments.test.ts index fbd42d8f0..0df546be1 100644 --- a/test/version-gate-fragments.test.ts +++ b/test/version-gate-fragments.test.ts @@ -95,3 +95,19 @@ describe('version gate: changelog fragments', () => { expect(workflow.on.pull_request.branches).toEqual(['main']); }); }); + +// Generated by /ship Step 7 coverage audit (GSTA-154 PR A, pass 1). +describe('version gate: legacy entries under changelog fragments', () => { + // Value: protects=a person's PR with a legacy [Unreleased] entry and no fragment still passes once fragments are on; + // fails_when=the [Unreleased] fallback in classifyTrainHead is dropped, so in-flight PRs fail with "add a CHANGELOG fragment"; + // why_new=existing gate tests cover fragment, bot and no-entry heads only; seam=none; no_claim=GitHub workflow wiring (script run directly) + test('an [Unreleased] entry without a fragment passes and names that entry', () => { + const r = fixture(); + r.git('checkout', '-q', '-b', 'legacy'); + write(r, 'legacy.txt', 'l\n'); + write(r, 'CHANGELOG.md', fs.readFileSync(path.join(r.work, 'CHANGELOG.md'), 'utf8').replace('# Changelog\n', '# Changelog\n\n## [Unreleased]\n\n- legacy entry\n')); + const res = run(r, commit(r, 'legacy')); + expect(res.code, res.out).toBe(0); + expect(res.out).toContain('the CHANGELOG change is confined to [Unreleased]'); + }); +}); From 57ed617200cc8c41017d2b36ecaff1c27b54c157 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 06:02:05 +0000 Subject: [PATCH 09/31] test(windows): leave test/ship-queue-land.test.ts out of the Windows-safe subset (gh shim cannot launch there) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- scripts/lib/windows-curation.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/lib/windows-curation.ts b/scripts/lib/windows-curation.ts index c884f2358..30a1a3368 100644 --- a/scripts/lib/windows-curation.ts +++ b/scripts/lib/windows-curation.ts @@ -51,6 +51,7 @@ const WINDOWS_FRAGILE_PATTERNS: Array<{ pattern: RegExp; reason: string }> = [ // when possible; this list is for environment-/runtime-specific tests where // the failure mode is structural rather than detectable via source-file scan. export const KNOWN_WINDOWS_INCOMPATIBLE: Array<{ file: string; reason: string }> = [ + { file: 'test/ship-queue-land.test.ts', reason: 'its gh shim is an extensionless #!/usr/bin/env node script on a colon-joined PATH, which windows-latest cannot launch, so land reaches the real gh (no GH_TOKEN) and every case fails (windows-free-shard 6, run 40824965988); land is a controller command run on Linux and macOS' }, // Like test/paid-rerun.test.ts (excluded by pattern), it replays a gate capture through the paid lane's --report and --rerun-plan CLI. { file: 'test/shared-libs-index-flags-readonly.test.ts', reason: 'its assume-unchanged replay seeds the review through test/helpers/shared-libs-path-fixture.ts, which launches bin/gstack-review-log (a bash script) directly; windows-latest refuses it as not found in $PATH (windows-free-shard 5, run 40727825859)' }, { file: 'test/credential-rejection.test.ts', reason: 'replays run 39696775546 through the --report and --rerun-plan CLI of the paid lane, which runs only on Linux CI; on windows-latest that CLI exits 1 with EINVAL in open() (windows-free-shard run 39982914852)' }, From 4bad65b5b177049f856248537bd09548ad9d3eec Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 06:02:05 +0000 Subject: [PATCH 10/31] docs(ship-policy): rollback lands open fragment PRs first; restamp folds only with the key on Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- docs/ship-policy.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 8f255beb0..e86a89935 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -1271,8 +1271,10 @@ release-field-only conflict, write an entry from the title, or refuse on a product conflict. Then set `changelog_fragments` and `min_gstack_version` in one policy PR; a claim-at-open repository switches `queue_mode` to `stamp-at-merge` in the same PR. Open PRs need no change: `land` handles each -census case. Rollback is removing the key; `gstack-restamp` still folds any -fragment it finds, so no entry is lost. +census case. Rollback is removing the key. With it off, `gstack-restamp` +neither folds nor deletes fragment files, so first land every open PR that +carries a fragment (`land --census` lists them) or move each fragment's text +under `[Unreleased]` in its PR; otherwise that entry never reaches CHANGELOG.md. ## The tree receipt From 5354172d8981997095140f0f1f98a19c20be95c5 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 06:34:55 +0000 Subject: [PATCH 11/31] fix(land): close pre-landing review findings land without --no-wait now compares the pushed branch head and polls the stamp commit's checks, refuses a PR whose base is not the stamped base, releases the lock on a pre-push failure, keeps --break-lock off drift locks, resumes from a lost journal via the stamp commit's pin, honours history merge-only, and refuses a fragment whose text is already released on the base. Version Gate stops applying open claims to a fragments stamp. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- lib/changelog-fragments.ts | 11 +++- lib/restamp.ts | 8 +-- lib/ship-queue-land.ts | 79 +++++++++++++++++--------- lib/version-gate-train.ts | 4 +- scripts/compare-pr-version.ts | 4 ++ test/ship-queue-land.test.ts | 86 ++++++++++++++++++++++++++++- test/version-gate-fragments.test.ts | 7 ++- 7 files changed, 161 insertions(+), 38 deletions(-) diff --git a/lib/changelog-fragments.ts b/lib/changelog-fragments.ts index 6bde412fb..d0ae4c2ab 100644 --- a/lib/changelog-fragments.ts +++ b/lib/changelog-fragments.ts @@ -58,9 +58,10 @@ export function parseFragment(rel: string, text: string): Fragment | FragmentErr * The fragments in `dir` of a tree-ish (a commit or tree id), or of the index when `treeish` is * null. `exclude` names a tree-ish whose fragments are not this change's own (the base branch, or a * stacked pin's parent): a path present there with the same blob is skipped, a path present there - * with other bytes is an error. + * with other bytes is an error. With `changelog`, a fragment whose text is already in a released section of + * that file at `exclude` is an error too: a landed PR's fragment carried back in by stacked history. */ -export function readFragments(cwd: string, treeish: string | null, dir: string, exclude?: string | null): FragmentSet { +export function readFragments(cwd: string, treeish: string | null, dir: string, exclude?: string | null, changelog?: string | null): FragmentSet { const prefix = dir.replace(/\/+$/, '') + '/'; const list = (t: string | null) => { const r = t === null ? git(cwd, ['ls-files', '-s', '-z', '--', prefix]) : git(cwd, ['ls-tree', '-r', '-z', '--full-tree', t, '--', prefix]); @@ -73,6 +74,8 @@ export function readFragments(cwd: string, treeish: string | null, dir: string, return out; }; const excluded = new Map((exclude ? list(exclude) : []).map(e => [e.path, e.sha])); + const shown = exclude && changelog ? git(cwd, ['show', `${exclude}:${changelog}`]) : null; + const released = shown?.ok ? parseChangelog(shown.out).sections.filter(s => !s.unreleased).map(s => s.lines.join('\n')).join('\n') : ''; const fragments: Fragment[] = []; const errors: FragmentError[] = []; for (const e of list(treeish).sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))) { @@ -90,7 +93,9 @@ export function readFragments(cwd: string, treeish: string | null, dir: string, const blob = git(cwd, ['cat-file', 'blob', e.sha]); if (!blob.ok) { err('blob unreadable'); continue; } const parsed = parseFragment(e.path, blob.out); - if ('code' in parsed) errors.push(parsed); else fragments.push(parsed); + if ('code' in parsed) errors.push(parsed); + else if (released && released.includes(parsed.body)) err(`its text is already released in ${changelog} on the base (a landed PR's fragment carried in by stacked history); delete it from this branch`); + else fragments.push(parsed); } return { fragments, errors }; } diff --git a/lib/restamp.ts b/lib/restamp.ts index b2c3eca3a..69e2152a9 100644 --- a/lib/restamp.ts +++ b/lib/restamp.ts @@ -493,7 +493,7 @@ function runStages(o: RestampOptions, plan: Plan, staging: string, since: string const date = (o.now ?? new Date()).toISOString().slice(0, 10); const from = o.was ?? plan.branchStamp; const dir = plan.policy.changelog_fragments?.dir; - const frags = dir ? readFragments(staging, null, dir, plan.baseSha) : null; + const frags = dir ? readFragments(staging, null, dir, plan.baseSha, clRel) : null; if (frags?.errors.length) { cl.status = 'failed'; cl.detail = `${frags.errors[0]!.path}: ${frags.errors[0]!.message}`; throw new Refusal('CHANGELOG_FRAGMENT_INVALID', `${cl.detail}; nothing written to the branch`); } for (const f of frags?.fragments ?? []) fs.rmSync(path.join(staging, f.path)); const r = reheadChangelog(foldIntoUnreleased(fs.readFileSync(clPath, 'utf8'), frags?.fragments ?? []), { from, to: plan.newVersion, date, file: clRel! }); @@ -652,10 +652,10 @@ function applyToBranch(o: RestampOptions, staging: string, wtree: string, files: } /** The fragments this change adds (absent from origin/), read from the working-tree fingerprint the staging worktree is seeded from. */ -function fragmentsOf(o: RestampOptions, dir: string): FragmentSet { +function fragmentsOf(o: RestampOptions, dir: string, changelog: string | null): FragmentSet { const fp = wtreeFingerprint({ cwd: o.repoRoot }); if ('error' in fp) throw new Refusal('RESTAMP_CONFLICT', `cannot fingerprint the working tree (${fp.error}); nothing written`); - const set = readFragments(o.repoRoot, fp.tree, dir, `origin/${o.base}`); + const set = readFragments(o.repoRoot, fp.tree, dir, `origin/${o.base}`, changelog); if (set.errors.length) throw new Refusal('CHANGELOG_FRAGMENT_INVALID', `${set.errors[0]!.path}: ${set.errors[0]!.message}; nothing written`); return set; } @@ -677,7 +677,7 @@ export function restamp(o: RestampOptions): RestampResult { throw new Refusal('REPO_COMMANDS_NOT_ALLOWED', `repo commands not executed (pass --allow-repo-commands): ${commands.map(c => `${c.key}: ${c.command}`).join('; ')}; nothing written`, { commands }); } const fragDir = loaded.policy.changelog_fragments?.dir ?? null; - const frags = fragDir ? fragmentsOf(o, fragDir) : null; + const frags = fragDir ? fragmentsOf(o, fragDir, loaded.policy.changelog) : null; const plan = planVersions(frags && !o.bump ? { ...o, bump: effectiveBump(loaded.policy.bump === 'patch' ? 'patch' : 'micro', frags.fragments) } : o, loaded, label); const headSha = git(o.repoRoot, ['rev-parse', 'HEAD']).out; const common: Partial = { diff --git a/lib/ship-queue-land.ts b/lib/ship-queue-land.ts index 14e96a9aa..8cef29ecf 100644 --- a/lib/ship-queue-land.ts +++ b/lib/ship-queue-land.ts @@ -17,7 +17,6 @@ import * as path from 'node:path'; import { mergeChangelogs, parseChangelog } from './changelog-check'; import { fragmentSlug, readFragments } from './changelog-fragments'; import { restamp, versionAtRef } from './restamp'; -import { BIN_DIR, runScriptSync } from './script-invocation'; import { matchesAny, type ShipPolicy } from './ship-policy'; import { ciAtHead, loadBasePolicy, type QueueContext, type StepOutput } from './ship-queue'; import { QUEUE_DIR_REL, git, writeJsonAtomic, type QueueFailure } from './ship-queue-gate'; @@ -48,8 +47,9 @@ function readLock(root: string): { sha: string; lock: LandLock | null } { const sha = git(root, ['rev-parse', '--verify', '--quiet', LAND_LOCK_REF]).out; if (!sha) return { sha: '', lock: null }; const body = git(root, ['show', `${sha}:lock.json`]); - const lock = body.ok ? (JSON.parse(body.out) as LandLock | { released: true }) : null; - return { sha, lock: lock && 'pr' in lock ? lock : null }; + let lock: LandLock | { released: true } | null = null; + try { lock = body.ok ? JSON.parse(body.out) : null; } catch { lock = null; } + return { sha, lock: lock && 'pr' in lock && /^\d+$/.test(String(lock.pr)) ? lock : null }; } function writeLock(root: string, expected: string, value: LandLock | { released: true; by: string; at: string }): { ok: boolean; sha: string } { @@ -165,10 +165,13 @@ function buildStamp(ctx: QueueContext, policy: ShipPolicy, o: { pin: string; bas } const dir = policy.changelog_fragments?.dir; if (dir && policy.changelog) { - const own = readFragments(wt, null, dir, o.baseSha); + const own = readFragments(wt, null, dir, o.baseSha, policy.changelog); const cl = fs.existsSync(path.join(wt, policy.changelog)) ? fs.readFileSync(path.join(wt, policy.changelog), 'utf8') : ''; if (!own.errors.length && !own.fragments.length && !parseChangelog(cl).sections.some(s => s.unreleased)) { const rel = `${dir.replace(/\/+$/, '')}/land-${fragmentSlug(o.pr)}.md`; + const segs = rel.split('/').slice(0, -1); + const link = segs.findIndex((_, i) => { try { return fs.lstatSync(path.join(wt, ...segs.slice(0, i + 1))).isSymbolicLink(); } catch { return false; } }); + if (link >= 0) return { code: 'CHANGELOG_FRAGMENT_INVALID', message: `${segs.slice(0, link + 1).join('/')} is a symlink at the pin; land writes a fragment only inside the repository` }; fs.mkdirSync(path.dirname(path.join(wt, rel)), { recursive: true }); fs.writeFileSync(path.join(wt, rel), `- ${o.title.replace(/\s+/g, ' ').trim()} (#${o.pr})\n`); git(wt, ['add', '--', rel]); @@ -187,15 +190,26 @@ function buildStamp(ctx: QueueContext, policy: ShipPolicy, o: { pin: string; bas } } -interface PrInfo { state: string; headRefName: string; headRefOid: string; isCrossRepository: boolean; body: string; title: string } +interface PrInfo { state: string; baseRefName?: string; headRefName: string; headRefOid: string; isCrossRepository: boolean; body: string; title: string } function prInfo(ctx: QueueContext, pr: string): PrInfo | QueueFailure { - const r = ctx.gh(['pr', 'view', pr, '--json', 'state,headRefName,headRefOid,isCrossRepository,body,title']); + const r = ctx.gh(['pr', 'view', pr, '--json', 'state,baseRefName,headRefName,headRefOid,isCrossRepository,body,title']); try { return JSON.parse(r.out) as PrInfo; } catch { return { code: 'QUEUE_NOT_READY', message: `gh pr view ${pr} failed: ${(r.err ?? r.out).trim().slice(0, 160)}` }; } } -function waitGreen(ctx: QueueContext, pr: string, minutes: number): boolean { - const r = runScriptSync(path.join(BIN_DIR, 'gstack-pr-wait'), [pr, '--until', 'green', '--timeout', String(minutes)], { cwd: ctx.repoRoot, stdio: ['ignore', 'inherit', 'inherit'], timeout: (minutes + 5) * 60_000 }); - return r.status === 0; +/** Poll the stamp commit's checks (the same read as the pin's) until they are green, one is red, or the time runs out. */ +function waitChecks(ctx: QueueContext, sha: string, minutes: number): void { + const deadline = Date.now() + minutes * 60_000; + for (let ci = ciAtHead(ctx, sha); !ci.green && !ci.line.startsWith('red') && Date.now() < deadline; ci = ciAtHead(ctx, sha)) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 30_000); + } +} + +/** A head that is land's own stamp commit ([pin, base], `(land)` subject for this PR): its pin, when the local journal is gone. */ +function stampPin(root: string, branch: string, head: string, pr: string): string | null { + git(root, ['fetch', '-q', 'origin', `refs/heads/${branch}`]); + const [parents = '', subject = ''] = git(root, ['log', '-1', '--format=%P%n%s', head]).out.split('\n'); + const p = parents.split(' ').filter(Boolean); + return p.length === 2 && subject.endsWith(` into #${pr} (land)`) ? p[0]! : null; } /** `land --census`: what land would do with each open PR before a repository turns fragments on. Reads only. */ @@ -212,14 +226,15 @@ export function census(ctx: QueueContext): StepOutput { const at = versionAtRef(root, baseSha); const versionRel = 'rel' in at ? at.rel : 'VERSION'; const lines = [`CENSUS: ${prs.length} open PR(s) against origin/${ctx.base}@${baseSha.slice(0, 12)} (fragments: ${policy.changelog_fragments ? policy.changelog_fragments.dir : 'off'})`]; + const heads = prs.filter(p => !p.isCrossRepository).map(p => `refs/pull/${p.number}/head`); + if (heads.length && !git(root, ['fetch', '-q', 'origin', ...heads]).ok) for (const h of heads) git(root, ['fetch', '-q', 'origin', h]); for (const p of prs.sort((a, b) => a.number - b.number)) { if (p.isCrossRepository) { lines.push(`#${p.number} fork: refused (QUEUE_TRAIN_UNTRUSTED)`); continue; } - git(root, ['fetch', '-q', 'origin', `+refs/pull/${p.number}/head:refs/gstack-census/${p.number}`]); const head = p.headRefOid; const mt = git(root, ['merge-tree', '--write-tree', '--name-only', '--no-messages', baseSha, head]); const conflicts = mt.ok ? [] : mt.out.split('\n').slice(1).filter(Boolean); const product = conflicts.filter(c => c !== policy.changelog && c !== versionRel && !matchesAny(policy.release_outputs, c) && !['package.json', 'package-lock.json', 'npm-shrinkwrap.json'].includes(path.basename(c))); - const frags = policy.changelog_fragments ? readFragments(root, head, policy.changelog_fragments.dir, baseSha) : { fragments: [], errors: [] }; + const frags = policy.changelog_fragments ? readFragments(root, head, policy.changelog_fragments.dir, baseSha, policy.changelog) : { fragments: [], errors: [] }; const cl = policy.changelog ? git(root, ['show', `${head}:${policy.changelog}`]).out : ''; const legacy = parseChangelog(cl).sections.some(s => s.unreleased); const hv = versionAtRef(root, head); @@ -293,10 +308,12 @@ export function land(ctx: QueueContext, o: LandOptions): StepOutput { if ('code' in info) return fail([], info, run); if (info.isCrossRepository) return fail([], { code: 'QUEUE_TRAIN_UNTRUSTED', message: `#${o.pr} is a fork head; land stamps and pushes only crew branches` }, run); if (info.state !== 'OPEN') return fail([], { code: 'QUEUE_NOT_READY', message: `#${o.pr} is ${info.state}` }, run); + if (info.baseRefName && info.baseRefName !== ctx.base) return fail([], { code: 'QUEUE_NOT_READY', message: `#${o.pr} targets ${info.baseRefName}, not ${ctx.base}; land merges only PRs on the base it stamps (retarget it, or land its parent first)` }, run); let j = readJson(file); - if (j && (j.phase === 'landed' || j.reviewed_pin !== info.headRefOid && j.last_pushed_head !== info.headRefOid)) j = null; - const pin = j?.reviewed_pin ?? info.headRefOid; - const lines = [`LAND: #${o.pr} branch=${info.headRefName} pin=${pin}${j ? ` (resuming at ${j.phase})` : ''}`]; + if (j && (j.phase === 'landed' || ![j.reviewed_pin, j.last_pushed_head, j.stamp_commit].includes(info.headRefOid))) j = null; + const recovered = j ? null : stampPin(root, info.headRefName, info.headRefOid, o.pr); + const pin = j?.reviewed_pin ?? recovered ?? info.headRefOid; + const lines = [`LAND: #${o.pr} branch=${info.headRefName} pin=${pin}${j ? ` (resuming at ${j.phase})` : recovered ? ` (the head ${info.headRefOid.slice(0, 12)} is land's stamp commit; restamping from its pin)` : ''}`]; git(root, ['fetch', '-q', 'origin', ctx.base]); const receipt = bindShippedReceipt(root, `origin/${ctx.base}`, info.body ?? '', o.pr, [pin]); lines.push(receipt.line, ...(receipt.depthLine ? [receipt.depthLine] : [])); @@ -314,11 +331,13 @@ export function land(ctx: QueueContext, o: LandOptions): StepOutput { } let { sha: lockSha, lock } = readLock(root); - const repairOf = lock && lock.pr !== o.pr && lock.phase === 'drift' ? readJson(path.join(root, lock.journal)) : null; + const drifted = lock && lock.pr !== o.pr && lock.phase === 'drift' ? lock.pr : null; + const repairOf = drifted ? readJson(path.join(root, landJournalRel(drifted))) : null; if (repairOf?.repair_pr === o.pr) lines.push(`LOCK: taking over #${lock!.pr}'s drift lock for its repair`); else if (lock && lock.pr !== o.pr) { const age = now().getTime() - Date.parse(lock.started_at); if (!o.breakLock) return fail(lines, { code: 'QUEUE_LAND_LOCKED', message: `#${lock.pr} holds the land lock (${lock.holder}, phase ${lock.phase}, since ${lock.started_at})` }, run); + if (lock.phase === 'drift') return fail(lines, { code: 'QUEUE_LAND_DRIFT', message: `#${lock.pr} holds a drift lock; --break-lock never breaks it, only its repair PR's land takes it over`, fix: `gstack-ship-queue land --pr ${lock.pr} --repair` }, run); if (lock.phase === 'pushing' && age < PUSH_STALE_MS) return fail(lines, { code: 'QUEUE_LAND_LOCKED', message: `#${lock.pr}'s holder is mid-push (${Math.round(age / 1000)} s ago); --break-lock refused` }, run); lines.push(`LOCK: broke #${lock.pr}'s lock (${lock.holder}, phase ${lock.phase})`); } @@ -331,7 +350,7 @@ export function land(ctx: QueueContext, o: LandOptions): StepOutput { const save = () => writeJsonAtomic(file, j); const failed = setLock(j?.phase === 'pushed' ? 'waiting' : 'stamping'); if (failed) return fail(lines, failed, run); - if (!j) { j = { schema_version: 1, pr: o.pr, branch: info.headRefName, reviewed_pin: pin, last_pushed_head: null, stamp_commit: null, stamp_tree: null, base_sha: null, version: null, date: null, restamps: 0, phase: 'locked' }; save(); } + if (!j) { j = { schema_version: 1, pr: o.pr, branch: info.headRefName, reviewed_pin: pin, last_pushed_head: recovered ? info.headRefOid : null, stamp_commit: null, stamp_tree: null, base_sha: null, version: null, date: null, restamps: 0, phase: 'locked' }; save(); } for (;;) { git(root, ['fetch', '-q', 'origin', ctx.base]); @@ -343,7 +362,11 @@ export function land(ctx: QueueContext, o: LandOptions): StepOutput { } if (j.phase === 'locked') { const s = buildStamp(ctx, policy, { pin, baseSha: baseNow, pr: o.pr, title: info.title, allowRepoCommands: !!o.allowRepoCommands, now: j.date ? new Date(`${j.date}T12:00:00Z`) : now() }); - if ('code' in s) { setLock('stamping'); return fail([...lines], s, run); } + if ('code' in s) { + if (repairOf?.repair_pr === o.pr) setLock('stamping'); + else writeLock(root, lockSha, { released: true, by: holder, at: now().toISOString() }); + return fail([...lines], s, run); + } lines.push(...s.lines); const commit = git(root, ['commit-tree', s.tree, '-p', pin, '-p', baseNow, '-m', `v${s.version} stamp: merge ${ctx.base} ${baseNow.slice(0, 12)} into #${o.pr} (land)`]).out; Object.assign(j, { stamp_commit: commit, stamp_tree: s.tree, base_sha: baseNow, version: s.version, date: j.date ?? now().toISOString().slice(0, 10), phase: 'stamped' }); @@ -360,33 +383,37 @@ export function land(ctx: QueueContext, o: LandOptions): StepOutput { if (remote !== j.stamp_commit) return fail(lines, { code: 'QUEUE_TRAIN_MOVED', message: `#${o.pr}'s branch is at ${remote?.slice(0, 12) || 'unknown'}, not ${lease.slice(0, 12)}: someone else pushed; land never overwrites it` }, run); } j.last_pushed_head = j.stamp_commit; j.phase = 'pushed'; save(); - setLock('waiting'); + const fw = setLock('waiting'); + if (fw) return fail(lines, fw, run); lines.push(`PUSHED: ${j.stamp_commit} to ${j.branch} (lease ${lease.slice(0, 12)})`); if (o.noWait) return { ok: true, run, lines: [...lines, `NEXT: gstack-pr-wait ${o.pr} --until green && gstack-ship-queue land --pr ${o.pr}`] }; - if (!waitGreen(ctx, o.pr, o.waitTimeoutMin ?? 60)) return fail(lines, { code: 'QUEUE_NOT_READY', message: `checks did not go green on ${j.stamp_commit!.slice(0, 12)}`, fix: `fix the failure, then gstack-ship-queue land --pr ${o.pr} (it resumes)` }, run); + waitChecks(ctx, j.stamp_commit!, o.waitTimeoutMin ?? 60); continue; } if (j.phase === 'pushed') { - if (info.headRefOid !== j.stamp_commit) return fail(lines, { code: 'QUEUE_TRAIN_MOVED', message: `#${o.pr}'s head is ${info.headRefOid.slice(0, 12)}, not the stamp commit ${j.stamp_commit?.slice(0, 12)}` }, run); + const head = git(root, ['ls-remote', 'origin', `refs/heads/${j.branch}`]).out.split('\t')[0] ?? ''; + if (head !== j.stamp_commit) return fail(lines, { code: 'QUEUE_TRAIN_MOVED', message: `#${o.pr}'s head is ${head.slice(0, 12) || 'unknown'}, not the stamp commit ${j.stamp_commit?.slice(0, 12)}` }, run); const ci = ciAtHead(ctx, j.stamp_commit!); lines.push(`CI: ${ci.line}`); if (!ci.green) return { ok: !ci.line.startsWith('red'), run, lines: [...lines, `NEXT: gstack-pr-wait ${o.pr} --until green && gstack-ship-queue land --pr ${o.pr}`], failure: ci.line.startsWith('red') ? { code: 'QUEUE_NOT_READY', message: `a check failed on the stamp commit ${j.stamp_commit!.slice(0, 12)}` } : undefined }; - setLock('merging'); - const m = ctx.gh(['pr', 'merge', o.pr, '--squash', '--match-head-commit', j.stamp_commit!]); + const fm = setLock('merging'); + if (fm) return fail(lines, fm, run); + const m = ctx.gh(['pr', 'merge', o.pr, policy.history === 'merge-only' ? '--merge' : '--squash', '--match-head-commit', j.stamp_commit!]); if (!m.ok) return fail(lines, { code: 'QUEUE_NOT_READY', message: `gh pr merge refused: ${(m.err ?? m.out).trim().slice(0, 200)}` }, run); j.phase = 'merged'; save(); } if (j.phase === 'merged') { git(root, ['fetch', '-q', 'origin', ctx.base]); - const main = git(root, ['rev-parse', `origin/${ctx.base}`]).out; + const tip = git(root, ['rev-parse', `origin/${ctx.base}`]).out; + const main = git(root, ['rev-list', '--first-parent', '--reverse', `${j.base_sha}..${tip}`]).out.split('\n')[0] || tip; const tree = git(root, ['rev-parse', `${main}^{tree}`]).out; const parent = git(root, ['rev-parse', `${main}^1`]).out; if (tree !== j.stamp_tree || parent !== j.base_sha) { - j.phase = 'drift'; j.drift_main = main; save(); setLock('drift'); - return fail([...lines, `MAIN: ${main} tree=${tree} parent=${parent}`], { code: 'QUEUE_LAND_DRIFT', message: `origin/${ctx.base} ${main.slice(0, 12)} is not the stamped tree on ${j.base_sha?.slice(0, 12)} (a merge outside land slipped in)`, fix: `gstack-ship-queue land --pr ${o.pr} --repair opens the PR that gives the outside merge its own release; /ship and land it, which releases the lock` }, run); + j.phase = 'drift'; j.drift_main = tip; save(); setLock('drift'); + return fail([...lines, `MAIN: ${tip} first=${main} tree=${tree} parent=${parent}`], { code: 'QUEUE_LAND_DRIFT', message: `origin/${ctx.base} ${main.slice(0, 12)} is not the stamped tree on ${j.base_sha?.slice(0, 12)} (a merge outside land slipped in)`, fix: `gstack-ship-queue land --pr ${o.pr} --repair opens the PR that gives the outside merge its own release; /ship and land it, which releases the lock` }, run); } j.phase = 'landed'; save(); - if (repairOf?.repair_pr === o.pr) writeJsonAtomic(path.join(root, landJournalRel(repairOf.pr)), { ...repairOf, phase: 'landed' }); + if (drifted && repairOf?.repair_pr === o.pr) writeJsonAtomic(path.join(root, landJournalRel(drifted)), { ...repairOf, phase: 'landed' }); const rel = writeLock(root, lockSha, { released: true, by: holder, at: now().toISOString() }); return { ok: true, run, lines: [...lines, `MERGED: #${o.pr} as ${main} (${j.version})`, `LANDED: origin/${ctx.base} tree ${tree} equals the stamped tree`, `LOCK: ${rel.ok ? 'released' : 'release push failed (the next land takes it over)'}`] }; } diff --git a/lib/version-gate-train.ts b/lib/version-gate-train.ts index 604b17990..00f0e3333 100644 --- a/lib/version-gate-train.ts +++ b/lib/version-gate-train.ts @@ -18,7 +18,7 @@ import { matchesAny } from './ship-policy'; export type TrainHead = | { kind: 'off' } | { kind: 'unstamped'; fragments?: number; entry?: 'fragment' | 'unreleased' | 'none-bot' } - | { kind: 'stamped'; expected: string } + | { kind: 'stamped'; expected: string; fragments?: boolean } | { kind: 'invalid'; reason: string }; function git(repoRoot: string, args: string[]): string | null { @@ -119,7 +119,7 @@ export function classifyTrainHead(o: { repoRoot: string; baseRef: string; head: const pin = git(o.repoRoot, ['rev-parse', '--verify', '--quiet', `${o.head}^2`]) ? `${o.head}^1` : mergeBase; const level = policy.bump === 'patch' ? 'patch' : 'micro'; const expected = bumpVersion(baseVersion, fragDir ? effectiveBump(level, readFragments(o.repoRoot, pin, fragDir, base).fragments) : (policy.bump ?? 'patch')); - return expected ? { kind: 'stamped', expected } : { kind: 'invalid', reason: `${base}:VERSION is not a 4-part version` }; + return expected ? { kind: 'stamped', expected, fragments: !!fragDir } : { kind: 'invalid', reason: `${base}:VERSION is not a 4-part version` }; } const file = typeof policy.changelog === 'string' ? policy.changelog : 'CHANGELOG.md'; diff --git a/scripts/compare-pr-version.ts b/scripts/compare-pr-version.ts index aa6c37273..db1c13f72 100644 --- a/scripts/compare-pr-version.ts +++ b/scripts/compare-pr-version.ts @@ -53,6 +53,10 @@ if (process.env.BASE_REF && process.env.HEAD_SHA) { console.log(`::error::${tag} claims v${prVersionEnv}, but under the base's ship policy a stamped head must carry v${train.expected} (base + the effective bump, what gstack-restamp --next allocates at the merge step). Leave VERSION, package.json and the release outputs at the base's values until the merge step stamps them.`); process.exit(1); } + if (train.kind === "stamped" && train.fragments) { + console.log(`✓ ${tag} carries v${train.expected}, what the merge step allocates under changelog_fragments; the land lock serializes allocation, so open claims do not apply.`); + process.exit(0); + } } if (parsed.offline === true) { diff --git a/test/ship-queue-land.test.ts b/test/ship-queue-land.test.ts index 68d8b70bd..ce2d2b43a 100644 --- a/test/ship-queue-land.test.ts +++ b/test/ship-queue-land.test.ts @@ -46,7 +46,7 @@ function setup(policy: Record = POLICY): Fx { " if(head!==want){console.error('Head branch was modified');process.exit(1)}", " if(d.mergeRefuse){console.error('Pull request is not mergeable: the base branch policy prohibits the merge');process.exit(1)}", " if(d.driftOnMerge){const m=g('rev-parse','refs/heads/main');const t=g('rev-parse',m+'^{tree}');g('update-ref','refs/heads/main',g('commit-tree',t,'-p',m,'-m','outside merge'));d.driftOnMerge=false}", - " const main=g('rev-parse','refs/heads/main');const sq=g('commit-tree',g('rev-parse',want+'^{tree}'),'-p',main,'-m','squash #'+a[2]);g('update-ref','refs/heads/main',sq);p.state='MERGED';save();process.exit(0)}", + " const main=g('rev-parse','refs/heads/main');const sq=g('commit-tree',g('rev-parse',want+'^{tree}'),'-p',main,'-m','squash #'+a[2]);g('update-ref','refs/heads/main',sq);if(d.outsideAfter){g('update-ref','refs/heads/main',g('commit-tree',g('rev-parse',sq+'^{tree}'),'-p',sq,'-m','outside after'));d.outsideAfter=false}d.mergeArgs=a.slice(3);p.state='MERGED';save();process.exit(0)}", "if(a[0]==='pr'&&a[1]==='create'){const n=String(Math.max(0,...Object.keys(d.prs).map(Number))+1);d.prs[n]={state:'OPEN',headRefName:a[a.indexOf('--head')+1],isCrossRepository:false,title:a[a.indexOf('--title')+1],body:''};save();console.log('https://github.com/o/r/pull/'+n);process.exit(0)}", "console.error('gh shim: unsupported '+a.join(' '));process.exit(1);", '', @@ -221,6 +221,7 @@ describe('land: lock, races and refusals', () => { expect(r.err).toContain('(QUEUE_LAND_DRIFT)'); openPr(fx, '10'); expect(land(fx, ['--pr', '10']).err).toMatch(/#9 holds the land lock .*phase drift.*\(QUEUE_LAND_LOCKED\)/); + expect(land(fx, ['--pr', '10', '--break-lock']).err).toMatch(/#9 holds a drift lock; --break-lock never breaks it.*\(QUEUE_LAND_DRIFT\)/); const rep = land(fx, ['--pr', '9', '--repair']); expect(rep.status).toBe(EXIT.ok); expect(rep.out).toContain('entry: changelog.d/repair-9.md names 1 outside commit(s)'); @@ -457,3 +458,86 @@ describe('land: failures after the push, repair branches and census rows', () => expect(fx.repo.git('ls-remote', 'origin', 'refs/notes/gstack-land')).toBe(''); }, 90_000); }); + +// /ship pre-landing review fixes (GSTA-154 PR A, pass 1): each case below failed or was unreachable before its fix. +describe('land: one pass, base, lock and history', () => { + // Value: protects=land without --no-wait pushes, waits and merges in one run; fails_when=the pushed phase compares a pre-push head; why_new=every other case passes --no-wait; seam=none; no_claim=GitHub check scheduling latency (gh shim reports green at once) + test('without --no-wait one run pushes the stamp, sees its checks green and merges', () => { + const fx = setup(); + openPr(fx, '7'); + const r = runBin(fx.repo, 'gstack-ship-queue', ['land', '--pr', '7', '--base', 'main', '--allow-repo-commands'], fx.env); + expect(r.err).toBe(''); + expect(r.status).toBe(EXIT.ok); + expect(r.out).toContain('PUSHED: '); + expect(r.out).toContain('MERGED: #7'); + expect(r.out).toContain('LOCK: released'); + }, 90_000); + + // Value: protects=land refuses a PR whose base is not the base it stamps; fails_when=a stacked PR gets main merged and squashed into its parent branch; why_new=no case set baseRefName; seam=none; no_claim=gh pr view field names (shim) + test('a PR that targets another branch refuses before the lock, with nothing pushed', () => { + const fx = setup(); + const pin = openPr(fx, '8'); + fx.write(d => { d.prs['8'].baseRefName = 'feature'; }); + const r = land(fx, ['--pr', '8']); + expect(r.status).toBe(EXIT.refused); + expect(r.err).toMatch(/#8 targets feature, not main.*\(QUEUE_NOT_READY\)/); + expect(remote(fx, 'pr-8')).toBe(pin); + expect(fx.repo.git('ls-remote', 'origin', 'refs/notes/gstack-land')).toBe(''); + }, 90_000); + + // Value: protects=a stamp that fails before any push releases the land lock; fails_when=one conflicting PR blocks every other land until --break-lock; why_new=the conflict case checks only the branch; seam=none; no_claim=none beyond protects + test('a product conflict releases the lock, so the next PR lands without --break-lock', () => { + const fx = setup(); + openPr(fx, '4', { edit: w => fs.writeFileSync(path.join(w, 'shared.txt'), 'four\n') }); + openPr(fx, '5', { edit: w => fs.writeFileSync(path.join(w, 'shared.txt'), 'five\n') }); + openPr(fx, '6'); + land(fx, ['--pr', '4']); land(fx, ['--pr', '4']); + expect(land(fx, ['--pr', '5']).err).toContain('(QUEUE_LAND_CONFLICT)'); + fx.repo.git('fetch', '-q', 'origin', '+refs/notes/gstack-land:refs/notes/gstack-land'); + expect(show(fx, 'refs/notes/gstack-land:lock.json')).toContain('"released": true'); + expect(land(fx, ['--pr', '6']).status).toBe(EXIT.ok); + }, 90_000); + + // Value: protects=a landed PR's fragment carried in by a stacked branch is refused, not folded twice; fails_when=the child PR's stamp repeats the parent's entry and bump; why_new=no case stacks a PR on an unlanded pin; seam=none; no_claim=exact-text match only (a reworded copy is not caught) + test('a fragment already released on the base (stacked history) refuses instead of folding twice', () => { + const fx = setup(); + const pin1 = openPr(fx, '1', { fragment: '- #1 the parent change\n' }); + land(fx, ['--pr', '1']); land(fx, ['--pr', '1']); + fx.repo.git('checkout', '-q', '-B', 'pr-11', pin1); + fs.writeFileSync(path.join(fx.repo.work, 'changelog.d', 'pr-11.md'), '- #11 child change\n'); + fx.repo.git('add', '-A'); fx.repo.git('commit', '-q', '-m', 'child'); + fx.repo.git('push', '-q', '-f', 'origin', 'HEAD:refs/heads/pr-11'); + const head = fx.repo.git('rev-parse', 'HEAD'); + fx.write(d => { d.prs['11'] = { state: 'OPEN', headRefName: 'pr-11', isCrossRepository: false, title: 'child', body: shippedBody('11', head) }; }); + fx.repo.git('checkout', '-q', '--detach', 'origin/main'); + const r = land(fx, ['--pr', '11']); + expect(r.status).toBe(EXIT.refused); + expect(r.err).toMatch(/changelog\.d\/pr-1\.md: its text is already released in CHANGELOG\.md.*\(CHANGELOG_FRAGMENT_INVALID\)/); + expect(remote(fx, 'pr-11')).toBe(head); + }, 90_000); + + // Value: protects=a lost local journal resumes from the stamp commit's pin; fails_when=the stamp commit is treated as the pin and the receipt refuses; why_new=resume cases keep the journal; seam=none; no_claim=none beyond protects + test('with the local journal gone, a head that is land\'s stamp commit resumes from its pin', () => { + const fx = setup(); + const pin = openPr(fx, '12'); + expect(land(fx, ['--pr', '12']).status).toBe(EXIT.ok); + fs.rmSync(path.join(fx.repo.work, '.gstack', 'tmp', 'ship-queue', 'land', '12.json')); + const again = land(fx, ['--pr', '12']); + expect(again.err).toBe(''); + expect(again.out).toContain(`pin=${pin} (the head `); + expect(again.out).toContain('PUSHED: '); + expect(land(fx, ['--pr', '12']).out).toContain('MERGED: #12'); + }, 90_000); + + // Value: protects=history merge-only lands with a merge commit, and a merge after land's own is not drift; fails_when=land squashes under merge-only, or flags a later outside merge as drift; why_new=no case sets history or merges after the squash; seam=none; no_claim=GitHub's merge-commit shape (the shim records the flag) + test('merge-only lands with --merge; an outside merge after the squash is not drift', () => { + const fx = setup({ ...POLICY, history: 'merge-only' }); + openPr(fx, '13'); + land(fx, ['--pr', '13']); + fx.write(d => { d.outsideAfter = true; }); + const r = land(fx, ['--pr', '13']); + expect(r.err).toBe(''); + expect(r.out).toContain('LANDED: '); + expect(JSON.parse(fs.readFileSync(fx.data, 'utf8')).mergeArgs).toContain('--merge'); + }, 90_000); +}); diff --git a/test/version-gate-fragments.test.ts b/test/version-gate-fragments.test.ts index 0df546be1..33fd31779 100644 --- a/test/version-gate-fragments.test.ts +++ b/test/version-gate-fragments.test.ts @@ -16,9 +16,9 @@ afterEach(() => { while (repos.length) cleanup(repos.pop()!); }); const fixture = () => { const r = makeFixtureRepo({ policy: POLICY, prefix: 'version-gate-frag-' }); repos.push(r); return r; }; const write = (r: FixtureRepo, rel: string, text: string) => { fs.mkdirSync(path.dirname(path.join(r.work, rel)), { recursive: true }); fs.writeFileSync(path.join(r.work, rel), text); }; const commit = (r: FixtureRepo, msg: string) => { r.git('add', '-A'); r.git('commit', '-q', '-m', msg); return r.git('rev-parse', 'HEAD'); }; -function run(r: FixtureRepo, head: string, extra: Record = {}) { +function run(r: FixtureRepo, head: string, extra: Record = {}, claimed: unknown[] = []) { const next = path.join(r.root, 'next.json'); - fs.writeFileSync(next, JSON.stringify({ version: '1.2.4.0', base_version: '1.2.3.0', claimed: [] })); + fs.writeFileSync(next, JSON.stringify({ version: '1.2.4.0', base_version: '1.2.3.0', claimed })); const prVersion = r.git('show', head + ':VERSION').trim(); const out = Bun.spawnSync([process.execPath, 'run', SCRIPT, next, '3600'], { cwd: r.work, env: { ...process.env, ...r.env, PR_VERSION: prVersion, BASE_REF: 'main', HEAD_SHA: head, PR_AUTHOR: 'garrytan', ...extra }, timeout: 30_000, stdout: 'pipe', stderr: 'pipe' }); return { code: out.exitCode, out: out.stdout.toString() + out.stderr.toString() }; @@ -84,6 +84,9 @@ describe('version gate: changelog fragments', () => { return r.git('commit-tree', r.git('write-tree'), '-p', pin, '-p', r.git('rev-parse', 'origin/main'), '-m', 'v' + version + ' stamp'); }; expect(run(r, stamp('1.3.0.0')).code).toBe(0); + const claimedToo = run(r, stamp('1.3.0.0'), {}, [{ pr: 77, branch: 'legacy', version: '1.3.0.0' }]); + expect(claimedToo.code).toBe(0); + expect(claimedToo.out).toContain('the land lock serializes allocation, so open claims do not apply'); const wrong = run(r, stamp('1.2.4.0')); expect(wrong.code).toBe(1); expect(wrong.out).toContain('must carry v1.3.0.0'); From 25becc243e3e12fd627acc1c3aeec11125094834 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 06:59:54 +0000 Subject: [PATCH 12/31] fix(land): close adversarial and outside-voice findings land reconciles a merge GitHub completed behind an error reply, refuses --break-lock against a holder that just entered a push or a merge, keeps a repair takeover on the lock itself (any checkout can land the repair PR), resumes --repair after a failed PR create, releases the lock on QUEUE_LAND_BASE_BUSY, never writes through a symlinked release file, keeps a package.json version source's other edits, and refuses an empty object id instead of pushing a deletion. A carried-back fragment is matched on whole released lines. Under changelog_fragments Version Gate accepts a stamped head only as a [pin, base] merge-step commit with valid fragments. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 12 ++-- docs/ship-policy.md | 27 +++++---- docs/troubleshooting.md | 8 +-- lib/changelog-fragments.ts | 12 ++-- lib/ship-queue-land.ts | 80 ++++++++++++++++--------- lib/version-gate-train.ts | 8 ++- test/changelog-fragments.test.ts | 15 +++++ test/ship-queue-land.test.ts | 92 ++++++++++++++++++++++++++++- test/version-gate-fragments.test.ts | 7 ++- 9 files changed, 205 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 370cb89b0..68538aa8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,20 +5,20 @@ **A repository can now assign its version when a PR merges instead of inside the PR: PRs carry unversioned CHANGELOG fragments and `gstack-ship-queue land` stamps, pushes and merges.** Today every PR picks a version before it merges, so each merge makes the next PR's version stale and its lane redoes the stamp (a median 14.5 minutes from stamp commit to merge over 30 slots, Oct 27-29; the queue froze on hand-made stamps in #3422 and #3487). With `changelog_fragments` on, two PRs cut from one base cannot collide, and nobody stamps by hand. gstack's own policy does not turn it on in this release (docs/ship-policy.md#changelog-fragments). ### Added -- **`changelog_fragments: { dir }`** (`lib/ship-policy.ts`, `lib/changelog-fragments.ts`): a PR adds `/.md` with its entry text and optional `bump: minor|major` front matter. Fragments are read as Git blobs only; a symlink, gitlink, nested path, empty or oversized fragment, one with a release heading, or a reused name is `CHANGELOG_FRAGMENT_INVALID` before any version is allocated. +- **`changelog_fragments: { dir }`** (`lib/ship-policy.ts`, `lib/changelog-fragments.ts`): a PR adds `/.md` with its entry text and optional `bump: minor|major` front matter. Fragments are read as Git blobs only; a symlink, gitlink, nested path, empty or oversized fragment, one with a release heading, a reused name, or one whose text is already released on the base (a parent PR's fragment carried in by stacked history) is `CHANGELOG_FRAGMENT_INVALID` before any version is allocated. - **`gstack-restamp` folds fragments**: their bodies go above any legacy `[Unreleased]` section under one new heading, the files are deleted, and the highest declared bump wins over the policy's. The tree receipt's `changelog-fragments` rule accepts exactly that fold and nothing else. -- **`gstack-ship-queue land --pr `** (`lib/ship-queue-land.ts`): the per-PR merge step. It takes the land lock (`refs/notes/gstack-land`), checks the /ship receipt and checks at the pin, merges the base resolving release fields only (a clean dependency edit survives; a product conflict refuses), stamps, pushes a commit with parents [pin, base] under an exact lease, waits for checks, restamps if the base moved (at most twice), squashes with `--match-head-commit` and confirms the base tree. Every step is journaled and resumable. `--repair` opens the follow-up PR after `QUEUE_LAND_DRIFT`; `--census` prints what land would do with every open PR, writing nothing. +- **`gstack-ship-queue land --pr `** (`lib/ship-queue-land.ts`): the per-PR merge step. It takes the land lock (`refs/notes/gstack-land`), checks the /ship receipt and checks at the pin, merges the base resolving release fields only (a clean dependency edit survives; a product conflict refuses), stamps, pushes a commit with parents [pin, base] under an exact lease, waits for checks, restamps if the base moved (at most twice), squashes with `--match-head-commit` (merges with `--merge` under `history: merge-only`) and confirms the base tree. A PR whose base is another branch refuses before the lock; a refusal before the push releases the lock. Every step is journaled and resumable, also from the stamp commit's pin when the local journal is gone. `--repair` opens the follow-up PR after `QUEUE_LAND_DRIFT`; `--census` prints what land would do with every open PR, writing nothing. - **/ship writes the fragment** (`lib/ship-policy.ts`, `ship/SKILL.md.tmpl`): under the key, `show-effective` prints a `ship: changelog_fragments` line, so Step 12 writes no version and Step 13 writes `/.md` (linted with `gstack-prose-lint --gate`) instead of an `[Unreleased]` section; the skill's policy paragraph now says its `ship:` lines bind Steps 12–16. - **`restamp_command` gets `GSTACK_RESTAMP_BASE_SHA`**, the release predecessor, so a repo counter such as a SAVE_VERSION computes predecessor + 1 from the base and never from the branch. - New result codes with troubleshooting anchors: `CHANGELOG_FRAGMENT_INVALID`, `QUEUE_LAND_LOCKED`, `QUEUE_LAND_CONFLICT`, `QUEUE_LAND_DRIFT`, `QUEUE_LAND_BASE_BUSY`. ### Changed -- **Version Gate runs on every PR** (the path filter is gone), so under `changelog_fragments` a PR that touches no release file still proves it carries a fragment. An unstamped head keeps its release fields at its merge base and adds a valid fragment (legacy `[Unreleased]` passes; a `[bot]` author needs none); a merge-step stamp commit must carry the base plus its fragments' effective bump. Without the key nothing changes. +- **Version Gate runs on every PR** (the path filter is gone), so under `changelog_fragments` a PR that touches no release file still proves it carries a fragment. An unstamped head keeps its release fields at its merge base and adds a valid fragment (legacy `[Unreleased]` passes; a `[bot]` author needs none); a merge-step stamp commit must have parents [pin, base] and carry the base plus its fragments' effective bump, so a lane that stamps its own head fails. Without the key nothing changes. ### Tests -- `test/changelog-fragments.test.ts`: blob-only reads and refusals, bump precedence, fold order with a legacy section, the restamp fold and its tree receipt, an edited fold refused. -- `test/ship-queue-land.test.ts` (bare origin and a gh shim): happy path; two PRs from one base without a collision; a legacy stamp plus a dependency edit; a title-derived entry; a product conflict; the lock and `--break-lock`; the base moving during CI; a foreign push never overwritten; a missing receipt; drift, `--repair` and the lock release; the census; a receipt that fails #3567's depth check refuses; a base with a four-part package.json in `stamp_files` lands four parts (#3557), gstack's own shape stays three. -- `test/version-gate-fragments.test.ts`: fragment heads, premature version changes, a bot author, an advanced base, an invalid fragment, the stamp commit's effective bump, and the workflow's missing path filter. +- `test/changelog-fragments.test.ts`: blob-only reads and refusals, a carried-back fragment refused on whole released lines only, bump precedence, fold order with a legacy section, the restamp fold and its tree receipt, an edited fold refused. +- `test/ship-queue-land.test.ts` (bare origin and a gh shim): happy path; two PRs from one base without a collision; a legacy stamp plus a dependency edit; a title-derived entry; a product conflict; the lock and `--break-lock`; the base moving during CI; a foreign push never overwritten; a missing receipt; drift, `--repair` and the lock release; the census; a receipt that fails #3567's depth check refuses; a base with a four-part package.json in `stamp_files` lands four parts (#3557), gstack's own shape stays three; one run without `--no-wait` merges; a PR on another base refuses; `--break-lock` refuses a drift lock; a conflict releases the lock; a stacked parent's fragment refuses; a lost journal resumes; `merge-only` merges with `--merge`; a merge GitHub completed behind an error reply reconciles; `--break-lock` refuses a holder mid-merge; `--repair` resumes after a failed PR create and its PR takes over from a clean checkout; a symlinked legacy VERSION refuses in `--dry-run`; a `package.json` version source keeps its dependency edits. +- `test/version-gate-fragments.test.ts`: fragment heads, premature version changes, a bot author, an advanced base, an invalid fragment, the stamp commit's effective bump (open version claims do not apply to it), and the workflow's missing path filter. - `test/restamp-command-boundary.test.ts`: the hook reads its counter from `GSTACK_RESTAMP_BASE_SHA`, not the branch. ## [1.91.312.0] - 2026-10-29 diff --git a/docs/ship-policy.md b/docs/ship-policy.md index e86a89935..749dcfa76 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -1212,19 +1212,20 @@ rule. ## `gstack-ship-queue land`: the per-PR merge step `land --pr ` merges one READY PR whose head is unstamped. It replaces the -lane's stamp round trip: nobody stamps by hand, and the stamp commit is made -and pushed by the tool. Steps, each journaled in +lane's stamp round trip. Nobody stamps by hand; the tool makes and pushes the +stamp commit. Steps, each journaled in `.gstack/tmp/ship-queue/land/.json` and resumable by rerunning the same command: -1. **Refuse** a fork head, a closed PR, a /ship receipt that is missing or +1. **Refuse** a fork head, a closed PR, a PR whose base is another branch, a /ship receipt that is missing or bound to another head (`QUEUE_RECEIPT_MISSING`, `QUEUE_RECEIPT_STALE`), and required checks that are not green at the pin. 2. **Lock.** Take the repository land lock, `refs/notes/gstack-land` (its own ref, never the trains notes ref), by exact lease. It records the PR, holder, phase and journal. A held lock is `QUEUE_LAND_LOCKED` naming the holder; `--break-lock` takes over a crashed holder's lock and is refused while that - holder is mid-push. + holder entered a push or a merge less than 10 minutes ago, and for a drift + lock. A refusal before the push releases the lock. 3. **Merge and stamp.** In a scratch worktree, merge the base into the pin. Conflicts in release fields only (the version file and `release_outputs` take the base's side, the CHANGELOG merges by section, a manifest or npm @@ -1232,7 +1233,8 @@ command: so a clean dependency edit survives) are resolved; anything else is `QUEUE_LAND_CONFLICT`, nothing pushed, back to the lane. A legacy pin that bumped VERSION itself has its release fields put back to its merge base - first and its entry re-headed `[Unreleased]`. A PR with no entry gets a + first and its entry re-headed `[Unreleased]` (a manifest version source + keeps its other edits; a symlinked release file refuses). A PR with no entry gets a fragment written from its title. Then `gstack-restamp --next` folds and allocates (`restamp_command` and `release_tool` need `--allow-repo-commands`, as everywhere). @@ -1246,17 +1248,20 @@ command: CI runs for real on the stamp commit in this mode. 6. **Merge.** Re-read the base first: if it moved, restamp from the new base and push again under the last-pushed lease, at most twice - (`QUEUE_LAND_BASE_BUSY` on a third move). Then - `gh pr merge --squash --match-head-commit ` and confirm the base's + (`QUEUE_LAND_BASE_BUSY` on a third move, which releases the lock). Then + `gh pr merge --squash --match-head-commit ` (`--merge` under + `history: merge-only`) and confirm the base's tree is the stamped tree on the base land stamped. The stamp date is taken - once and reused by every restamp. + once and reused by every restamp. A rerun after GitHub merged the PR + (an error reply, a crash) goes straight to that tree check. 7. **Drift.** GitHub's merge API cannot bind the base, so a merge made outside `land` can slip in between the check and the squash. The post-merge check then fails with `QUEUE_LAND_DRIFT` and keeps the lock. `land --pr --repair` opens a PR from the base whose only change is a fragment for what the outside merge brought (its unfolded fragments, moved, - or one entry naming its commits); /ship and land that PR, and its land takes - over the drift lock and releases it. + or one entry naming its commits) and records it on the lock; /ship and land + that PR, from any checkout, and its land takes over the drift lock and + releases it. A rerun of `--repair` finds the PR it already opened. `--dry-run` merges and stamps in a scratch worktree and prints the version and tree, taking no lock and pushing nothing. @@ -1265,7 +1270,7 @@ tree, taking no lock and pushing nothing. ### Migrating to fragments Before turning the key on, run `gstack-ship-queue land --census` (read-only). -It prints one line per open PR: fold its fragments, re-head a legacy +It prints one line per open PR, saying whether land would fold its fragments, re-head a legacy `[Unreleased]`, rewrite a tentative per-PR VERSION bump, resolve a release-field-only conflict, write an entry from the title, or refuse on a product conflict. Then set `changelog_fragments` and `min_gstack_version` in diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 8bf5d4b30..f9d6aabb8 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -4229,8 +4229,8 @@ the name already landed. Then rerun. race it for the next version. The line names the holder, its PR and phase. **Fix.** Wait for the holder to finish. If it crashed, rerun with -`--break-lock`; that is refused while the holder's last phase is a push less -than 10 minutes old. A lock in phase `drift` is released by landing its +`--break-lock`; that is refused while the holder entered a push or a merge +less than 10 minutes ago. A lock in phase `drift` is released by landing its repair PR ([QUEUE_LAND_DRIFT](#queue-land-drift)). @@ -4264,8 +4264,8 @@ merged outside `land`. the two allowed restamps, so `land` stopped instead of restamping again. **Fix.** Find what is merging outside `land` (every merge should go through -it), then rerun `gstack-ship-queue land --pr `; it resumes from its -journal. +it), then rerun `gstack-ship-queue land --pr `; the lock was released and +the rerun restamps from the pin. ### `QUEUE_TRAIN_MOVED` diff --git a/lib/changelog-fragments.ts b/lib/changelog-fragments.ts index d0ae4c2ab..5fb0a1000 100644 --- a/lib/changelog-fragments.ts +++ b/lib/changelog-fragments.ts @@ -58,8 +58,8 @@ export function parseFragment(rel: string, text: string): Fragment | FragmentErr * The fragments in `dir` of a tree-ish (a commit or tree id), or of the index when `treeish` is * null. `exclude` names a tree-ish whose fragments are not this change's own (the base branch, or a * stacked pin's parent): a path present there with the same blob is skipped, a path present there - * with other bytes is an error. With `changelog`, a fragment whose text is already in a released section of - * that file at `exclude` is an error too: a landed PR's fragment carried back in by stacked history. + * with other bytes is an error. With `changelog`, a fragment whose lines appear whole and in order in a released + * section of that file at `exclude` is an error too: a landed PR's fragment carried back in by stacked history. */ export function readFragments(cwd: string, treeish: string | null, dir: string, exclude?: string | null, changelog?: string | null): FragmentSet { const prefix = dir.replace(/\/+$/, '') + '/'; @@ -75,7 +75,11 @@ export function readFragments(cwd: string, treeish: string | null, dir: string, }; const excluded = new Map((exclude ? list(exclude) : []).map(e => [e.path, e.sha])); const shown = exclude && changelog ? git(cwd, ['show', `${exclude}:${changelog}`]) : null; - const released = shown?.ok ? parseChangelog(shown.out).sections.filter(s => !s.unreleased).map(s => s.lines.join('\n')).join('\n') : ''; + const released = shown?.ok ? parseChangelog(shown.out.replace(/\r\n/g, '\n')).sections.filter(s => !s.unreleased).map(s => s.lines) : []; + const isReleased = (body: string) => { + const want = body.split('\n'); + return released.some(lines => lines.some((_, i) => want.every((w, k) => lines[i + k] === w))); + }; const fragments: Fragment[] = []; const errors: FragmentError[] = []; for (const e of list(treeish).sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))) { @@ -94,7 +98,7 @@ export function readFragments(cwd: string, treeish: string | null, dir: string, if (!blob.ok) { err('blob unreadable'); continue; } const parsed = parseFragment(e.path, blob.out); if ('code' in parsed) errors.push(parsed); - else if (released && released.includes(parsed.body)) err(`its text is already released in ${changelog} on the base (a landed PR's fragment carried in by stacked history); delete it from this branch`); + else if (isReleased(parsed.body)) err(`its text is already released in ${changelog} on the base (a landed PR's fragment carried in by stacked history); delete it from this branch`); else fragments.push(parsed); } return { fragments, errors }; diff --git a/lib/ship-queue-land.ts b/lib/ship-queue-land.ts index 8cef29ecf..2a9eb8cd8 100644 --- a/lib/ship-queue-land.ts +++ b/lib/ship-queue-land.ts @@ -25,6 +25,14 @@ import { bindShippedReceipt } from './ship-receipt-depth'; export const LAND_LOCK_REF = 'refs/notes/gstack-land'; export const MAX_RESTAMPS = 2; const PUSH_STALE_MS = 10 * 60_000; +const MANIFESTS = ['package.json', 'package-lock.json', 'npm-shrinkwrap.json']; +const isSha = (s: string | undefined) => /^[0-9a-f]{40,64}$/.test(s ?? ''); +/** The first path segment of `rel` inside `wt` that is a symlink, or null: land writes only inside the checkout. */ +function linkedSegment(wt: string, rel: string): string | null { + const segs = rel.split('/'); + const at = segs.findIndex((_, i) => { try { return fs.lstatSync(path.join(wt, ...segs.slice(0, i + 1))).isSymbolicLink(); } catch { return false; } }); + return at < 0 ? null : segs.slice(0, at + 1).join('/'); +} export interface LandJournal { schema_version: 1; pr: string; branch: string; @@ -33,7 +41,7 @@ export interface LandJournal { drift_main?: string; repair_pr?: string; phase: 'locked' | 'stamped' | 'pushed' | 'merged' | 'landed' | 'drift'; } -export interface LandLock { pr: string; holder: string; started_at: string; phase: 'stamping' | 'pushing' | 'waiting' | 'merging' | 'drift'; journal: string } +export interface LandLock { pr: string; holder: string; started_at: string; phase: 'stamping' | 'pushing' | 'waiting' | 'merging' | 'drift'; journal: string; phase_at?: string; repair_pr?: string; drift_of?: string } export interface LandOptions { pr: string; repair?: boolean; dryRun?: boolean; noWait?: boolean; breakLock?: boolean; allowRepoCommands?: boolean; waitTimeoutMin?: number; now?: () => Date; holder?: string } const fail = (lines: string[], failure: QueueFailure, run: string): StepOutput => ({ ok: false, lines, failure, run }); @@ -56,6 +64,7 @@ function writeLock(root: string, expected: string, value: LandLock | { released: const blob = git(root, ['hash-object', '-w', '--stdin'], undefined, JSON.stringify(value, null, 2) + '\n').out; const tree = git(root, ['mktree'], undefined, `100644 blob ${blob}\tlock.json\n`).out; const commit = git(root, ['commit-tree', tree, ...(expected ? ['-p', expected] : []), '-m', 'released' in value ? `land lock released by ${value.by}` : `land lock #${value.pr} ${value.phase}`]).out; + if (![blob, tree, commit].every(isSha)) return { ok: false, sha: '' }; const push = git(root, ['push', '-q', `--force-with-lease=${LAND_LOCK_REF}:${expected}`, 'origin', `${commit}:${LAND_LOCK_REF}`]); if (push.ok) git(root, ['update-ref', LAND_LOCK_REF, commit]); return { ok: push.ok, sha: commit }; @@ -72,13 +81,14 @@ export function resolveReleaseConflicts(wt: string, conflicts: string[], policy: const stage = (n: number, rel: string) => git(wt, ['show', `:${n}:${rel}`]); for (const rel of conflicts) { const base = path.basename(rel); + if (linkedSegment(wt, rel)) { left.push(rel); continue; } if (policy.changelog && rel === policy.changelog) { const merged = mergeChangelogs(stage(2, rel).out + '\n', stage(3, rel).out + '\n'); if (merged === null) { left.push(rel); continue; } fs.writeFileSync(path.join(wt, rel), merged); - } else if (rel === versionRel || matchesAny(policy.release_outputs, rel)) { + } else if (!MANIFESTS.includes(base) && (rel === versionRel || matchesAny(policy.release_outputs, rel))) { if (!git(wt, ['checkout', '--theirs', '--', rel]).ok) { left.push(rel); continue; } - } else if (['package.json', 'package-lock.json', 'npm-shrinkwrap.json'].includes(base)) { + } else if (MANIFESTS.includes(base)) { const sides = [1, 2, 3].map(n => stage(n, rel)); if (sides.some(s => !s.ok)) { left.push(rel); continue; } const theirs = JSON.parse(sides[2]!.out); @@ -107,13 +117,15 @@ export function resolveReleaseConflicts(wt: string, conflicts: string[], policy: * values and re-head that entry `[Unreleased]`, committed in the scratch worktree, so the base merge * meets an unstamped change and the restamp allocates the version. Returns a note, or null. */ -function unstampLegacy(wt: string, policy: ShipPolicy, versionRel: string, mergeBase: string): string | null { +function unstampLegacy(wt: string, policy: ShipPolicy, versionRel: string, mergeBase: string): string | QueueFailure | null { const mb = versionAtRef(wt, mergeBase); const own = versionAtRef(wt, 'HEAD'); if (!('version' in mb) || !('version' in own) || mb.version === own.version) return null; - const touched = [versionRel]; - fs.writeFileSync(path.join(wt, versionRel), git(wt, ['show', `${mergeBase}:${versionRel}`]).out + '\n'); - for (const rel of ['package.json', 'package-lock.json', 'npm-shrinkwrap.json']) { + const link = [versionRel, ...MANIFESTS, ...(policy.changelog ? [policy.changelog] : [])].map(rel => linkedSegment(wt, rel)).find(Boolean); + if (link) return { code: 'QUEUE_LAND_CONFLICT', message: `${link} is a symlink at the pin; land rewrites release fields only inside the repository` }; + const touched = MANIFESTS.includes(path.basename(versionRel)) ? [] : [versionRel]; + if (touched.length) fs.writeFileSync(path.join(wt, versionRel), git(wt, ['show', `${mergeBase}:${versionRel}`]).out + '\n'); + for (const rel of MANIFESTS) { const before = git(wt, ['show', `${mergeBase}:${rel}`]); if (!before.ok || !fs.existsSync(path.join(wt, rel))) continue; const text = fs.readFileSync(path.join(wt, rel), 'utf8'); @@ -154,6 +166,7 @@ function buildStamp(ctx: QueueContext, policy: ShipPolicy, o: { pin: string; bas const at = versionAtRef(root, o.baseSha); const versionRel = 'rel' in at ? at.rel : 'VERSION'; const legacy = unstampLegacy(wt, policy, versionRel, git(root, ['merge-base', o.pin, o.baseSha]).out); + if (legacy && typeof legacy !== 'string') return legacy; if (legacy) lines.push(legacy); const merge = git(wt, ['merge', '--no-ff', '--no-commit', '-q', o.baseSha]); if (!merge.ok) { @@ -169,9 +182,8 @@ function buildStamp(ctx: QueueContext, policy: ShipPolicy, o: { pin: string; bas const cl = fs.existsSync(path.join(wt, policy.changelog)) ? fs.readFileSync(path.join(wt, policy.changelog), 'utf8') : ''; if (!own.errors.length && !own.fragments.length && !parseChangelog(cl).sections.some(s => s.unreleased)) { const rel = `${dir.replace(/\/+$/, '')}/land-${fragmentSlug(o.pr)}.md`; - const segs = rel.split('/').slice(0, -1); - const link = segs.findIndex((_, i) => { try { return fs.lstatSync(path.join(wt, ...segs.slice(0, i + 1))).isSymbolicLink(); } catch { return false; } }); - if (link >= 0) return { code: 'CHANGELOG_FRAGMENT_INVALID', message: `${segs.slice(0, link + 1).join('/')} is a symlink at the pin; land writes a fragment only inside the repository` }; + const link = linkedSegment(wt, rel); + if (link) return { code: 'CHANGELOG_FRAGMENT_INVALID', message: `${link} is a symlink at the pin; land writes a fragment only inside the repository` }; fs.mkdirSync(path.dirname(path.join(wt, rel)), { recursive: true }); fs.writeFileSync(path.join(wt, rel), `- ${o.title.replace(/\s+/g, ' ').trim()} (#${o.pr})\n`); git(wt, ['add', '--', rel]); @@ -183,7 +195,9 @@ function buildStamp(ctx: QueueContext, policy: ShipPolicy, o: { pin: string; bas if (r.status !== 'stamped' && r.status !== 'noop') return { code: r.code ?? 'QUEUE_LAND_CONFLICT', message: `restamp ${r.status}: ${r.message ?? ''}`, fix: r.fix }; lines.push(...r.stages.filter(s => s.status !== 'pending').map(s => `stage ${s.name}: ${s.status}${s.detail ? ` (${s.detail})` : ''}`)); git(wt, ['add', '-A']); - return { tree: git(wt, ['write-tree']).out, version: r.newVersion!, lines }; + const tree = git(wt, ['write-tree']).out; + if (!isSha(tree)) return { code: 'QUEUE_LAND_CONFLICT', message: 'cannot write the stamped tree in the scratch worktree' }; + return { tree, version: r.newVersion!, lines }; } finally { git(root, ['worktree', 'remove', '--force', wt]); fs.rmSync(wt, { recursive: true, force: true }); @@ -281,11 +295,13 @@ function repair(ctx: QueueContext, policy: ShipPolicy, j: LandJournal | null, fi if (!git(wt, ['commit', '-q', '--no-verify', '-m', `land: release entry for the merge outside land beside #${j.pr}`]).ok) return fail(lines, { code: 'QUEUE_NOT_READY', message: 'cannot commit the repair' }, run); const head = git(wt, ['rev-parse', 'HEAD']).out; const push = git(root, ['push', '-q', `--force-with-lease=refs/heads/${branch}:`, 'origin', `${head}:refs/heads/${branch}`]); - if (!push.ok) return fail(lines, { code: 'QUEUE_TRAIN_MOVED', message: `${branch} already exists on origin; open or land its PR` }, run); + if (!push.ok) lines.push(`branch: ${branch} already on origin (an earlier --repair pushed it); opening or finding its PR`); const pr = ctx.gh(['pr', 'create', '--base', ctx.base, '--head', branch, '--title', `Release entry for the merge outside land beside #${j.pr}`, '--body', `QUEUE_LAND_DRIFT repair (docs/troubleshooting.md#queue-land-drift). Outside commits: ${outside.join(', ') || 'unknown'}.`]); - const n = /\/pull\/(\d+)/.exec(pr.out)?.[1]; - if (!pr.ok || !n) return fail(lines, { code: 'QUEUE_NOT_READY', message: `gh pr create failed: ${(pr.err ?? pr.out).trim().slice(0, 160)}` }, run); + const n = /\/pull\/(\d+)/.exec(`${pr.out}\n${pr.err ?? ''}`)?.[1]; + if (!n) return fail(lines, { code: 'QUEUE_NOT_READY', message: `gh pr create failed: ${(pr.err ?? pr.out).trim().slice(0, 160)}` }, run); j.repair_pr = n; writeJsonAtomic(file, j); + const held = readLock(root); + if (held.lock?.pr === j.pr && held.lock.phase === 'drift') writeLock(root, held.sha, { ...held.lock, repair_pr: n }); return { ok: true, run, lines: [...lines, `REPAIR_PR: #${n} ${branch} ${head}`, `NEXT: run /ship on #${n}, then gstack-ship-queue land --pr ${n} (it takes over #${j.pr}'s drift lock and releases it)`] }; } finally { git(root, ['worktree', 'remove', '--force', wt]); @@ -307,10 +323,12 @@ export function land(ctx: QueueContext, o: LandOptions): StepOutput { const info = prInfo(ctx, o.pr); if ('code' in info) return fail([], info, run); if (info.isCrossRepository) return fail([], { code: 'QUEUE_TRAIN_UNTRUSTED', message: `#${o.pr} is a fork head; land stamps and pushes only crew branches` }, run); - if (info.state !== 'OPEN') return fail([], { code: 'QUEUE_NOT_READY', message: `#${o.pr} is ${info.state}` }, run); - if (info.baseRefName && info.baseRefName !== ctx.base) return fail([], { code: 'QUEUE_NOT_READY', message: `#${o.pr} targets ${info.baseRefName}, not ${ctx.base}; land merges only PRs on the base it stamps (retarget it, or land its parent first)` }, run); let j = readJson(file); - if (j && (j.phase === 'landed' || ![j.reviewed_pin, j.last_pushed_head, j.stamp_commit].includes(info.headRefOid))) j = null; + const mergedResume = info.state === 'MERGED' && !!j && ['pushed', 'merged', 'landed'].includes(j.phase) && j.stamp_commit === info.headRefOid; + if (info.state !== 'OPEN' && !mergedResume) return fail([], { code: 'QUEUE_NOT_READY', message: `#${o.pr} is ${info.state}` }, run); + if (info.baseRefName && info.baseRefName !== ctx.base) return fail([], { code: 'QUEUE_NOT_READY', message: `#${o.pr} targets ${info.baseRefName}, not ${ctx.base}; land merges only PRs on the base it stamps (retarget it, or land its parent first)` }, run); + if (j && !mergedResume && (j.phase === 'landed' || ![j.reviewed_pin, j.last_pushed_head, j.stamp_commit].includes(info.headRefOid))) j = null; + if (j && mergedResume) j.phase = 'merged'; const recovered = j ? null : stampPin(root, info.headRefName, info.headRefOid, o.pr); const pin = j?.reviewed_pin ?? recovered ?? info.headRefOid; const lines = [`LAND: #${o.pr} branch=${info.headRefName} pin=${pin}${j ? ` (resuming at ${j.phase})` : recovered ? ` (the head ${info.headRefOid.slice(0, 12)} is land's stamp commit; restamping from its pin)` : ''}`]; @@ -333,22 +351,24 @@ export function land(ctx: QueueContext, o: LandOptions): StepOutput { let { sha: lockSha, lock } = readLock(root); const drifted = lock && lock.pr !== o.pr && lock.phase === 'drift' ? lock.pr : null; const repairOf = drifted ? readJson(path.join(root, landJournalRel(drifted))) : null; - if (repairOf?.repair_pr === o.pr) lines.push(`LOCK: taking over #${lock!.pr}'s drift lock for its repair`); + const driftOf = drifted && (lock!.repair_pr === o.pr || repairOf?.repair_pr === o.pr) ? drifted : lock?.pr === o.pr ? lock.drift_of : undefined; + if (drifted && driftOf) lines.push(`LOCK: taking over #${drifted}'s drift lock for its repair`); else if (lock && lock.pr !== o.pr) { - const age = now().getTime() - Date.parse(lock.started_at); + const age = now().getTime() - Date.parse(lock.phase_at ?? lock.started_at); if (!o.breakLock) return fail(lines, { code: 'QUEUE_LAND_LOCKED', message: `#${lock.pr} holds the land lock (${lock.holder}, phase ${lock.phase}, since ${lock.started_at})` }, run); - if (lock.phase === 'drift') return fail(lines, { code: 'QUEUE_LAND_DRIFT', message: `#${lock.pr} holds a drift lock; --break-lock never breaks it, only its repair PR's land takes it over`, fix: `gstack-ship-queue land --pr ${lock.pr} --repair` }, run); - if (lock.phase === 'pushing' && age < PUSH_STALE_MS) return fail(lines, { code: 'QUEUE_LAND_LOCKED', message: `#${lock.pr}'s holder is mid-push (${Math.round(age / 1000)} s ago); --break-lock refused` }, run); + if (lock.phase === 'drift' || lock.drift_of) return fail(lines, { code: 'QUEUE_LAND_DRIFT', message: `#${lock.drift_of ?? lock.pr} holds a drift lock; --break-lock never breaks it, only its repair PR's land takes it over`, fix: `gstack-ship-queue land --pr ${lock.drift_of ?? lock.pr} --repair` }, run); + if ((lock.phase === 'pushing' || lock.phase === 'merging') && age < PUSH_STALE_MS) return fail(lines, { code: 'QUEUE_LAND_LOCKED', message: `#${lock.pr}'s holder is mid-${lock.phase === 'pushing' ? 'push' : 'merge'} (${Math.round(age / 1000)} s ago); --break-lock refused` }, run); lines.push(`LOCK: broke #${lock.pr}'s lock (${lock.holder}, phase ${lock.phase})`); } const setLock = (phase: LandLock['phase']): QueueFailure | null => { - const w = writeLock(root, lockSha, { pr: o.pr, holder, started_at: lock?.pr === o.pr ? lock.started_at : now().toISOString(), phase, journal: run }); + const next: LandLock = { pr: o.pr, holder, started_at: lock?.pr === o.pr ? lock.started_at : now().toISOString(), phase, journal: run, phase_at: now().toISOString(), ...(driftOf ? { drift_of: driftOf } : {}) }; + const w = writeLock(root, lockSha, next); if (!w.ok) return { code: 'QUEUE_LAND_LOCKED', message: 'the land lock moved under this run (another lander took it)' }; - lockSha = w.sha; lock = { pr: o.pr, holder, started_at: lock?.pr === o.pr ? lock.started_at : now().toISOString(), phase, journal: run }; + lockSha = w.sha; lock = next; return null; }; const save = () => writeJsonAtomic(file, j); - const failed = setLock(j?.phase === 'pushed' ? 'waiting' : 'stamping'); + const failed = setLock(j?.phase === 'pushed' ? 'waiting' : j?.phase === 'merged' ? 'merging' : 'stamping'); if (failed) return fail(lines, failed, run); if (!j) { j = { schema_version: 1, pr: o.pr, branch: info.headRefName, reviewed_pin: pin, last_pushed_head: recovered ? info.headRefOid : null, stamp_commit: null, stamp_tree: null, base_sha: null, version: null, date: null, restamps: 0, phase: 'locked' }; save(); } @@ -356,19 +376,24 @@ export function land(ctx: QueueContext, o: LandOptions): StepOutput { git(root, ['fetch', '-q', 'origin', ctx.base]); const baseNow = git(root, ['rev-parse', `origin/${ctx.base}`]).out; if (j.phase === 'pushed' && j.base_sha !== baseNow) { - if (j.restamps >= MAX_RESTAMPS) return fail(lines, { code: 'QUEUE_LAND_BASE_BUSY', message: `origin/${ctx.base} moved during CI ${j.restamps + 1} times` }, run); + if (j.restamps >= MAX_RESTAMPS) { + j.restamps = 0; save(); + if (!driftOf) writeLock(root, lockSha, { released: true, by: holder, at: now().toISOString() }); + return fail(lines, { code: 'QUEUE_LAND_BASE_BUSY', message: `origin/${ctx.base} moved during CI ${MAX_RESTAMPS + 1} times; the lock is released and a later run restamps from the pin` }, run); + } lines.push(`BASE_MOVED: ${j.base_sha?.slice(0, 12)} -> ${baseNow.slice(0, 12)}; restamping`); j.restamps += 1; j.phase = 'locked'; save(); } if (j.phase === 'locked') { const s = buildStamp(ctx, policy, { pin, baseSha: baseNow, pr: o.pr, title: info.title, allowRepoCommands: !!o.allowRepoCommands, now: j.date ? new Date(`${j.date}T12:00:00Z`) : now() }); if ('code' in s) { - if (repairOf?.repair_pr === o.pr) setLock('stamping'); + if (driftOf) setLock('stamping'); else writeLock(root, lockSha, { released: true, by: holder, at: now().toISOString() }); return fail([...lines], s, run); } lines.push(...s.lines); const commit = git(root, ['commit-tree', s.tree, '-p', pin, '-p', baseNow, '-m', `v${s.version} stamp: merge ${ctx.base} ${baseNow.slice(0, 12)} into #${o.pr} (land)`]).out; + if (!isSha(commit)) return fail(lines, { code: 'QUEUE_LAND_CONFLICT', message: 'cannot create the stamp commit; nothing pushed' }, run); Object.assign(j, { stamp_commit: commit, stamp_tree: s.tree, base_sha: baseNow, version: s.version, date: j.date ?? now().toISOString().slice(0, 10), phase: 'stamped' }); save(); lines.push(`STAMP_VERSION: ${s.version}`, `STAMP_COMMIT: ${commit} tree=${s.tree}`); @@ -413,7 +438,8 @@ export function land(ctx: QueueContext, o: LandOptions): StepOutput { return fail([...lines, `MAIN: ${tip} first=${main} tree=${tree} parent=${parent}`], { code: 'QUEUE_LAND_DRIFT', message: `origin/${ctx.base} ${main.slice(0, 12)} is not the stamped tree on ${j.base_sha?.slice(0, 12)} (a merge outside land slipped in)`, fix: `gstack-ship-queue land --pr ${o.pr} --repair opens the PR that gives the outside merge its own release; /ship and land it, which releases the lock` }, run); } j.phase = 'landed'; save(); - if (drifted && repairOf?.repair_pr === o.pr) writeJsonAtomic(path.join(root, landJournalRel(drifted)), { ...repairOf, phase: 'landed' }); + const driftJournal = driftOf ? readJson(path.join(root, landJournalRel(driftOf))) : null; + if (driftOf && driftJournal) writeJsonAtomic(path.join(root, landJournalRel(driftOf)), { ...driftJournal, phase: 'landed' }); const rel = writeLock(root, lockSha, { released: true, by: holder, at: now().toISOString() }); return { ok: true, run, lines: [...lines, `MERGED: #${o.pr} as ${main} (${j.version})`, `LANDED: origin/${ctx.base} tree ${tree} equals the stamped tree`, `LOCK: ${rel.ok ? 'released' : 'release push failed (the next land takes it over)'}`] }; } diff --git a/lib/version-gate-train.ts b/lib/version-gate-train.ts index 00f0e3333..9ef0790e5 100644 --- a/lib/version-gate-train.ts +++ b/lib/version-gate-train.ts @@ -116,9 +116,13 @@ export function classifyTrainHead(o: { repoRoot: string; baseRef: string; head: } else touched.push(...outputs); if (touched.length) { // A merge-step stamp commit has parents [pin, base]: the fragments it folded are the pin's own. - const pin = git(o.repoRoot, ['rev-parse', '--verify', '--quiet', `${o.head}^2`]) ? `${o.head}^1` : mergeBase; + const second = git(o.repoRoot, ['rev-parse', '--verify', '--quiet', `${o.head}^2`])?.trim(); + const pin = second ? `${o.head}^1` : mergeBase; + if (fragDir && (!second || git(o.repoRoot, ['merge-base', '--is-ancestor', second, base]) === null)) return { kind: 'invalid', reason: `under changelog_fragments only the merge step stamps (a commit with parents [pin, ${base}]); leave VERSION, package.json and the release outputs at the merge base's values and add a fragment` }; + const folded = fragDir ? readFragments(o.repoRoot, pin, fragDir, base) : null; + if (folded?.errors.length) return { kind: 'invalid', reason: `${folded.errors[0]!.path}: ${folded.errors[0]!.message}` }; const level = policy.bump === 'patch' ? 'patch' : 'micro'; - const expected = bumpVersion(baseVersion, fragDir ? effectiveBump(level, readFragments(o.repoRoot, pin, fragDir, base).fragments) : (policy.bump ?? 'patch')); + const expected = bumpVersion(baseVersion, folded ? effectiveBump(level, folded.fragments) : (policy.bump ?? 'patch')); return expected ? { kind: 'stamped', expected, fragments: !!fragDir } : { kind: 'invalid', reason: `${base}:VERSION is not a 4-part version` }; } diff --git a/test/changelog-fragments.test.ts b/test/changelog-fragments.test.ts index dab553f81..a35b99d90 100644 --- a/test/changelog-fragments.test.ts +++ b/test/changelog-fragments.test.ts @@ -177,3 +177,18 @@ describe('restamp: an explicit --bump', () => { expect(fs.existsSync(path.join(repo.work, 'changelog.d', 'feat-d.md'))).toBe(false); }); }); + +// /ship adversarial review (GSTA-154 PR A): the carried-back check matched substrings of released text. +describe('readFragments: text already released at the base', () => { + // Value: protects=a carried-back fragment refuses only when its whole lines are released; fails_when=a short entry that is a substring of an old line is refused; why_new=the land case checks only an exact carry; seam=none; no_claim=a reworded carry (not detected) + test('whole released lines refuse; a substring of a released line does not', () => { + const repo = fixture(); + const base = readFile(repo, 'CHANGELOG.md'); + const line = base.split('\n').find(l => l.startsWith('- '))!; + fragmentPr(repo, 'feat-r', { 'short.md': line.slice(0, 5) + '\n', 'carried.md': line + '\n' }); + const set = readFragments(repo.work, 'HEAD', 'changelog.d', 'origin/main', 'CHANGELOG.md'); + expect(set.fragments.map(f => f.path)).toEqual(['changelog.d/short.md']); + expect(set.errors.map(e => e.path)).toEqual(['changelog.d/carried.md']); + expect(set.errors[0]!.message).toContain('already released in CHANGELOG.md'); + }); +}); diff --git a/test/ship-queue-land.test.ts b/test/ship-queue-land.test.ts index ce2d2b43a..a54795fe4 100644 --- a/test/ship-queue-land.test.ts +++ b/test/ship-queue-land.test.ts @@ -46,7 +46,9 @@ function setup(policy: Record = POLICY): Fx { " if(head!==want){console.error('Head branch was modified');process.exit(1)}", " if(d.mergeRefuse){console.error('Pull request is not mergeable: the base branch policy prohibits the merge');process.exit(1)}", " if(d.driftOnMerge){const m=g('rev-parse','refs/heads/main');const t=g('rev-parse',m+'^{tree}');g('update-ref','refs/heads/main',g('commit-tree',t,'-p',m,'-m','outside merge'));d.driftOnMerge=false}", - " const main=g('rev-parse','refs/heads/main');const sq=g('commit-tree',g('rev-parse',want+'^{tree}'),'-p',main,'-m','squash #'+a[2]);g('update-ref','refs/heads/main',sq);if(d.outsideAfter){g('update-ref','refs/heads/main',g('commit-tree',g('rev-parse',sq+'^{tree}'),'-p',sq,'-m','outside after'));d.outsideAfter=false}d.mergeArgs=a.slice(3);p.state='MERGED';save();process.exit(0)}", + " const main=g('rev-parse','refs/heads/main');const sq=g('commit-tree',g('rev-parse',want+'^{tree}'),'-p',main,'-m','squash #'+a[2]);g('update-ref','refs/heads/main',sq);if(d.outsideAfter){g('update-ref','refs/heads/main',g('commit-tree',g('rev-parse',sq+'^{tree}'),'-p',sq,'-m','outside after'));d.outsideAfter=false}d.mergeArgs=a.slice(3);p.state='MERGED';save();if(d.mergeErrorAfter){d.mergeErrorAfter=false;save();console.error('HTTP 502: Bad Gateway');process.exit(1)}process.exit(0)}", + "if(a[0]==='pr'&&a[1]==='create'&&d.createFail){d.createFail=false;save();console.error('timeout');process.exit(1)}", + "if(a[0]==='pr'&&a[1]==='create'){const h=a[a.indexOf('--head')+1];const had=Object.entries(d.prs).find(([,p])=>p.headRefName===h&&p.state==='OPEN');if(had){console.error('a pull request for branch \"'+h+'\" into branch \"main\" already exists:\\nhttps://github.com/o/r/pull/'+had[0]);process.exit(1)}}", "if(a[0]==='pr'&&a[1]==='create'){const n=String(Math.max(0,...Object.keys(d.prs).map(Number))+1);d.prs[n]={state:'OPEN',headRefName:a[a.indexOf('--head')+1],isCrossRepository:false,title:a[a.indexOf('--title')+1],body:''};save();console.log('https://github.com/o/r/pull/'+n);process.exit(0)}", "console.error('gh shim: unsupported '+a.join(' '));process.exit(1);", '', @@ -184,6 +186,11 @@ describe('land: lock, races and refusals', () => { bump(2); expect(land(fx, ['--pr', '1']).status).toBe(EXIT.ok); bump(3); expect(land(fx, ['--pr', '1']).err).toContain('(QUEUE_LAND_BASE_BUSY)'); + fx.repo.git('fetch', '-q', 'origin', '+refs/notes/gstack-land:refs/notes/gstack-land'); + expect(show(fx, 'refs/notes/gstack-land:lock.json')).toContain('"released": true'); + const later = land(fx, ['--pr', '1']); + expect(later.err).toBe(''); + expect(later.out).toMatch(/^BASE_MOVED: /m); }, 90_000); test('a missing receipt refuses before the lock; a foreign push after the stamp is never overwritten', () => { @@ -541,3 +548,86 @@ describe('land: one pass, base, lock and history', () => { expect(JSON.parse(fs.readFileSync(fx.data, 'utf8')).mergeArgs).toContain('--merge'); }, 90_000); }); + +// /ship adversarial (Claude) and outside-voice (Codex) findings (GSTA-154 PR A): each case failed or was unreachable before its fix. +describe('land: merged-after-error, live locks, repair recovery, legacy release files', () => { + // Value: protects=a merge GitHub completed but reported as failed reconciles on rerun; fails_when=the rerun refuses "is MERGED" and the lock stays held; why_new=no case merged with an error; seam=none; no_claim=GitHub's real error shapes + test('gh pr merge errors after GitHub merged: the rerun verifies the tree and releases the lock', () => { + const fx = setup(); + openPr(fx, '20'); + land(fx, ['--pr', '20']); + fx.write(d => { d.mergeErrorAfter = true; }); + expect(land(fx, ['--pr', '20']).err).toContain('HTTP 502'); + const again = land(fx, ['--pr', '20']); + expect(again.err).toBe(''); + expect(again.out).toContain('LANDED: '); + expect(again.out).toContain('LOCK: released'); + openPr(fx, '24'); + expect(land(fx, ['--pr', '24']).status).toBe(EXIT.ok); + }, 90_000); + + // Value: protects=--break-lock never breaks a holder that entered merging moments ago; fails_when=two landers squash the same version; why_new=only the push phase was guarded; seam=none; no_claim=none beyond protects + test('--break-lock refuses a lock whose holder entered merging moments ago', () => { + const fx = setup(); + openPr(fx, '21'); openPr(fx, '22'); + land(fx, ['--pr', '21']); + fx.write(d => { d.mergeRefuse = true; }); + expect(land(fx, ['--pr', '21']).err).toContain('gh pr merge refused'); + expect(land(fx, ['--pr', '22', '--break-lock']).err).toMatch(/#21's holder is mid-merge \(\d+ s ago\); --break-lock refused.*\(QUEUE_LAND_LOCKED\)/); + }, 90_000); + + // Value: protects=--repair resumes after its branch was pushed but PR creation failed, and the repair PR takes over from the lock alone; fails_when=every retry fails the branch lease, or a clean checkout cannot take over the drift lock; why_new=the drift case keeps the journal and creates the PR first time; seam=none; no_claim=gh's exact "already exists" wording + test('--repair resumes after a failed PR create; the repair PR takes over the drift lock with no local journal', () => { + const fx = setup(); + openPr(fx, '23'); + land(fx, ['--pr', '23']); + fx.write(d => { d.driftOnMerge = true; }); + expect(land(fx, ['--pr', '23']).err).toContain('(QUEUE_LAND_DRIFT)'); + fx.write(d => { d.createFail = true; }); + expect(land(fx, ['--pr', '23', '--repair']).err).toContain('gh pr create failed'); + Bun.sleepSync(1100); + const rep = land(fx, ['--pr', '23', '--repair']); + expect(rep.err).toBe(''); + expect(rep.out).toContain('already on origin'); + const n = /REPAIR_PR: #(\d+) gstack-land-repair\/23/.exec(rep.out)!; + const head = remote(fx, 'gstack-land-repair/23'); + fx.write(d => { d.prs[n[1]!].body = shippedBody(n[1]!, head); }); + fs.rmSync(path.join(fx.repo.work, '.gstack', 'tmp', 'ship-queue', 'land', '23.json')); + expect(land(fx, ['--pr', n[1]!]).out).toContain("LOCK: taking over #23's drift lock for its repair"); + const merged = land(fx, ['--pr', n[1]!]); + expect(merged.status).toBe(EXIT.ok); + expect(merged.out).toContain('LOCK: released'); + }, 120_000); + + // Value: protects=land never writes through a PR-controlled symlink while unstamping a legacy pin; fails_when=a VERSION link chain overwrites a file outside the checkout, even in --dry-run; why_new=only the title fragment was guarded; seam=none; no_claim=Windows symlink semantics + test('a legacy pin whose VERSION is a symlink refuses in --dry-run and writes nothing outside', () => { + const fx = setup(); + const outside = path.join(fx.repo.root, 'outside.txt'); + fs.writeFileSync(outside, 'keep\n'); + openPr(fx, '25', { edit: w => { fs.rmSync(path.join(w, 'VERSION')); fs.symlinkSync('1.2.9.0', path.join(w, 'VERSION')); fs.symlinkSync(outside, path.join(w, '1.2.9.0')); } }); + const r = land(fx, ['--pr', '25', '--dry-run']); + expect(r.err).toMatch(/VERSION is a symlink at the pin.*\(QUEUE_LAND_CONFLICT\)/); + expect(fs.readFileSync(outside, 'utf8')).toBe('keep\n'); + }, 90_000); + + // Value: protects=a legacy pin in a repo whose version source is package.json keeps its dependency and script edits; fails_when=the whole manifest is reset to the merge base; why_new=every case used a VERSION file; seam=none; no_claim=lockfile shapes beyond package.json + test('version source package.json: a legacy pin keeps its dependency and script edits', () => { + const fx = setup(); + fx.repo.git('checkout', '-q', '-B', 'vp', 'origin/main'); + fs.mkdirSync(path.join(fx.repo.work, '.gstack'), { recursive: true }); + fs.writeFileSync(path.join(fx.repo.work, '.gstack', 'version-path'), 'package.json\n'); + const base = JSON.parse(readFile(fx.repo, 'package.json')); base.version = '1.2.3.0'; + fs.writeFileSync(path.join(fx.repo.work, 'package.json'), JSON.stringify(base, null, 2) + '\n'); + fx.repo.git('add', '-A'); fx.repo.git('commit', '-q', '-m', 'version from package.json'); fx.repo.git('push', '-q', 'origin', 'HEAD:refs/heads/main'); + fx.repo.git('fetch', '-q', 'origin'); + openPr(fx, '26', { edit: w => { const p = JSON.parse(fs.readFileSync(path.join(w, 'package.json'), 'utf8')); p.version = '1.2.9.0'; p.dependencies['right-pad'] = '2.0.0'; p.scripts = { ...p.scripts, build: 'node build.js' }; fs.writeFileSync(path.join(w, 'package.json'), JSON.stringify(p, null, 2) + '\n'); } }); + const r = land(fx, ['--pr', '26', '--dry-run']); + expect(r.err).toBe(''); + expect(r.out).toMatch(/^legacy: the pin stamped 1\.2\.9\.0 itself/m); + const tree = /^STAMP_TREE: ([0-9a-f]+)/m.exec(r.out)![1]!; + const pkg = JSON.parse(show(fx, tree + ':package.json')); + expect(pkg.dependencies['right-pad']).toBe('2.0.0'); + expect(pkg.scripts.build).toBe('node build.js'); + expect(pkg.version).toBe('1.2.4'); + }, 90_000); +}); diff --git a/test/version-gate-fragments.test.ts b/test/version-gate-fragments.test.ts index 33fd31779..8f65a748f 100644 --- a/test/version-gate-fragments.test.ts +++ b/test/version-gate-fragments.test.ts @@ -44,7 +44,12 @@ describe('version gate: changelog fragments', () => { write(r, 'VERSION', '1.2.9.0\n'); const early = run(r, commit(r, 'g')); expect(early.code).toBe(1); - expect(early.out).toContain('must carry v1.2.4.0'); + expect(early.out).toContain('under changelog_fragments only the merge step stamps'); + r.git('checkout', '-q', '-B', 'self', 'origin/main'); + write(r, 'VERSION', '1.2.4.0\n'); write(r, 'self.txt', 'self\n'); + const self = run(r, commit(r, 'a lane stamps the expected version itself, with no fragment')); + expect(self.code).toBe(1); + expect(self.out).toContain('under changelog_fragments only the merge step stamps'); r.git('checkout', '-q', '-B', 'h', 'origin/main'); write(r, 'h.txt', 'h\n'); const head = commit(r, 'h'); From ab72a9554a7b970f459b19792f755a2d1d486a62 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 07:20:18 +0000 Subject: [PATCH 13/31] feat(land): refuse a pin without a READY receipt; symlinked release files refuse before restamp Under changelog_fragments, gstack-ship-queue ready records a READY receipt (refs/notes/gstack-trains) once its pregate, full_e2e and /ship receipt checks pass, and land refuses a pin without one (fix: run gstack-ship-queue ready --pr N), --dry-run included (GStack Minder ruling, 2026-10-29). The merged tree's release files may not be symlinks, so restamp never writes outside the scratch worktree. --repair adopts an existing branch only when it holds land's repair commit. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 2 +- docs/ship-policy.md | 9 ++++++- lib/ship-queue-land.ts | 20 +++++++++++++-- lib/ship-queue.ts | 7 ++++++ test/ship-queue-land.test.ts | 47 ++++++++++++++++++++++++++++++++++- test/ship-queue-train.test.ts | 17 +++++++++++++ 6 files changed, 97 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68538aa8b..8a298b1e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ ### Added - **`changelog_fragments: { dir }`** (`lib/ship-policy.ts`, `lib/changelog-fragments.ts`): a PR adds `/.md` with its entry text and optional `bump: minor|major` front matter. Fragments are read as Git blobs only; a symlink, gitlink, nested path, empty or oversized fragment, one with a release heading, a reused name, or one whose text is already released on the base (a parent PR's fragment carried in by stacked history) is `CHANGELOG_FRAGMENT_INVALID` before any version is allocated. - **`gstack-restamp` folds fragments**: their bodies go above any legacy `[Unreleased]` section under one new heading, the files are deleted, and the highest declared bump wins over the policy's. The tree receipt's `changelog-fragments` rule accepts exactly that fold and nothing else. -- **`gstack-ship-queue land --pr `** (`lib/ship-queue-land.ts`): the per-PR merge step. It takes the land lock (`refs/notes/gstack-land`), checks the /ship receipt and checks at the pin, merges the base resolving release fields only (a clean dependency edit survives; a product conflict refuses), stamps, pushes a commit with parents [pin, base] under an exact lease, waits for checks, restamps if the base moved (at most twice), squashes with `--match-head-commit` (merges with `--merge` under `history: merge-only`) and confirms the base tree. A PR whose base is another branch refuses before the lock; a refusal before the push releases the lock. Every step is journaled and resumable, also from the stamp commit's pin when the local journal is gone. `--repair` opens the follow-up PR after `QUEUE_LAND_DRIFT`; `--census` prints what land would do with every open PR, writing nothing. +- **`gstack-ship-queue land --pr `** (`lib/ship-queue-land.ts`): the per-PR merge step. It takes the land lock (`refs/notes/gstack-land`), checks the /ship receipt, the READY receipt and checks at the pin, merges the base resolving release fields only (a clean dependency edit survives; a product conflict refuses), stamps, pushes a commit with parents [pin, base] under an exact lease, waits for checks, restamps if the base moved (at most twice), squashes with `--match-head-commit` (merges with `--merge` under `history: merge-only`) and confirms the base tree. A PR whose base is another branch refuses before the lock; a refusal before the push releases the lock. Every step is journaled and resumable, also from the stamp commit's pin when the local journal is gone. `--repair` opens the follow-up PR after `QUEUE_LAND_DRIFT`; `--census` prints what land would do with every open PR, writing nothing. - **/ship writes the fragment** (`lib/ship-policy.ts`, `ship/SKILL.md.tmpl`): under the key, `show-effective` prints a `ship: changelog_fragments` line, so Step 12 writes no version and Step 13 writes `/.md` (linted with `gstack-prose-lint --gate`) instead of an `[Unreleased]` section; the skill's policy paragraph now says its `ship:` lines bind Steps 12–16. - **`restamp_command` gets `GSTACK_RESTAMP_BASE_SHA`**, the release predecessor, so a repo counter such as a SAVE_VERSION computes predecessor + 1 from the base and never from the branch. - New result codes with troubleshooting anchors: `CHANGELOG_FRAGMENT_INVALID`, `QUEUE_LAND_LOCKED`, `QUEUE_LAND_CONFLICT`, `QUEUE_LAND_DRIFT`, `QUEUE_LAND_BASE_BUSY`. diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 749dcfa76..be2e10152 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -170,8 +170,12 @@ ci: green at (passed=31 failed=0 pending=0 skipped=2) PREGATE: pass tree= head= checks=6 gate: command="" full_e2e=no gate-ahead=none SHIP_RECEIPT: pr=42 head= … +READY_RECEIPT: /ship-queue/ready//42-.json (notes pushed to origin refs/notes/gstack-trains) ``` +Under `changelog_fragments`, a `READY_RECEIPT:` line follows, written only when +every check above passed; `land` refuses a pin without one. + CI's Version Gate (`scripts/compare-pr-version.ts`) accepts that head as unstamped when `VERSION` and `package.json`'s version match its merge base, the CHANGELOG change is confined to `[Unreleased]`, and any changed release output @@ -1217,7 +1221,10 @@ stamp commit. Steps, each journaled in `.gstack/tmp/ship-queue/land/.json` and resumable by rerunning the same command: -1. **Refuse** a fork head, a closed PR, a PR whose base is another branch, a /ship receipt that is missing or +1. **Refuse** a fork head, a closed PR, a PR whose base is another branch, a + pin without a READY receipt (`gstack-ship-queue ready --pr ` records one + on `refs/notes/gstack-trains` once its pregate, full_e2e and receipt checks + pass; land checks nothing weaker), a /ship receipt that is missing or bound to another head (`QUEUE_RECEIPT_MISSING`, `QUEUE_RECEIPT_STALE`), and required checks that are not green at the pin. 2. **Lock.** Take the repository land lock, `refs/notes/gstack-land` (its own diff --git a/lib/ship-queue-land.ts b/lib/ship-queue-land.ts index 2a9eb8cd8..63e04aad8 100644 --- a/lib/ship-queue-land.ts +++ b/lib/ship-queue-land.ts @@ -21,6 +21,7 @@ import { matchesAny, type ShipPolicy } from './ship-policy'; import { ciAtHead, loadBasePolicy, type QueueContext, type StepOutput } from './ship-queue'; import { QUEUE_DIR_REL, git, writeJsonAtomic, type QueueFailure } from './ship-queue-gate'; import { bindShippedReceipt } from './ship-receipt-depth'; +import { loadReady, rebuildFromNotes, repoIdentity } from './ship-queue-train-record'; export const LAND_LOCK_REF = 'refs/notes/gstack-land'; export const MAX_RESTAMPS = 2; @@ -191,6 +192,10 @@ function buildStamp(ctx: QueueContext, policy: ShipPolicy, o: { pin: string; bas } } if (!git(wt, ['commit', '-q', '--no-verify', '--allow-empty', '-m', `land #${o.pr}: merge origin/${ctx.base}`]).ok) return { code: 'QUEUE_LAND_CONFLICT', message: 'cannot commit the merge in the scratch worktree' }; + const exact = [versionRel, ...MANIFESTS, ...(policy.changelog ? [policy.changelog] : [])]; + const linked = git(wt, ['ls-files', '-s', '-z']).out.split('\0').filter(rec => rec.startsWith('120000 ')).map(rec => rec.slice(rec.indexOf('\t') + 1)) + .find(rel => exact.includes(rel) || matchesAny([...policy.release_outputs, ...policy.stamp_paths], rel)) ?? exact.map(rel => linkedSegment(wt, rel)).find(Boolean); + if (linked) return { code: 'QUEUE_LAND_CONFLICT', message: `${linked} is a symlink in the merged tree; the merge step writes release files only inside the repository` }; const r = restamp({ repoRoot: wt, base: ctx.base, next: true, policyFrom: 'base', allowRepoCommands: o.allowRepoCommands, dryRun: false, now: o.now, env: process.env }); if (r.status !== 'stamped' && r.status !== 'noop') return { code: r.code ?? 'QUEUE_LAND_CONFLICT', message: `restamp ${r.status}: ${r.message ?? ''}`, fix: r.fix }; lines.push(...r.stages.filter(s => s.status !== 'pending').map(s => `stage ${s.name}: ${s.status}${s.detail ? ` (${s.detail})` : ''}`)); @@ -295,14 +300,21 @@ function repair(ctx: QueueContext, policy: ShipPolicy, j: LandJournal | null, fi if (!git(wt, ['commit', '-q', '--no-verify', '-m', `land: release entry for the merge outside land beside #${j.pr}`]).ok) return fail(lines, { code: 'QUEUE_NOT_READY', message: 'cannot commit the repair' }, run); const head = git(wt, ['rev-parse', 'HEAD']).out; const push = git(root, ['push', '-q', `--force-with-lease=refs/heads/${branch}:`, 'origin', `${head}:refs/heads/${branch}`]); - if (!push.ok) lines.push(`branch: ${branch} already on origin (an earlier --repair pushed it); opening or finding its PR`); + let pushed = head; + if (!push.ok) { + git(root, ['fetch', '-q', 'origin', `refs/heads/${branch}`]); + const remote = git(root, ['log', '-1', '--format=%H%n%s', 'FETCH_HEAD']).out.split('\n'); + if (!isSha(remote[0]) || remote[1] !== `land: release entry for the merge outside land beside #${j.pr}`) return fail(lines, { code: 'QUEUE_TRAIN_MOVED', message: `cannot push ${branch}, and origin holds no repair commit there: ${push.err.split('\n')[0]}` }, run); + pushed = remote[0]!; + lines.push(`branch: ${branch} already on origin at ${pushed.slice(0, 12)} (an earlier --repair pushed it); opening or finding its PR`); + } const pr = ctx.gh(['pr', 'create', '--base', ctx.base, '--head', branch, '--title', `Release entry for the merge outside land beside #${j.pr}`, '--body', `QUEUE_LAND_DRIFT repair (docs/troubleshooting.md#queue-land-drift). Outside commits: ${outside.join(', ') || 'unknown'}.`]); const n = /\/pull\/(\d+)/.exec(`${pr.out}\n${pr.err ?? ''}`)?.[1]; if (!n) return fail(lines, { code: 'QUEUE_NOT_READY', message: `gh pr create failed: ${(pr.err ?? pr.out).trim().slice(0, 160)}` }, run); j.repair_pr = n; writeJsonAtomic(file, j); const held = readLock(root); if (held.lock?.pr === j.pr && held.lock.phase === 'drift') writeLock(root, held.sha, { ...held.lock, repair_pr: n }); - return { ok: true, run, lines: [...lines, `REPAIR_PR: #${n} ${branch} ${head}`, `NEXT: run /ship on #${n}, then gstack-ship-queue land --pr ${n} (it takes over #${j.pr}'s drift lock and releases it)`] }; + return { ok: true, run, lines: [...lines, `REPAIR_PR: #${n} ${branch} ${pushed}`, `NEXT: run /ship on #${n}, then gstack-ship-queue land --pr ${n} (it takes over #${j.pr}'s drift lock and releases it)`] }; } finally { git(root, ['worktree', 'remove', '--force', wt]); fs.rmSync(wt, { recursive: true, force: true }); @@ -336,6 +348,10 @@ export function land(ctx: QueueContext, o: LandOptions): StepOutput { const receipt = bindShippedReceipt(root, `origin/${ctx.base}`, info.body ?? '', o.pr, [pin]); lines.push(receipt.line, ...(receipt.depthLine ? [receipt.depthLine] : [])); if (!receipt.ok) return fail(lines, { code: receipt.code, message: receipt.message, fix: 'run /ship at the pinned head first' }, run); + if (!policy.changelog_fragments) return fail(lines, { code: 'QUEUE_NOT_READY', message: 'the base policy has changelog_fragments off; land merges only under the key, where ready records the READY receipt it checks' }, run); + const keys = repoIdentity(root).keys; + if (!loadReady(keys, o.pr, pin)) rebuildFromNotes(root, ctx.remote ?? 'origin'); + if (!loadReady(keys, o.pr, pin)) return fail(lines, { code: 'QUEUE_NOT_READY', message: `no READY receipt for #${o.pr} at the pin ${pin.slice(0, 12)}; ready checks the pregate and full_e2e evidence land merges on`, fix: `run gstack-ship-queue ready --pr ${o.pr}` }, run); if (!j) { const ci = ciAtHead(ctx, pin); lines.push(`CI: ${ci.line}`); diff --git a/lib/ship-queue.ts b/lib/ship-queue.ts index e4588ea13..68ae3cf26 100644 --- a/lib/ship-queue.ts +++ b/lib/ship-queue.ts @@ -39,6 +39,7 @@ import { type AttemptRecord, type QueueFailure, } from './ship-queue-gate'; import { wtreeFingerprint } from './script-invocation'; +import { nowIso, persist, readyPath, repoIdentity } from './ship-queue-train-record'; import { buildReceipt, type TreeReceipt } from './tree-receipt'; export type Gh = (args: string[]) => { ok: boolean; out: string; err?: string }; @@ -277,6 +278,12 @@ export function ready(ctx: QueueContext, o: { pr: string; pregate?: string; gate if (!pregateOk) return fail({ code: 'QUEUE_NOT_READY', message: `the pregate result is not a current pass: ${pregateLine}` }, lines, run); if (gate && !gate.ok) return fail({ code: 'QUEUE_GATE_MISSING', message: `full_e2e is set and no passing gate is bound to ${head.slice(0, 12)}: ${gate.line}` }, lines, run); if (!receipt.ok) return fail({ code: receipt.code, message: receipt.message }, lines, run); + if (!policy.policy.train && policy.policy.changelog_fragments) { + const repo = repoIdentity(ctx.repoRoot); + const now = nowIso(); + const p = persist(ctx.repoRoot, [{ schema: 1, kind: 'ready', repo_key: repo.key, pr: o.pr, head, base_sha: baseSha, ready_at: now, screen: { choice: 'no-screen', policy: null, nonce: null, run: null, merge_sha: null }, updated_at: now }], { remote: ctx.remote ?? 'origin' }); + lines.push(`READY_RECEIPT: ${readyPath(repo.key, o.pr, head)} (${p.detail})`); + } return { ok: true, lines, run }; } diff --git a/test/ship-queue-land.test.ts b/test/ship-queue-land.test.ts index a54795fe4..4d5cc9b71 100644 --- a/test/ship-queue-land.test.ts +++ b/test/ship-queue-land.test.ts @@ -15,6 +15,7 @@ import { BASE_POLICY, cleanup, makeFixtureRepo, readFile, runBin, type FixtureRe import { shippedBody } from './helpers/ship-queue-train-fixture'; import { resolveReleaseConflicts } from '../lib/ship-queue-land'; import { mergePolicy } from '../lib/ship-policy'; +import { persist, repoIdentity } from '../lib/ship-queue-train-record'; const POLICY = { ...BASE_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.92.0.0' }; const repos: FixtureRepo[] = []; @@ -58,7 +59,17 @@ function setup(policy: Record = POLICY): Fx { } /** A PR branch from origin/main, pushed, with a fragment (or none), and a /ship receipt for its head in the shim. */ -function openPr(fx: Fx, pr: string, opts: { fragment?: string | null; edit?: (work: string) => void; shipped?: boolean } = {}): string { +/** The READY receipt `gstack-ship-queue ready` records (a note on origin refs/notes/gstack-trains), written into the fixture's own state root. */ +function markReady(fx: Fx, pr: string, head: string): void { + const prior = process.env.GSTACK_STATE_ROOT; + process.env.GSTACK_STATE_ROOT = path.join(fx.repo.root, 'ready-state'); + try { + const now = new Date().toISOString(); + persist(fx.repo.work, [{ schema: 1, kind: 'ready', repo_key: repoIdentity(fx.repo.work).key, pr, head, base_sha: fx.repo.git('rev-parse', 'origin/main'), ready_at: now, screen: { choice: 'no-screen', policy: null, nonce: null, run: null, merge_sha: null }, updated_at: now }]); + } finally { if (prior === undefined) delete process.env.GSTACK_STATE_ROOT; else process.env.GSTACK_STATE_ROOT = prior; } +} + +function openPr(fx: Fx, pr: string, opts: { fragment?: string | null; edit?: (work: string) => void; shipped?: boolean; ready?: boolean } = {}): string { const { repo } = fx; repo.git('checkout', '-q', 'origin/main', '-B', 'pr-' + pr); fs.writeFileSync(path.join(repo.work, 'pr-' + pr + '.txt'), pr + '\n'); @@ -72,6 +83,7 @@ function openPr(fx: Fx, pr: string, opts: { fragment?: string | null; edit?: (wo repo.git('push', '-q', '-f', 'origin', 'HEAD:refs/heads/pr-' + pr); const head = repo.git('rev-parse', 'HEAD'); fx.write(d => { d.prs[pr] = { state: 'OPEN', headRefName: 'pr-' + pr, isCrossRepository: false, title: 'PR ' + pr + ' title', body: opts.shipped === false ? '' : shippedBody(pr, head) }; }); + if (opts.ready !== false) markReady(fx, pr, head); repo.git('checkout', '-q', '--detach', 'origin/main'); return head; } @@ -131,6 +143,7 @@ describe('land: fold, push, merge', () => { fx.repo.git('push', '-q', '-f', 'origin', 'HEAD:refs/heads/pr-3'); const head = fx.repo.git('rev-parse', 'HEAD'); fx.write(d => { d.prs['3'] = { state: 'OPEN', headRefName: 'pr-3', isCrossRepository: false, title: 'legacy', body: shippedBody('3', head) }; }); + markReady(fx, '3', head); const r = land(fx, ['--pr', '3']); expect(r.err).toBe(''); expect(r.out).toMatch(/^legacy: the pin stamped 1\.2\.9\.0 itself/m); @@ -236,6 +249,7 @@ describe('land: lock, races and refusals', () => { expect(show(fx, n[2] + ':changelog.d/repair-9.md')).toBe('- Changes merged outside land beside #9: outside merge'); expect(land(fx, ['--pr', n[1]!]).err).toContain('(QUEUE_RECEIPT_MISSING)'); fx.write(d => { d.prs[n[1]!].body = shippedBody(n[1]!, n[2]!); }); + markReady(fx, n[1]!, n[2]!); const pushed = land(fx, ['--pr', n[1]!]); expect(pushed.out).toContain("LOCK: taking over #9's drift lock for its repair"); const merged = land(fx, ['--pr', n[1]!]); @@ -516,6 +530,7 @@ describe('land: one pass, base, lock and history', () => { fx.repo.git('push', '-q', '-f', 'origin', 'HEAD:refs/heads/pr-11'); const head = fx.repo.git('rev-parse', 'HEAD'); fx.write(d => { d.prs['11'] = { state: 'OPEN', headRefName: 'pr-11', isCrossRepository: false, title: 'child', body: shippedBody('11', head) }; }); + markReady(fx, '11', head); fx.repo.git('checkout', '-q', '--detach', 'origin/main'); const r = land(fx, ['--pr', '11']); expect(r.status).toBe(EXIT.refused); @@ -592,6 +607,7 @@ describe('land: merged-after-error, live locks, repair recovery, legacy release const n = /REPAIR_PR: #(\d+) gstack-land-repair\/23/.exec(rep.out)!; const head = remote(fx, 'gstack-land-repair/23'); fx.write(d => { d.prs[n[1]!].body = shippedBody(n[1]!, head); }); + markReady(fx, n[1]!, head); fs.rmSync(path.join(fx.repo.work, '.gstack', 'tmp', 'ship-queue', 'land', '23.json')); expect(land(fx, ['--pr', n[1]!]).out).toContain("LOCK: taking over #23's drift lock for its repair"); const merged = land(fx, ['--pr', n[1]!]); @@ -610,6 +626,17 @@ describe('land: merged-after-error, live locks, repair recovery, legacy release expect(fs.readFileSync(outside, 'utf8')).toBe('keep\n'); }, 90_000); + // Value: protects=restamp never folds fragments through a PR-controlled symlinked CHANGELOG; fails_when=the fold rewrites a file outside the checkout, even in --dry-run; why_new=the legacy guard runs only when the pin bumped VERSION; seam=none; no_claim=Windows symlink semantics + test('a pin whose CHANGELOG is a symlink refuses before restamp and leaves the target untouched', () => { + const fx = setup(); + const outside = path.join(fx.repo.root, 'outside-changelog.md'); + fs.writeFileSync(outside, '# Changelog\n\n## [1.2.3.0] - 2026-10-01\n\n- base release\n'); + openPr(fx, '27', { edit: w => { fs.rmSync(path.join(w, 'CHANGELOG.md')); fs.symlinkSync(outside, path.join(w, 'CHANGELOG.md')); } }); + const r = land(fx, ['--pr', '27', '--dry-run']); + expect(r.err).toMatch(/CHANGELOG\.md is a symlink in the merged tree.*\(QUEUE_LAND_CONFLICT\)/); + expect(fs.readFileSync(outside, 'utf8')).toBe('# Changelog\n\n## [1.2.3.0] - 2026-10-01\n\n- base release\n'); + }, 90_000); + // Value: protects=a legacy pin in a repo whose version source is package.json keeps its dependency and script edits; fails_when=the whole manifest is reset to the merge base; why_new=every case used a VERSION file; seam=none; no_claim=lockfile shapes beyond package.json test('version source package.json: a legacy pin keeps its dependency and script edits', () => { const fx = setup(); @@ -631,3 +658,21 @@ describe('land: merged-after-error, live locks, repair recovery, legacy release expect(pkg.version).toBe('1.2.4'); }, 90_000); }); + +// GStack Minder ruling (2026-10-29, GSTA-154): standalone land checks the evidence ready enforces. +describe('land: the READY receipt', () => { + // Value: protects=land refuses a pin ready never accepted (pregate, full_e2e), before any lock or push; fails_when=land merges a head that skipped ready; why_new=every other case marks its PR ready; seam=none; no_claim=what ready itself checks (its own tests) + test('a pin with a /ship receipt but no READY receipt refuses, naming the ready command; nothing is locked or pushed', () => { + const fx = setup(); + const pin = openPr(fx, '28', { ready: false }); + for (const args of [['--pr', '28'], ['--pr', '28', '--dry-run']]) { + const r = land(fx, args); + expect(r.status).toBe(EXIT.refused); + expect(r.err).toMatch(/no READY receipt for #28 at the pin [0-9a-f]{12}.*fix: run gstack-ship-queue ready --pr 28 \(QUEUE_NOT_READY\)/); + } + expect(remote(fx, 'pr-28')).toBe(pin); + expect(fx.repo.git('ls-remote', 'origin', 'refs/notes/gstack-land')).toBe(''); + markReady(fx, '28', pin); + expect(land(fx, ['--pr', '28']).out).toContain('PUSHED: '); + }, 90_000); +}); diff --git a/test/ship-queue-train.test.ts b/test/ship-queue-train.test.ts index 15e24c277..0926bc00d 100644 --- a/test/ship-queue-train.test.ts +++ b/test/ship-queue-train.test.ts @@ -108,6 +108,23 @@ describe('the policy gate', () => { expect(flagged.err).toContain('--screen/--no-screen/--broad ignored: the base policy declares no train block'); expect(fs.existsSync(path.join(fx.state, 'ship-queue'))).toBe(false); }, 120_000); + + // Value: protects=under changelog_fragments ready records the READY receipt land checks, only after every ready check passed; fails_when=land has no proof of the pregate; why_new=the case above covers the key off; seam=none; no_claim=the notes push to a real GitHub remote + test('under changelog_fragments ready records a READY receipt; a failed pregate records none', () => { + const fx = make({ ...BASE_POLICY, changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.92.0.0' }); + const head = openPr(fx, '1'); + fx.write(d => { d.ci[head] = [{ status: 'completed', conclusion: 'success' }]; }); + const tree = runBin(fx.repo, 'gstack-wtree', []).out.trim(); + const pregate = path.join(fx.repo.root, 'pregate.json'); + fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree, head, verdict: 'fail', checks: [{ id: 'unit', status: 'fail' }] })); + expect(queue(fx, ['ready', '--pr', '1', '--pregate', pregate]).status).toBe(EXIT.refused); + expect(fs.existsSync(path.join(fx.state, 'ship-queue'))).toBe(false); + fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree, head, verdict: 'pass', checks: [{ id: 'unit', status: 'pass' }] })); + const ok = queue(fx, ['ready', '--pr', '1', '--pregate', pregate]); + expect(ok.status).toBe(EXIT.ok); + expect(ok.out).toMatch(new RegExp(`^READY_RECEIPT: .*ship-queue/ready/[^/]+/1-${head}\\.json \\(notes pushed to origin refs/notes/gstack-trains\\)$`, 'm')); + expect(fx.repo.git('ls-remote', 'origin', 'refs/notes/gstack-trains')).not.toBe(''); + }, 120_000); }); describe('ready under a train policy: the screen choice', () => { From d095dc2c813fcdd3b024b940966139148944db2a Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 07:29:17 +0000 Subject: [PATCH 14/31] fix(pregate): clear's stamp-only substitution applies the changelog_fragments fold; docs match land and ready gstack-pregate clear and gstack-tree-receipt now classify through one classifyFiles, so under changelog_fragments a receipt for the fragment head clears the stamped head when the stamp is exactly the fold. The ship-policy reference names ready's receipt push, land's own check polling, the key-off refusal and the /ship changelog_fragments line. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-ship-queue | 5 +-- docs/ship-policy.md | 21 ++++++++----- lib/pregate/remote-receipt.ts | 6 ++-- lib/tree-receipt.ts | 42 +++++++++++++++----------- test/pregate-remote-stamp-only.test.ts | 25 +++++++++++++++ 5 files changed, 70 insertions(+), 29 deletions(-) diff --git a/bin/gstack-ship-queue b/bin/gstack-ship-queue index b05dc7e4a..0584c3bba 100755 --- a/bin/gstack-ship-queue +++ b/bin/gstack-ship-queue @@ -21,7 +21,8 @@ * * 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, except under a + * gstack-restamp stays the primitive. Nothing here pushes, except land, ready + * under changelog_fragments (its READY receipt note), and under a * ship policy with a `train` block: merge trains push train/ branches * and the refs/notes/gstack-trains records, and dispatch through eval-bg. * Logic: lib/ship-queue.ts, lib/ship-queue-gate.ts, lib/ship-queue-train*.ts @@ -66,7 +67,7 @@ const HELP = `gstack-ship-queue — the shared merge queue's protocol steps (doc docs/ship-policy.md#run-a-merge-train); ready then takes --screen|--no-screen --base : base branch (default: origin/HEAD, else main). Nothing here pushes, except land (the stamp commit and the -refs/notes/gstack-land lock) and merge trains. +refs/notes/gstack-land lock), ready under changelog_fragments (its READY receipt note) and merge trains. ${exitTableHelp()}`; function flag(args: string[], name: string): string | undefined { diff --git a/docs/ship-policy.md b/docs/ship-policy.md index be2e10152..3792e7e43 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -149,7 +149,8 @@ ship receipt into the coordinator protocol, so nobody proves a stamp diff by hand. Lanes own their PRs and never merge; the coordinator owns the order and every merge. Each step ends in a line the other side acts on, then `GSTACK_RESULT: skill=ship-queue status=complete|refused run=`. Nothing -here pushes. +here pushes, except `ready` under `changelog_fragments` (its READY receipt goes +to `refs/notes/gstack-trains`) and [`land`](#land). **1. Lane: report ready (unstamped).** After the gate and pregate pass on the pushed head: @@ -784,6 +785,7 @@ which carry the workflow consequences the skill prose would otherwise spell out: | `ship: preregistration: …` | `gstack-evidence ancestor --from-policy --base ` must print only `ANCESTOR: ok` before push | | `ship: release_tool: …` | the command runs only inside `gstack-restamp` (staging worktree, outputs confined to `release_outputs`) | | `ship: review_scope …` | Step 9.4 item 0 runs `gstack-review-scope classify` on each pass's findings; see [review_scope](#review-scope) | +| `ship: changelog_fragments : …` | Step 12 writes no version; Step 13 writes the entry as `/.md` (entry text only, `bump:` front matter only when the change warrants more than the policy bump), never an `[Unreleased]` section, linted with `gstack-prose-lint --gate`; `gstack-ship-queue land` assigns the version; see [changelog fragments](#changelog-fragments) | The PR body's first section is the ship receipt (`gstack-ship-receipt write … --body`), written by the pr-body section of `/ship` after the body is composed. @@ -1209,8 +1211,9 @@ valid fragment (a legacy `[Unreleased]`-only entry passes during migration; a `[bot]` author, such as Dependabot, needs none). Other manifest edits, a dependency or a script, pass. A head made by the merge step, a commit whose parents are [pin, base], must carry the base's version plus the effective -bump of the fragments its pin adds. `gstack-ship-queue ready` applies the same -rule. +bump of the fragments its pin adds. Under a train policy `gstack-ship-queue +ready` applies the same rule itself; without one, `ready` requires the head's +checks green, Version Gate included. ## `gstack-ship-queue land`: the per-PR merge step @@ -1222,7 +1225,8 @@ stamp commit. Steps, each journaled in command: 1. **Refuse** a fork head, a closed PR, a PR whose base is another branch, a - pin without a READY receipt (`gstack-ship-queue ready --pr ` records one + base policy with `changelog_fragments` off (`QUEUE_NOT_READY`), a pin + without a READY receipt (`gstack-ship-queue ready --pr ` records one on `refs/notes/gstack-trains` once its pregate, full_e2e and receipt checks pass; land checks nothing weaker), a /ship receipt that is missing or bound to another head (`QUEUE_RECEIPT_MISSING`, `QUEUE_RECEIPT_STALE`), and @@ -1250,8 +1254,10 @@ command: keeps the reviewed pin apart from the last pushed head, so a rerun leases on what it pushed and never on a head someone else pushed (`QUEUE_TRAIN_MOVED`, never overwritten). -5. **Wait** for the PR's checks on the stamp commit with `gstack-pr-wait` - (`--no-wait` prints the command instead; `--timeout `, default 60). +5. **Wait** for the PR's checks on the stamp commit, polling every 30 seconds + with the same check read the pin passed (`--timeout `, default 60). `--no-wait` + stops after the push and prints `gstack-pr-wait --until green && + gstack-ship-queue land --pr ` to run instead. CI runs for real on the stamp commit in this mode. 6. **Merge.** Re-read the base first: if it moved, restamp from the new base and push again under the last-pushed lease, at most twice @@ -1311,7 +1317,8 @@ Hunks are classified per file type: the version file (old → new only), the manifest (`version` key only), npm and bun lockfiles (root `version` keys only, where `gstack-version-bump` writes; a dependency entry is `changed`), the CHANGELOG (a heading move or re-heading, compared as an order-independent -multiset of entries), `stamp_paths` and `release_outputs` (every +multiset of entries; under `changelog_fragments`, also exactly the deleted +fragments folded under the new heading, and those deletions are stamps), `stamp_paths` and `release_outputs` (every removed/added pair must be an exact `(was X)` / `since: X` / `vX` rewrite (a quote around X in the first two forms included) with X the gated version, or a newer version of the repo's width that the stamp_paths stage moves (a tentative `*_KEY_VERSION` written as the next diff --git a/lib/pregate/remote-receipt.ts b/lib/pregate/remote-receipt.ts index 701bcbdf4..1d5fa7810 100644 --- a/lib/pregate/remote-receipt.ts +++ b/lib/pregate/remote-receipt.ts @@ -12,7 +12,7 @@ * `substituteStampOnlyTree` is the one accepted substitution. The gated tree * and the head tree are diffed path by path, and every differing file is * classified by the tree receipt's own rule (lib/tree-receipt.ts - * `classifyFile`: the repo's `stamp_files` allowlist and `stamp_patterns` + * `classifyFiles`, the changelog_fragments fold included: the repo's `stamp_files` allowlist and `stamp_patterns` * templates from `.gstack/ship-policy.json` on the base branch, beside the * built-in version-file, manifest-version, lockfile-root-version and * changelog-heading forms). The receipt is accepted for the head only when @@ -33,7 +33,7 @@ import { spawnSync } from 'node:child_process'; import type { PregateReceiptSubstitution } from '../headless-artifacts'; import { parseUnifiedDiff, versionAtRef } from '../restamp'; import { isUnavailable, loadPolicyFromBase, SHIP_POLICY_PATH } from '../ship-policy'; -import { classifyFile } from '../tree-receipt'; +import { classifyFiles } from '../tree-receipt'; import { treeMismatch } from './checks/lanes'; /** What `clear` records when a remote_gate receipt from a stamp-only predecessor tree is accepted for the head (the pregate.json shape, lib/headless-artifacts.ts). */ @@ -103,7 +103,7 @@ export function substituteStampOnlyTree(repoRoot: string, base: string, gatedTre if (diff === null) return { problem: `${mismatch}; git diff ${short(gatedTree)} ${short(headTree)} failed`, fix: rerun }; const inventory = git(repoRoot, ['diff-tree', '-r', '-z', '--name-only', '--no-renames', '--ignore-submodules=none', '--end-of-options', gatedTree, headTree]); if (inventory === null) return { problem: `${mismatch}; git diff-tree ${short(gatedTree)} ${short(headTree)} failed`, fix: rerun }; - const files = parseUnifiedDiff(diff).map(f => classifyFile(repoRoot, f, gatedTree, headTree, loaded.policy, versionRel, oldV, newV)); + const files = classifyFiles(repoRoot, parseUnifiedDiff(diff), gatedTree, headTree, loaded.policy, versionRel, oldV, newV); if (!files.length) return { problem: `${mismatch}; the trees differ but git diff produced no file entry to classify`, fix: rerun }; const expected = new Set(inventory.split('\0').filter(Boolean)); const seen = new Set(files.map(f => f.path)); diff --git a/lib/tree-receipt.ts b/lib/tree-receipt.ts index 9285583f8..de94d0004 100644 --- a/lib/tree-receipt.ts +++ b/lib/tree-receipt.ts @@ -50,6 +50,30 @@ function blob(cwd: string, treeish: string, rel: string): string | null { return r.status === 0 ? r.stdout : null; } +/** + * Every file's verdict for one gated/current pair, with the changelog_fragments fold: a fragment deleted + * between the two is a stamp only when the CHANGELOG change is exactly those fragments folded under the new + * heading. gstack-tree-receipt and gstack-pregate clear both classify through this, so their rules agree. + */ +export function classifyFiles(repoRoot: string, fileDiffs: FileDiff[], gated: string, current: string, policy: ShipPolicy, versionRel: string, oldV: string | null, newV: string | null): FileVerdict[] { + const prefix = policy.changelog_fragments ? policy.changelog_fragments.dir.replace(/\/+$/, '') + '/' : null; + const folded = new Map(); + for (const f of fileDiffs) { + if (!prefix || f.status !== 'D' || f.binary || f.modeChanged || !f.path.startsWith(prefix) || f.path.slice(prefix.length).includes('/') || !f.path.endsWith('.md')) continue; + const text = blob(repoRoot, gated, f.path); + const parsed = text === null ? null : parseFragment(f.path, text); + if (parsed && !('code' in parsed)) folded.set(f.path, parsed); + } + const fragments = [...folded.values()].sort((a, b) => (a.path < b.path ? -1 : 1)); + const changelogVerdict = (f: FileDiff) => classifyFile(repoRoot, f, gated, current, policy, versionRel, oldV, newV, fragments); + const clDiff = fileDiffs.find(f => policy.changelog && f.path === policy.changelog); + const clStamp = !!clDiff && changelogVerdict(clDiff).verdict === 'stamp'; + return fileDiffs.map((f): FileVerdict => { + if (!folded.has(f.path)) return f === clDiff ? changelogVerdict(f) : classifyFile(repoRoot, f, gated, current, policy, versionRel, oldV, newV); + return clStamp ? { path: f.path, rule: 'changelog_fragments', verdict: 'stamp' } : { path: f.path, rule: 'changelog_fragments', verdict: 'changed', residual: `deleted without its text folded under the new heading in ${policy.changelog ?? 'the CHANGELOG'}` }; + }); +} + /** * Every removed/added pair differs only by stamp rewrites to the new version: * the old version, or a tentative one (newer than it) the stamp_paths stage @@ -147,23 +171,7 @@ export function buildReceipt(o: ReceiptOptions): TreeReceipt | { error: string; const oldV = 'version' in gv ? gv.version : null; const newV = 'version' in cv ? cv.version : null; const diffText = git(o.repoRoot, ['diff', '--unified=0', '--no-color', '--no-ext-diff', gatedHead, currentHead]) ?? ''; - const fileDiffs = parseUnifiedDiff(diffText); - const prefix = o.policy.changelog_fragments ? o.policy.changelog_fragments.dir.replace(/\/+$/, '') + '/' : null; - const folded = new Map(); - for (const f of fileDiffs) { - if (!prefix || f.status !== 'D' || f.binary || f.modeChanged || !f.path.startsWith(prefix) || f.path.slice(prefix.length).includes('/') || !f.path.endsWith('.md')) continue; - const text = blob(o.repoRoot, gatedHead, f.path); - const parsed = text === null ? null : parseFragment(f.path, text); - if (parsed && !('code' in parsed)) folded.set(f.path, parsed); - } - const fragments = [...folded.values()].sort((a, b) => (a.path < b.path ? -1 : 1)); - const changelogVerdict = (f: FileDiff) => classifyFile(o.repoRoot, f, gatedHead, currentHead, o.policy, versionRel, oldV, newV, fragments); - const clDiff = fileDiffs.find(f => o.policy.changelog && f.path === o.policy.changelog); - const clStamp = !!clDiff && changelogVerdict(clDiff).verdict === 'stamp'; - const files = fileDiffs.map((f): FileVerdict => { - if (!folded.has(f.path)) return f === clDiff ? changelogVerdict(f) : classifyFile(o.repoRoot, f, gatedHead, currentHead, o.policy, versionRel, oldV, newV); - return clStamp ? { path: f.path, rule: 'changelog_fragments', verdict: 'stamp' } : { path: f.path, rule: 'changelog_fragments', verdict: 'changed', residual: `deleted without its text folded under the new heading in ${o.policy.changelog ?? 'the CHANGELOG'}` }; - }); + const files = classifyFiles(o.repoRoot, parseUnifiedDiff(diffText), gatedHead, currentHead, o.policy, versionRel, oldV, newV); const residual = files.filter(f => f.verdict === 'changed').map(f => f.path); const stampFiles = files.filter(f => f.verdict === 'stamp').map(f => f.path); const stampDiff = stampFiles.length ? (git(o.repoRoot, ['diff', '--no-color', '--no-ext-diff', gatedHead, currentHead, '--', ...stampFiles]) ?? '') : ''; diff --git a/test/pregate-remote-stamp-only.test.ts b/test/pregate-remote-stamp-only.test.ts index fbeb5edc2..1a50a49b0 100644 --- a/test/pregate-remote-stamp-only.test.ts +++ b/test/pregate-remote-stamp-only.test.ts @@ -290,3 +290,28 @@ describe.skipIf(process.platform === 'win32')('clear --receipt accepts a stamp-o expect(pregateJson(repo).requires_remote[0].receipt).toBeNull(); }, 120_000); }); + +// /ship docs audit (GSTA-154 PR A): clear's substitution and gstack-tree-receipt share one classifier, fragment fold included. +describe.skipIf(process.platform === 'win32')('clear --receipt under changelog_fragments', () => { + // Value: protects=a receipt for the fragment head clears the stamped head when the stamp is exactly the fold; fails_when=clear treats the fold as a change and forces a second remote gate, or accepts a fold that dropped the text; why_new=every other case uses [Unreleased]; seam=none; no_claim=restamp's own fold (its tests) + test('the fold of a deleted fragment is a stamp; a fold that changed the text is not', () => { + const repo = makeFixtureRepo({ + pregate: { ...BASE_PREGATE, remote_gate: GATE }, + baseFiles: { '.gstack/ship-policy.json': JSON.stringify({ ...POLICY, changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.92.0.0' }, null, 2) + '\n', VERSION: '1.0.0\n', 'CHANGELOG.md': '# Changelog\n\n## [1.0.0] - 2026-10-01\n\n- base\n' }, + }); + repos.push(repo); + repo.git('checkout', '-q', 'feature'); + repo.git('merge', '-q', '--ff-only', 'main'); + const gated = commit(repo, { 'lib/util.js': 'exports.add = (a, b) => a + b;\n', 'changelog.d/add.md': '- add\n' }, 'change with a fragment'); + const gatedTree = repo.git('rev-parse', `${gated}^{tree}`); + const stampTo = (entry: string) => { + repo.git('checkout', '-q', '--detach', gated); + fs.rmSync(path.join(repo.work, 'changelog.d', 'add.md')); + return repo.git('rev-parse', `${commit(repo, { VERSION: '1.0.1\n', 'CHANGELOG.md': `# Changelog\n\n## [1.0.1] - 2026-10-29\n\n${entry}\n## [1.0.0] - 2026-10-01\n\n- base\n` }, 'v1.0.1 stamp')}^{tree}`); + }; + const ok = substituteStampOnlyTree(repo.work, 'main', gatedTree, stampTo('- add\n')); + expect('substitution' in ok && ok.substitution.files).toEqual(expect.arrayContaining([{ path: 'CHANGELOG.md', rule: 'changelog-fragments' }, { path: 'changelog.d/add.md', rule: 'changelog_fragments' }])); + const edited = substituteStampOnlyTree(repo.work, 'main', gatedTree, stampTo('- add, reworded\n')); + expect('problem' in edited && edited.problem).toContain('changelog.d/add.md (changelog_fragments: deleted without its text folded'); + }, 120_000); +}); From b12d406573aef97a24be230c3a087c81dedbf1a4 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 07:33:24 +0000 Subject: [PATCH 15/31] docs(pregate, ship-policy): the fragment fold's two receipt rules, and clear applies them too Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- docs/pregate.md | 7 +++++-- docs/ship-policy.md | 10 ++++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/pregate.md b/docs/pregate.md index 1e8698852..e9b059c20 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -447,7 +447,8 @@ receipt for H when the two trees differ only as a stamp does: stamps, each hunk in them must be the exact old → new rewrite under `stamp_patterns` (literal `{version}` templates) or the built-in forms (`(was X)`, `since: X`, `vX`), and the version file, `package.json`'s - `version`, a lockfile's root version keys and a CHANGELOG re-heading have + `version`, a lockfile's root version keys and a CHANGELOG re-heading (under + `changelog_fragments`, also the fold of the fragments the head deletes) have their own built-in rules. There is no second list and no looser rule here: the file verdicts `clear` reaches on a pair are the verdicts `gstack-tree-receipt --gated G --current H` reaches, and its gate rule holds @@ -466,7 +467,9 @@ receipt for H when the two trees differ only as a stamp does: the policy label (`origin/@:.gstack/ship-policy.json`) and every differing path with the rule that classed it (`version-file`, `version-manifest`, `manifest-version`, `lockfile-root-version`, - `changelog-heading`, `stamp_files`, `stamp_paths`, `release_outputs`). `clear` prints the same + `changelog-heading`, `changelog-fragments` for the CHANGELOG under the fold, + `changelog_fragments` for a folded fragment's deletion, `stamp_files`, + `stamp_paths`, `release_outputs`). `clear` prints the same as `PREGATE_RECEIPT_SUBSTITUTED: …`, `verify` repeats it on its ok line, and the ship receipt carries the receipt string. The receipt's own tests and failed checks apply as before (its exit status is checked only when diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 3792e7e43..075aabcb3 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -1198,10 +1198,12 @@ writes no version in Step 12 and writes Step 13's entry as **The fold.** `gstack-restamp` (and so `land`) concatenates the fragment bodies in path order above any legacy `[Unreleased]` section, re-heads both as one `## [] - ` section at the top, and deletes the fragment -files, in one stamp. The tree receipt's `changelog-fragments` rule calls a -deleted fragment a stamp only when its body appears verbatim, in order, among -the lines added under the new heading; an edited fold, or a fragment deleted -without its text, is a change. +files, in one stamp. The tree receipt, and so `gstack-pregate clear`'s +stamp-only substitution, calls a deleted fragment a stamp (rule +`changelog_fragments`) only when the new CHANGELOG section is exactly the +deleted fragments' bodies in path order above any legacy `[Unreleased]` text +(rule `changelog-fragments`); an edited fold, or a fragment deleted without +its text, is a change. **Version Gate.** The workflow runs on every PR (no path filter), so a PR that touches no release file still has to prove its entry. Under the key, an From dc309c793e74ba78638c0d303899f7da8507e848 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 04:37:05 +0000 Subject: [PATCH 16/31] feat(ship-queue): stamped train prefixes S_i with the self-check (PR B) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- lib/restamp.ts | 6 +- lib/result-codes-release.ts | 5 ++ lib/ship-queue-train-stamp.ts | 98 +++++++++++++++++++++++++++++ test/ship-queue-train-stamp.test.ts | 92 +++++++++++++++++++++++++++ 4 files changed, 198 insertions(+), 3 deletions(-) create mode 100644 lib/ship-queue-train-stamp.ts create mode 100644 test/ship-queue-train-stamp.test.ts diff --git a/lib/restamp.ts b/lib/restamp.ts index 69e2152a9..c9b4c7707 100644 --- a/lib/restamp.ts +++ b/lib/restamp.ts @@ -57,7 +57,7 @@ export interface RestampOptions { repoRoot: string; base: string; version?: string; next?: boolean; bump?: Bump; queueMode?: QueueMode; after?: { pr: string; ref?: string }; pr?: string; expectBase?: string; expectOrder?: string[]; was?: string; policyFrom: 'base' | 'head'; allowRepoCommands: boolean; dryRun: boolean; now?: Date; env?: NodeJS.ProcessEnv; - gh?: (args: string[]) => { ok: boolean; out: string }; failAfter?: string; gateWorktree?: string; + gh?: (args: string[]) => { ok: boolean; out: string }; failAfter?: string; gateWorktree?: string; baseRef?: string; } export interface RestampResult { status: 'stamped' | 'noop' | 'dry-run' | 'refused' | 'failed' | 'gate-ahead'; @@ -164,7 +164,7 @@ interface Plan { } function planVersions(o: RestampOptions, loaded: LoadedPolicy, policyLabel: string): Plan { - const baseRef = `origin/${o.base}`; + const baseRef = o.baseRef ?? `origin/${o.base}`; const baseSha = git(o.repoRoot, ['rev-parse', '--verify', '--quiet', `${baseRef}^{commit}`]); if (!baseSha.ok) throw new Refusal('POLICY_SOURCE_UNAVAILABLE', `${baseRef} is not a resolvable commit; nothing written`, { fix: `git fetch origin ${o.base} --depth=1` }); if (o.expectBase && !baseSha.out.startsWith(o.expectBase)) { @@ -655,7 +655,7 @@ function applyToBranch(o: RestampOptions, staging: string, wtree: string, files: function fragmentsOf(o: RestampOptions, dir: string, changelog: string | null): FragmentSet { const fp = wtreeFingerprint({ cwd: o.repoRoot }); if ('error' in fp) throw new Refusal('RESTAMP_CONFLICT', `cannot fingerprint the working tree (${fp.error}); nothing written`); - const set = readFragments(o.repoRoot, fp.tree, dir, `origin/${o.base}`, changelog); + const set = readFragments(o.repoRoot, fp.tree, dir, o.baseRef ?? `origin/${o.base}`, changelog); if (set.errors.length) throw new Refusal('CHANGELOG_FRAGMENT_INVALID', `${set.errors[0]!.path}: ${set.errors[0]!.message}; nothing written`); return set; } diff --git a/lib/result-codes-release.ts b/lib/result-codes-release.ts index e9551f80a..96a2f66ea 100644 --- a/lib/result-codes-release.ts +++ b/lib/result-codes-release.ts @@ -122,6 +122,11 @@ export const RELEASE_RESULT_CODES = { summary: 'the rebuild is not allowed: the bisect was not all green, the train was already rebuilt (or re-dispatched), or there is no bisect or INFRA record', fix: 'route the red cases to a repair lane; the train is rebuilt only once the repair merges (a changed train)', }, + QUEUE_TRAIN_STAMP_MISMATCH: { + anchor: 'queue-train-stamp-mismatch', + summary: 'a stamped train prefix S_i is not the merge of S_(i-1) and its pin modulo stamps, or differs from the plain prefix T_i outside release paths, or could not be stamped; nothing was pushed', + fix: 'read the named slot and files: a stamp hook writing product files is fixed in the hook; otherwise rebuild the train without that slot and report it', + }, QUEUE_TRAIN_TREE_MISMATCH: { anchor: 'queue-train-tree-mismatch', summary: 'main’s tree is neither the train’s prefix before this PR nor after it (modulo stamps), so the merge does not publish what the gate tested', diff --git a/lib/ship-queue-train-stamp.ts b/lib/ship-queue-train-stamp.ts new file mode 100644 index 000000000..04030db1a --- /dev/null +++ b/lib/ship-queue-train-stamp.ts @@ -0,0 +1,98 @@ +/** + * Stamped train prefixes (docs/ship-policy.md#run-a-merge-train, GSTA-154 PR B): under + * changelog_fragments a train is built twice. T_i is the plain chain buildChain makes (each pin merged + * onto the previous prefix, unversioned); S_i = stamp(merge(S_(i-1), pin_i)) is what slot i publishes, + * with S_0 = T_0 = the base. Each S_i is stamped by gstack-restamp with S_(i-1) as its base, so slot i + * folds only its own fragments (a stacked pin's parent fragments are already folded in S_(i-1)) and the + * versions run base+bump_1, +bump_2, ... in ORDER, every earlier release entry kept. Two checks run + * before anything is pushed: S_i is merge(S_(i-1), pin_i) modulo stamps (the tree receipt, which knows + * the fragment fold), and S_i equals T_i byte for byte outside release paths. Either failing is + * QUEUE_TRAIN_STAMP_MISMATCH. + */ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { parseChangelog } from './changelog-check'; +import { restamp, versionAtRef, versionFiles } from './restamp'; +import { matchesAny, type ShipPolicy } from './ship-policy'; +import { git, type QueueFailure } from './ship-queue-gate'; +import { mergeCommit } from './ship-queue-train'; +import type { TrainPrefix, TrainSlot } from './ship-queue-train-record'; +import { buildReceipt } from './tree-receipt'; + +export interface StampedPrefix { index: number; commit: string; tree: string; pr: string; version: string; date: string } +export interface StampChainOptions { + repoRoot: string; base: string; policy: ShipPolicy; policyLabel: string; baseSha: string; + slots: TrainSlot[]; plain: TrainPrefix[]; allowRepoCommands: boolean; now: Date; +} + +const IDENTITY = { 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' }; + +/** A path a stamp may write: version files, the CHANGELOG, fragments, release outputs, stamp paths and stamp files. */ +export function isReleasePath(root: string, policy: ShipPolicy, versionRel: string, rel: string): boolean { + const dir = policy.changelog_fragments?.dir.replace(/\/+$/, ''); + return versionFiles(root, versionRel).includes(rel) || rel === policy.changelog || (!!dir && rel.startsWith(dir + '/')) + || matchesAny(policy.release_outputs, rel) || matchesAny(policy.stamp_paths, rel) || matchesAny(policy.stamp_files, rel); +} + +/** + * S_i is merge(S_(i-1), pin_i) modulo stamps, keeps every release entry of S_(i-1) (merged's first parent) + * byte for byte, and equals T_i outside release paths. Null when all hold, else why not. + */ +export function stampMismatch(o: StampChainOptions, merged: string, stamped: string, plainTree: string): string | null { + const r = buildReceipt({ repoRoot: o.repoRoot, gated: merged, current: stamped, policy: o.policy, policyLabel: o.policyLabel }); + if ('error' in r) return r.error; + if (r.tree !== 'same-modulo-stamps') return `the stamp changed more than stamps: ${r.residual.slice(0, 8).join(', ')}`; + const cl = o.policy.changelog; + if (cl) { + const read = (rev: string) => git(o.repoRoot, ['show', `${rev}:${cl}`]); + const before = read(`${merged}^1`); + const after = read(stamped); + if (before.ok && !after.ok) return `the stamp removed ${cl}`; + const kept = new Map(parseChangelog(after.out).sections.filter(s => s.version).map(s => [s.version!, s.lines.join('\n').trimEnd()])); + const lost = parseChangelog(before.ok ? before.out : '').sections.filter(s => s.version && kept.get(s.version) !== s.lines.join('\n').trimEnd()); + if (lost.length) return `the stamp rewrote or dropped the earlier release entr${lost.length > 1 ? 'ies' : 'y'} ${lost.map(s => `[${s.version}]`).join(', ')} in ${cl}`; + } + const at = versionAtRef(o.repoRoot, stamped); + const versionRel = 'rel' in at ? at.rel : 'VERSION'; + const diff = git(o.repoRoot, ['diff', '--name-only', '--no-renames', plainTree, `${stamped}^{tree}`]); + if (!diff.ok) return `cannot compare with the plain prefix: ${diff.err.split('\n')[0]}`; + const beyond = diff.out.split('\n').filter(f => f && !isReleasePath(o.repoRoot, o.policy, versionRel, f)); + return beyond.length ? `differs from the plain prefix outside release paths: ${beyond.slice(0, 8).join(', ')}` : null; +} + +/** Build S_1..S_k. A conflict, a refused stamp or a failed check stops at that slot with nothing pushed. */ +export function stampChain(o: StampChainOptions): { stamped: StampedPrefix[]; lines: string[] } | QueueFailure { + const stamped: StampedPrefix[] = []; + const lines: string[] = []; + const date = o.now.toISOString().slice(0, 10); + for (const [i, slot] of o.slots.entries()) { + const pred = i === 0 ? o.baseSha : stamped[i - 1]!.commit; + const m = mergeCommit(o.repoRoot, pred, slot.head, o.policy, `train S${i + 1} (unstamped): merge #${slot.pr} at ${slot.head.slice(0, 12)}`); + if ('conflicts' in m) return { code: 'QUEUE_TRAIN_CONFLICT', message: `#${slot.pr} conflicts with S${i}: ${m.conflicts.join(', ')}` }; + const wt = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-train-stamp-')); + fs.rmSync(wt, { recursive: true, force: true }); + if (!git(o.repoRoot, ['worktree', 'add', '--detach', '-q', wt, m.commit]).ok) return { code: 'QUEUE_TRAIN_STAMP_MISMATCH', message: `cannot check out the merge for S${i + 1}` }; + let tree = ''; + let version = ''; + try { + const r = restamp({ repoRoot: wt, base: o.base, baseRef: pred, next: true, policyFrom: 'base', allowRepoCommands: o.allowRepoCommands, dryRun: false, now: new Date(`${date}T12:00:00Z`), env: process.env }); + if (r.status !== 'stamped') return { code: r.code ?? 'QUEUE_TRAIN_STAMP_MISMATCH', message: `S${i + 1} (#${slot.pr}): restamp ${r.status}: ${r.message ?? ''}`, fix: r.fix }; + git(wt, ['add', '-A']); + tree = git(wt, ['write-tree']).out; + version = r.newVersion!; + } finally { + git(o.repoRoot, ['worktree', 'remove', '--force', wt]); + fs.rmSync(wt, { recursive: true, force: true }); + } + const c = git(o.repoRoot, ['commit-tree', tree, '-p', pred, '-p', slot.head, '-m', `train S${i + 1}: #${slot.pr} v${version}`], { ...process.env, ...IDENTITY }); + if (!c.ok) return { code: 'QUEUE_TRAIN_STAMP_MISMATCH', message: `commit-tree failed for S${i + 1}: ${c.err.slice(0, 120)}` }; + const plain = o.plain[i + 1]; + const why = plain ? stampMismatch(o, m.commit, c.out, plain.tree) : `no plain prefix T${i + 1}`; + if (why) return { code: 'QUEUE_TRAIN_STAMP_MISMATCH', message: `S${i + 1} (#${slot.pr}): ${why}` }; + stamped.push({ index: i + 1, commit: c.out, tree, pr: slot.pr, version, date }); + lines.push(`S${i + 1}: #${slot.pr} v${version} ${c.out.slice(0, 12)} tree=${tree.slice(0, 12)} (merge of S${i} and the pin, modulo stamps; T${i + 1} outside release paths)`); + } + return { stamped, lines }; +} diff --git a/test/ship-queue-train-stamp.test.ts b/test/ship-queue-train-stamp.test.ts new file mode 100644 index 000000000..eb58f7a6d --- /dev/null +++ b/test/ship-queue-train-stamp.test.ts @@ -0,0 +1,92 @@ +/** + * Stamped train prefixes (lib/ship-queue-train-stamp.ts): S_i = stamp(merge(S_(i-1), pin_i)) keeps every + * earlier release, folds only slot i's own fragments (stacked pins included), runs the versions in ORDER + * with each slot's effective bump, and a tampered earlier entry or a product change is refused. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { mergePolicy } from '../lib/ship-policy'; +import { buildChain } from '../lib/ship-queue-train'; +import { stampChain, stampMismatch, type StampChainOptions } from '../lib/ship-queue-train-stamp'; +import type { TrainSlot } from '../lib/ship-queue-train-record'; +import { BASE_POLICY, cleanup, makeFixtureRepo, type FixtureRepo } from './helpers/restamp-fixture'; + +const POLICY = { ...BASE_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.92.0.0' }; +const repos: FixtureRepo[] = []; +afterEach(() => { while (repos.length) cleanup(repos.pop()!); }); + +function pin(r: FixtureRepo, name: string, from: string, fragment: string): string { + r.git('checkout', '-q', '-B', name, from); + fs.writeFileSync(path.join(r.work, name + '.txt'), name + '\n'); + fs.mkdirSync(path.join(r.work, 'changelog.d'), { recursive: true }); + fs.writeFileSync(path.join(r.work, 'changelog.d', name + '.md'), fragment); + r.git('add', '-A'); r.git('commit', '-q', '-m', name); + return r.git('rev-parse', 'HEAD'); +} +function chain(r: FixtureRepo, heads: Array<[string, string]>): StampChainOptions { + const baseSha = r.git('rev-parse', 'origin/main'); + const slots = heads.map(([pr, head]) => ({ pr, head, ready_at: '2026-10-29T00:00:00Z', screen: { mode: 'none' } }) as unknown as TrainSlot); + const policy = mergePolicy(POLICY, 'test').policy; + const built = buildChain(r.work, baseSha, slots, policy); + if (!('prefixes' in built)) throw new Error('chain conflicted'); + return { repoRoot: r.work, base: 'main', policy, policyLabel: 'test', baseSha, slots, plain: built.prefixes, allowRepoCommands: false, now: new Date('2026-10-29T12:00:00Z') }; +} +const show = (r: FixtureRepo, rev: string) => r.git('show', rev); + +describe('stamped train prefixes', () => { + test('independent pins: S2 keeps S1\'s release, versions run in order with each slot\'s bump, fragments are gone', () => { + const r = makeFixtureRepo({ policy: POLICY, prefix: 'train-stamp-' }); repos.push(r); + const a = pin(r, 'a', 'origin/main', '- a change\n'); + const b = pin(r, 'b', 'origin/main', '---\nbump: minor\n---\n- b change\n'); + const o = chain(r, [['1', a], ['2', b]]); + const out = stampChain(o); + if ('code' in out) throw new Error(out.message); + expect(out.stamped.map(s => s.version)).toEqual(['1.2.4.0', '1.3.0.0']); + const s2 = out.stamped[1]!.commit; + expect(show(r, s2 + ':VERSION')).toBe('1.3.0.0'); + const cl = show(r, s2 + ':CHANGELOG.md'); + expect(cl.indexOf('## [1.3.0.0] - 2026-10-29')).toBeLessThan(cl.indexOf('## [1.2.4.0] - 2026-10-29')); + expect(cl).toContain('- a change'); + expect(cl).toContain('- b change'); + expect(r.git('ls-tree', '-r', '--name-only', s2, '--', 'changelog.d')).toBe(''); + expect(r.git('rev-parse', s2 + '^1')).toBe(out.stamped[0]!.commit); + expect(r.git('rev-parse', s2 + '^2')).toBe(b); + }, 60_000); + + test('stacked pins: the child folds only its own fragment; the parent\'s entry appears once', () => { + const r = makeFixtureRepo({ policy: POLICY, prefix: 'train-stamp-' }); repos.push(r); + const p = pin(r, 'p', 'origin/main', '- parent change\n'); + const c = pin(r, 'c', p, '- child change\n'); + const out = stampChain(chain(r, [['1', p], ['2', c]])); + if ('code' in out) throw new Error(out.message); + const cl = show(r, out.stamped[1]!.commit + ':CHANGELOG.md'); + expect(cl.split('- parent change').length).toBe(2); + const top = cl.slice(cl.indexOf('## [1.2.5.0]'), cl.indexOf('## [1.2.4.0]')); + expect(top).toContain('- child change'); + expect(top).not.toContain('- parent change'); + }, 60_000); + + test('a tampered earlier version or a product change in S_i is refused', () => { + const r = makeFixtureRepo({ policy: POLICY, prefix: 'train-stamp-' }); repos.push(r); + const a = pin(r, 'a', 'origin/main', '- a change\n'); + const b = pin(r, 'b', 'origin/main', '- b change\n'); + const o = chain(r, [['1', a], ['2', b]]); + const out = stampChain(o); + if ('code' in out) throw new Error(out.message); + const s1 = out.stamped[0]!.commit; + const s2 = out.stamped[1]!; + const merged = r.git('commit-tree', r.git('merge-tree', '--write-tree', b, s1).split('\n')[0]!, '-p', s1, '-p', b, '-m', 'm'); + expect(stampMismatch(o, merged, s2.commit, o.plain[2]!.tree)).toBeNull(); + const tamper = (file: string, edit: (t: string) => string) => { + r.git('checkout', '-q', '-f', '--detach', s2.commit); + fs.writeFileSync(path.join(r.work, file), edit(fs.readFileSync(path.join(r.work, file), 'utf8'))); + r.git('add', '-A'); + return r.git('commit-tree', r.git('write-tree'), '-p', s1, '-p', b, '-m', 'tampered'); + }; + const older = tamper('CHANGELOG.md', t => t.replace('## [1.2.4.0]', '## [1.2.3.9]')); + expect(stampMismatch(o, merged, older, o.plain[2]!.tree)).toContain('the stamp rewrote or dropped the earlier release entry [1.2.4.0] in CHANGELOG.md'); + const product = tamper('a.txt', () => 'not a\n'); + expect(stampMismatch(o, merged, product, o.plain[2]!.tree)).toContain('a.txt'); + }, 60_000); +}); From ead4a7c2737f26854b93b69eaffa78d61a498ece Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 04:44:43 +0000 Subject: [PATCH 17/31] feat(ship-queue): train build stamps S_i under fragments; train land publishes S_i exactly; backstop covers exact trees (PR B) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- lib/ship-queue-land.ts | 2 +- lib/ship-queue-train-cli.ts | 13 +++ lib/ship-queue-train-land.ts | 91 ++++++++++++++++++++ lib/ship-queue-train-record.ts | 4 + lib/ship-queue-train-stamp.ts | 6 +- lib/ship-queue-train-verify.ts | 5 +- lib/ship-queue-train.ts | 11 ++- scripts/train-backstop.ts | 16 ++-- scripts/train-gate.ts | 13 +-- test/ship-queue-train-land.test.ts | 132 +++++++++++++++++++++++++++++ test/train-backstop.test.ts | 47 ++++++++++ 11 files changed, 321 insertions(+), 19 deletions(-) create mode 100644 lib/ship-queue-train-land.ts create mode 100644 test/ship-queue-train-land.test.ts diff --git a/lib/ship-queue-land.ts b/lib/ship-queue-land.ts index 63e04aad8..6d8142292 100644 --- a/lib/ship-queue-land.ts +++ b/lib/ship-queue-land.ts @@ -216,7 +216,7 @@ function prInfo(ctx: QueueContext, pr: string): PrInfo | QueueFailure { } /** Poll the stamp commit's checks (the same read as the pin's) until they are green, one is red, or the time runs out. */ -function waitChecks(ctx: QueueContext, sha: string, minutes: number): void { +export function waitChecks(ctx: QueueContext, sha: string, minutes: number): void { const deadline = Date.now() + minutes * 60_000; for (let ci = ciAtHead(ctx, sha); !ci.green && !ci.line.startsWith('red') && Date.now() < deadline; ci = ciAtHead(ctx, sha)) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 30_000); diff --git a/lib/ship-queue-train-cli.ts b/lib/ship-queue-train-cli.ts index 01fb58477..4e4111b35 100644 --- a/lib/ship-queue-train-cli.ts +++ b/lib/ship-queue-train-cli.ts @@ -19,6 +19,8 @@ import { trainForget } from './ship-queue-train-forget'; import { redactCause } from './ship-queue-train-record'; export const TRAIN_HELP = ` train build --prs @[,...] [--order ,...] [--urgent | --repair] [--dry-run [--stack-tests [--stack-tests-command ""]]] [--free-timeout ] + [--allow-repo-commands]: changelog_fragments trains stamp S_1..S_k with the + base policy's restamp_command or release_tool --order: explicit slot order, every PR in --prs once (default: screened first, then READY order) train build --train resume a train (push, free tests, dispatch) from its record @@ -27,6 +29,9 @@ export const TRAIN_HELP = ` train build --prs @[,...] [--order ,...] train status [--train ] never spends: rebuild records from refs/notes/gstack-trains, reconcile unconfirmed dispatches by nonce train verdict --train read the gate run and its record: GREEN, RED, INCOMPLETE or PENDING + train land --train --pr [--no-wait] [--timeout ] + (changelog_fragments trains) publish slot n's stamped prefix S_i: commit its tree on + [pin, main], push under the pin's lease, wait for checks, squash, confirm main is S_i train bisect --train --spend-cap [--dry-run] red cases on every prefix T0..Tk, resumable and capped train rebuild --train [--infra] the one rebuild an all-green bisect earns (or the one INFRA re-dispatch) @@ -105,6 +110,7 @@ export async function trainMain(ctx: QueueContext, args: string[]): Promise buildTrain(ctx, { pins: flag(rest, '--prs'), order: flag(rest, '--order'), resume: nonce, dryRun, urgent: rest.includes('--urgent'), repair: rest.includes('--repair'), freeTimeoutSec, pollMs: Number(process.env.GSTACK_SHIP_QUEUE_POLL_MS) || 30_000, stackTests: stackWords?.length ? stackWords : rest.includes('--stack-tests') ? true : undefined, + allowRepoCommands: rest.includes('--allow-repo-commands'), })); } if (sub === 'status') return guarded('train status', () => trainStatus(ctx, { train: nonce })); @@ -123,6 +129,13 @@ export async function trainMain(ctx: QueueContext, args: string[]): Promise trainWatch(ctx, { train: nonce, timeoutSec, pollMs: Number(process.env.GSTACK_SHIP_QUEUE_POLL_MS) || 30_000, freeTimeoutSec })); } if (sub === 'verdict') return guarded('train verdict', () => trainVerdict(ctx, { train: nonce })); + if (sub === 'land') { + const pr = flag(rest, '--pr'); + if (!pr || !/^\d+$/.test(pr)) return usage('train land needs --pr '); + const timeout = positive(flag(rest, '--timeout'), 60); + if (Number.isNaN(timeout)) return usage('--timeout needs minutes'); + return guarded('train land', async () => (await import('./ship-queue-train-land')).trainLand(ctx, { train: nonce, pr, noWait: rest.includes('--no-wait'), timeoutMin: timeout })); + } if (sub === 'rebuild') return guarded('train rebuild', () => trainRebuild(ctx, { train: nonce, infra: rest.includes('--infra') })); if (sub === 'bisect') { const cap = positive(flag(rest, '--spend-cap'), NaN); diff --git a/lib/ship-queue-train-land.ts b/lib/ship-queue-train-land.ts new file mode 100644 index 000000000..46bf53677 --- /dev/null +++ b/lib/ship-queue-train-land.ts @@ -0,0 +1,91 @@ +/** + * `train land --train --pr ` (docs/ship-policy.md#train-land, GSTA-154 PR B): the merge step for + * one slot of a stamped train (changelog_fragments). The train built S_1..S_k and gated S_k; slot i + * publishes S_i exactly. With main's tree equal to S_(i-1)'s (slot 1: the train base), land commits S_i's + * tree with parents [pin_i, main], labels the PR evals-no-debounce (a tool-made head gets no later push, + * so the quiet window protects nothing), pushes under --force-with-lease=:, waits for the + * PR's checks on that commit (CI runs live at the slot until GSTA-156's reuse is on main), merges with + * --match-head-commit and confirms main's tree is S_i. Any other main is the freeze path + * (QUEUE_TRAIN_TREE_MISMATCH), as for verify. Journaled beside the train record; a rerun resumes. + */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { ciAtHead, type QueueContext } from './ship-queue'; +import { git, writeJsonAtomic } from './ship-queue-gate'; +import { waitChecks } from './ship-queue-land'; +import { bindReceipt } from './ship-receipt'; +import { checkRun, prInfo } from './ship-queue-train-github'; +import { activeFreeze, attemptsOf, trainFile } from './ship-queue-train-record'; +import { fetchBase, fetchTrainBranch, findTrain, frozenLines, nextFor, outFailed, outOk, outPending, outRefused, trainEnv, type TrainOut } from './ship-queue-train'; +import { confirmMerged, stopped } from './ship-queue-train-verify'; + +const Q = 'gstack-ship-queue'; +interface SlotJournal { schema_version: 1; pr: string; pin: string; stamp_commit: string; base: string } + +export async function trainLand(ctx: QueueContext, o: { train: string; pr: string; noWait?: boolean; timeoutMin?: number }): Promise { + const fetchErr = fetchBase(ctx); + const env = trainEnv(ctx); + if ('code' in env) return outRefused(env, [], 'none', 'none'); + if (fetchErr) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: fetchErr }, [], 'none', 'none'); + const t = findTrain(env, o.train); + if (!t) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `no train record ${o.train} here or in refs/notes/gstack-trains` }, [], `${Q} train status`, 'none'); + const run = trainFile(t.nonce); + const i = t.slots.findIndex(s => s.pr === o.pr); + if (i < 0) return outRefused({ code: 'QUEUE_GATE_MISSING', message: `train ${t.nonce} does not include #${o.pr}` }, [], nextFor(t), run); + if (!t.stamped) return outRefused({ code: 'QUEUE_GATE_MISSING', message: `train ${t.nonce} was built without stamped prefixes; merge it with the slot stamp and ${Q} verify` }, [], `${Q} verify --pr ${o.pr} --train ${t.nonce}`, run); + const gate = attemptsOf(t).filter(a => a.purpose !== 'bisect').at(-1); + if (gate?.outcome !== 'GREEN' || !['green', 'merging', 'merged'].includes(t.state)) return outRefused({ code: 'QUEUE_GATE_MISSING', message: `train ${t.nonce} has no green record (state ${t.state}, last gate ${gate?.outcome ?? 'none'})` }, [], nextFor(t), run); + const check = checkRun(ctx, t.head, env.train.full_gate_check); + if (check?.conclusion !== 'success') return outRefused({ code: 'QUEUE_GATE_MISSING', message: `no successful ${env.train.full_gate_check} check run on the train head ${t.head.slice(0, 12)}` }, [], `${Q} train verdict --train ${t.nonce}`, run); + if (!fetchTrainBranch(env, t)) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `the train's prefix commits are not here and ${t.branch} could not be fetched` }, [], `${Q} train status --train ${t.nonce}`, run); + const freeze = activeFreeze(env.repo.keys); + if (freeze && freeze.repair?.train !== t.nonce) return outRefused({ code: 'QUEUE_TRAIN_FROZEN', message: `the queue is frozen (${freeze.reason}); only the admitted repair train merges` }, frozenLines(freeze), `${Q} train build --repair --prs @`, run); + + const root = ctx.repoRoot; + const s = t.stamped[i]!; + const pred = i === 0 ? { index: 0, tree: t.prefixes[0]!.tree } : t.stamped[i - 1]!; + const pin = t.slots[i]!.head; + const mainNow = () => { fetchBase(ctx); const sha = git(root, ['rev-parse', `${env.remote}/${ctx.base}`]).out; return { sha, tree: git(root, ['rev-parse', `${sha}^{tree}`]).out }; }; + const lines = [`TRAIN_LAND: ${t.nonce} slot ${i + 1}/${t.slots.length} #${o.pr} pin ${pin} -> S${s.index} v${s.version} tree ${s.tree}`]; + let main = mainNow(); + if (main.tree === s.tree) return confirmMerged(env, t, o.pr, main.sha, s.index, [...lines, `main: ${main.sha} is S${s.index} exactly`]); + if (main.tree !== pred.tree) return stopped(env, t, 'QUEUE_TRAIN_TREE_MISMATCH', `main ${main.sha.slice(0, 12)} is neither S${pred.index} nor S${s.index} exactly`, main.sha, lines, true); + + const file = path.join(path.dirname(run), `land-${o.pr}.json`); + let j: SlotJournal | null = null; + try { j = JSON.parse(fs.readFileSync(file, 'utf8')) as SlotJournal; } catch { j = null; } + const info = prInfo(ctx, o.pr); + if ('error' in info) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: info.error }, lines, `${Q} train land --train ${t.nonce} --pr ${o.pr}`, run); + if (info.isCrossRepository) return outRefused({ code: 'QUEUE_TRAIN_UNTRUSTED', message: `#${o.pr} is a fork head; train land pushes only crew branches` }, lines, 'none', run); + if (!j || j.base !== main.sha) { + if (info.headRefOid !== pin && info.headRefOid !== j?.stamp_commit) return stopped(env, t, 'QUEUE_TRAIN_MOVED', `#${o.pr}'s head is ${info.headRefOid.slice(0, 12)}, not its pin ${pin.slice(0, 12)}`, main.sha, lines, true); + const shipped = bindReceipt(info.body, o.pr, [pin]); + lines.push(shipped.line); + if (!shipped.ok) return outRefused({ code: shipped.code, message: shipped.message }, lines, `#${o.pr}'s lane runs /ship at its pin and READYs again`, run); + const commit = git(root, ['commit-tree', s.tree, '-p', pin, '-p', main.sha, '-m', `v${s.version} stamp: train ${t.nonce} S${s.index} (#${o.pr})`]).out; + const label = ctx.gh(['pr', 'edit', o.pr, '--add-label', 'evals-no-debounce']); + lines.push(`LABEL: evals-no-debounce ${label.ok ? 'added' : `not added (${(label.err ?? label.out).trim().slice(0, 80)}); the 10-minute quiet window applies`}`); + const lease = j?.stamp_commit && info.headRefOid === j.stamp_commit ? j.stamp_commit : pin; + const push = git(root, ['push', '-q', `--force-with-lease=refs/heads/${info.headRefName}:${lease}`, env.remote, `${commit}:refs/heads/${info.headRefName}`]); + if (!push.ok) return stopped(env, t, 'QUEUE_TRAIN_MOVED', `#${o.pr}'s branch moved off ${lease.slice(0, 12)} before the stamp push; train land never overwrites it`, main.sha, lines, true); + j = { schema_version: 1, pr: o.pr, pin, stamp_commit: commit, base: main.sha }; + fs.mkdirSync(path.dirname(file), { recursive: true }); + writeJsonAtomic(file, j); + lines.push(`PUSHED: ${commit} (tree S${s.index}, parents [pin, main ${main.sha.slice(0, 12)}]) to ${info.headRefName}`); + const next = `gstack-pr-wait ${o.pr} --until green && ${Q} train land --train ${t.nonce} --pr ${o.pr}`; + if (o.noWait) return outPending({ code: 'QUEUE_NOT_READY', message: `checks run on the stamp commit ${commit.slice(0, 12)}` }, lines, next, run); + waitChecks(ctx, commit, o.timeoutMin ?? 60); + } + const head = prInfo(ctx, o.pr); + if ('error' in head || head.headRefOid !== j.stamp_commit) return stopped(env, t, 'QUEUE_TRAIN_MOVED', `#${o.pr}'s head is not the stamp commit ${j.stamp_commit.slice(0, 12)}`, main.sha, lines, true); + const ci = ciAtHead(ctx, j.stamp_commit); + lines.push(`CI: ${ci.line}`); + if (!ci.green) return outPending({ code: 'QUEUE_NOT_READY', message: `checks on the stamp commit ${j.stamp_commit.slice(0, 12)} are not green` }, lines, `gstack-pr-wait ${o.pr} --until green && ${Q} train land --train ${t.nonce} --pr ${o.pr}`, run); + main = mainNow(); + if (main.sha !== j.base) return stopped(env, t, 'QUEUE_TRAIN_TREE_MISMATCH', `main moved to ${main.sha.slice(0, 12)} during the slot's checks (stamped on ${j.base.slice(0, 12)})`, main.sha, lines, true); + const m = ctx.gh(['pr', 'merge', o.pr, '--squash', '--match-head-commit', j.stamp_commit]); + if (!m.ok) return outFailed({ code: 'QUEUE_NOT_READY', message: `gh pr merge refused: ${(m.err ?? m.out).trim().slice(0, 200)}` }, lines, `${Q} train land --train ${t.nonce} --pr ${o.pr}`, run); + main = mainNow(); + if (main.tree !== s.tree) return stopped(env, t, 'QUEUE_TRAIN_TREE_MISMATCH', `after the merge main ${main.sha.slice(0, 12)} is not S${s.index} exactly`, main.sha, lines, true); + return confirmMerged(env, t, o.pr, main.sha, s.index, [...lines, `MERGED: #${o.pr} as ${main.sha}; main's tree is S${s.index} exactly`]); +} diff --git a/lib/ship-queue-train-record.ts b/lib/ship-queue-train-record.ts index 9b65c7619..bef44522c 100644 --- a/lib/ship-queue-train-record.ts +++ b/lib/ship-queue-train-record.ts @@ -34,12 +34,16 @@ export interface ScreenChoice { } export interface TrainSlot { pr: string; head: string; ready_at: string; screen: ScreenChoice } export interface TrainPrefix { index: number; commit: string; tree: string; pr: string | null } +/** A stamped prefix S_i (changelog_fragments trains, lib/ship-queue-train-stamp.ts): what slot i publishes, exactly. */ +export interface StampedPrefix { index: number; commit: string; tree: string; pr: string; version: string; date: string } export interface FreeRun { run: number | null; url: string | null; head_sha: string | null; conclusion: string | null; verdict: 'green' | 'red' | 'missing' | 'stale' | 'cancelled' | 'pending' } export interface TrainRecord { schema: 1; kind: 'train'; nonce: string; key: string; repo_key: string; repo: string; base: { ref: string; sha: string }; policy: string; slots: TrainSlot[]; held: string[]; prefixes: TrainPrefix[]; head: string; tree: string; branch: string; urgent: boolean; repair: boolean; explicit_order?: boolean; state: TrainState; free: FreeRun | null; attempts: string[]; merged: Array<{ pr: string; sha: string; tree: string; at: string }>; superseded_by: string | null; + /** S_1..S_k when the base policy declares changelog_fragments; head and tree are then S_k's. Absent on plain trains. */ + stamped?: StampedPrefix[]; effects: Array<{ at: string; what: string }>; error: string | null; created_at: string; updated_at: string; } export type AttemptPurpose = 'gate' | 'rebuild' | 'infra-redispatch' | 'bisect'; diff --git a/lib/ship-queue-train-stamp.ts b/lib/ship-queue-train-stamp.ts index 04030db1a..16554bd6f 100644 --- a/lib/ship-queue-train-stamp.ts +++ b/lib/ship-queue-train-stamp.ts @@ -17,10 +17,10 @@ import { restamp, versionAtRef, versionFiles } from './restamp'; import { matchesAny, type ShipPolicy } from './ship-policy'; import { git, type QueueFailure } from './ship-queue-gate'; import { mergeCommit } from './ship-queue-train'; -import type { TrainPrefix, TrainSlot } from './ship-queue-train-record'; +import type { StampedPrefix, TrainPrefix, TrainSlot } from './ship-queue-train-record'; import { buildReceipt } from './tree-receipt'; -export interface StampedPrefix { index: number; commit: string; tree: string; pr: string; version: string; date: string } +export type { StampedPrefix }; export interface StampChainOptions { repoRoot: string; base: string; policy: ShipPolicy; policyLabel: string; baseSha: string; slots: TrainSlot[]; plain: TrainPrefix[]; allowRepoCommands: boolean; now: Date; @@ -86,7 +86,7 @@ export function stampChain(o: StampChainOptions): { stamped: StampedPrefix[]; li git(o.repoRoot, ['worktree', 'remove', '--force', wt]); fs.rmSync(wt, { recursive: true, force: true }); } - const c = git(o.repoRoot, ['commit-tree', tree, '-p', pred, '-p', slot.head, '-m', `train S${i + 1}: #${slot.pr} v${version}`], { ...process.env, ...IDENTITY }); + const c = git(o.repoRoot, ['commit-tree', tree, '-p', pred, '-p', slot.head, '-m', `train S${i + 1}: merge #${slot.pr} at ${slot.head.slice(0, 12)} v${version}`], { ...process.env, ...IDENTITY }); if (!c.ok) return { code: 'QUEUE_TRAIN_STAMP_MISMATCH', message: `commit-tree failed for S${i + 1}: ${c.err.slice(0, 120)}` }; const plain = o.plain[i + 1]; const why = plain ? stampMismatch(o, m.commit, c.out, plain.tree) : `no plain prefix T${i + 1}`; diff --git a/lib/ship-queue-train-verify.ts b/lib/ship-queue-train-verify.ts index 4d75b33ce..927656c00 100644 --- a/lib/ship-queue-train-verify.ts +++ b/lib/ship-queue-train-verify.ts @@ -170,7 +170,7 @@ function verifiedElsewhere(env: TrainEnv, t: TrainRecord, mainSha: string): stri * plain refusal (nothing was published). A GitHub state that cannot be read * is neither: the verify refuses and asks to be rerun, never "nothing merged". */ -async function stopped(env: TrainEnv, t: TrainRecord, code: 'QUEUE_TRAIN_TREE_MISMATCH' | 'QUEUE_TRAIN_MOVED', message: string, mainSha: string, lines: string[], fetched: boolean): Promise { +export async function stopped(env: TrainEnv, t: TrainRecord, code: 'QUEUE_TRAIN_TREE_MISMATCH' | 'QUEUE_TRAIN_MOVED', message: string, mainSha: string, lines: string[], fetched: boolean): Promise { const run = trainFile(t.nonce); // One GitHub read per unverified slot decides both the freeze and its record. const states = new Map(t.slots.filter(s => !t.merged.some(m => m.pr === s.pr)).map(s => [s.pr, prState(env.ctx, s.pr)] as const)); @@ -197,6 +197,7 @@ export async function verifyTrain(ctx: QueueContext, o: { pr: string; train: str const run = trainFile(t.nonce); const i = t.slots.findIndex(s => s.pr === o.pr); if (i < 0) return outRefused({ code: 'QUEUE_GATE_MISSING', message: `train ${t.nonce} does not include #${o.pr}` }, [], nextFor(t), run); + if (t.stamped) return outRefused({ code: 'QUEUE_GATE_MISSING', message: `train ${t.nonce} has stamped prefixes; its slots publish S_i exactly through train land, never a hand stamp checked modulo stamps` }, [], `${Q} train land --train ${t.nonce} --pr ${o.pr}`, run); const gate = attemptsOf(t).filter(a => a.purpose !== 'bisect').at(-1); if (gate?.outcome !== 'GREEN' || !['green', 'merging', 'merged'].includes(t.state)) { return outRefused({ code: 'QUEUE_GATE_MISSING', message: `train ${t.nonce} has no green record (state ${t.state}, last gate ${gate?.outcome ?? 'none'})` }, [], nextFor(t), run); @@ -230,7 +231,7 @@ export async function verifyTrain(ctx: QueueContext, o: { pr: string; train: str `merge #${o.pr} with expected head ${head.slice(0, 12)} (gstack-restamp stamps at merge), then ${Q} verify --pr ${o.pr} --train ${t.nonce}`, run); } -function confirmMerged(env: TrainEnv, t: TrainRecord, pr: string, main: string, index: number, lines: string[]): TrainOut { +export function confirmMerged(env: TrainEnv, t: TrainRecord, pr: string, main: string, index: number, lines: string[]): TrainOut { const run = trainFile(t.nonce); if (!t.merged.some(m => m.pr === pr)) t.merged.push({ pr, sha: main, tree: git(env.ctx.repoRoot, ['rev-parse', `${main}^{tree}`]).out, at: nowIso() }); t.state = t.merged.length === t.slots.length ? 'merged' : 'merging'; diff --git a/lib/ship-queue-train.ts b/lib/ship-queue-train.ts index 5485f9b20..a55a36c2b 100644 --- a/lib/ship-queue-train.ts +++ b/lib/ship-queue-train.ts @@ -26,6 +26,7 @@ import { bindShippedReceipt } from './ship-receipt-depth'; import { git, sha256, withIndex, type QueueFailure } from './ship-queue-gate'; import { recordVerdict, screenVerdict, verdictLines } from './ship-queue-screen'; import { stackTestLine, stackTests } from './ship-queue-stack-tests'; +import { stampChain } from './ship-queue-train-stamp'; import { asRun, execFor, newestBranchRun, prInfo, remoteBranchSha, repoSlug, runView, checkRun } from './ship-queue-train-github'; import { TERMINAL_STATES, activeFreeze, attemptsOf, listTrains, loadBisect, loadReady, loadTrain, nowIso, persist, rebuildFromNotes, repoIdentity, trainFile, @@ -321,6 +322,8 @@ export interface BuildOptions { pins?: string; order?: string; resume?: string; dryRun: boolean; urgent: boolean; repair: boolean; freeTimeoutSec: number; pollMs: number; /** `--dry-run --stack-tests`: run each PR's new test files on its integrated prefix (lib/ship-queue-stack-tests.ts); the command's words when `--stack-tests-command` names one. */ stackTests?: string[] | true; + /** Stamping S_i runs the base policy's restamp_command or release_tool (changelog_fragments trains). */ + allowRepoCommands?: boolean; } /** `--order ,,...`: every pinned PR exactly once (a leading # allowed). */ @@ -412,13 +415,17 @@ async function newTrain(env: TrainEnv, o: BuildOptions): Promise<{ t: TrainRecor if (due && !o.dryRun) return outPending({ code: 'QUEUE_TRAIN_WAITING', message: `${order.train.length} of ${env.train.max_size} PRs; the oldest READY dispatches at ${due}` }, lines, `${Q} train build ${prsArgs(order.train, !!explicit)} (after ${due}, or with more PRs, or --urgent for one)`, 'none'); const live = listTrains(env.repo.keys).find(t => t.key === key && !TERMINAL_STATES.includes(t.state) && t.state !== 'red' && t.state !== 'unattributed'); if (live) return { t: Object.assign(live, { held: order.held.map(s => s.pr) }), lines }; - const head = chain.prefixes.at(-1)!; + const policy = env.policy.policy; + const stamped = policy.changelog_fragments ? stampChain({ repoRoot: env.ctx.repoRoot, base: env.ctx.base, policy, policyLabel: env.policy.source.label, baseSha, slots: order.train, plain: chain.prefixes, allowRepoCommands: !!o.allowRepoCommands, now: new Date(nowIso()) }) : null; + if (stamped && 'code' in stamped) return outFailed(stamped, lines, `${Q} train build ${prsArgs(order.train, !!explicit)} --allow-repo-commands (after the named slot is fixed or dropped)`, 'none'); + if (stamped) lines.push(...stamped.lines); + const head = stamped ? stamped.stamped.at(-1)! : chain.prefixes.at(-1)!; const nonce = makeNonce('gate', head.commit, baseSha, randomBytes(3).toString('hex'), { purpose: 'train' }); const now = nowIso(); return { lines, t: { schema: 1, kind: 'train', nonce, key, repo_key: env.repo.key, repo: env.repo.url, base: { ref: env.ctx.base, sha: baseSha }, policy: env.policy.source.label, slots: order.train, held: order.held.map(s => s.pr), prefixes: chain.prefixes, head: head.commit, tree: head.tree, branch: `train/${nonce}`, - urgent: o.urgent, repair: o.repair, ...(explicit ? { explicit_order: true } : {}), state: 'built', free: null, attempts: [], merged: [], superseded_by: null, effects: [], error: null, + urgent: o.urgent, repair: o.repair, ...(explicit ? { explicit_order: true } : {}), ...(stamped ? { stamped: stamped.stamped } : {}), state: 'built', free: null, attempts: [], merged: [], superseded_by: null, effects: [], error: null, created_at: now, updated_at: now, } }; } diff --git a/scripts/train-backstop.ts b/scripts/train-backstop.ts index 935353550..5f7a0db1a 100644 --- a/scripts/train-backstop.ts +++ b/scripts/train-backstop.ts @@ -53,8 +53,11 @@ function noteRecords(git: Git, ki }); } -/** The newest green-train record whose prefix trees include this digest, with its run; null when none. */ -export function coveringRecord(gh: GhRunner, repo: string, train: TrainPolicy, digest: string, lines: string[]): { run: Run; record: TrainRecord; index: number } | null { +/** + * The newest green-train record with a prefix covering main, with its run; null when none. A plain prefix T_i + * covers main's tree modulo stamps (its digest); a stamped prefix S_i covers only main's exact tree. + */ +export function coveringRecord(gh: GhRunner, repo: string, train: TrainPolicy, main: { digest: string | null; tree: string }, lines: string[]): { run: Run; record: TrainRecord; index: number; exact: boolean } | null { const runs = json<{ workflow_runs?: Run[] }>(gh(['api', '-X', 'GET', `repos/${repo}/actions/workflows/${train.gate_workflow}/runs`, '-f', 'event=workflow_dispatch', '-f', 'status=success', '-f', 'per_page=50'], GH_MS)); if (!runs) { lines.push('cannot list the train gate runs; the backstop gates main'); return null; } const trains = (runs.workflow_runs ?? []).map(run => ({ run, nonce: /\[eval-bg ([A-Za-z0-9._-]+)\]/.exec(run.display_title ?? '')?.[1] ?? '' })) @@ -69,7 +72,7 @@ export function coveringRecord(gh: GhRunner, repo: string, train: TrainPolicy, d try { record = file ? JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8')) as TrainRecord : null; } catch { record = null; } if (!record || record.kind !== 'train-gate' || record.verdict !== 'GREEN' || record.census?.complete !== true || record.nonce !== nonce || record.head_sha !== run.head_sha || !Array.isArray(record.prefixes)) continue; - const hit = record.prefixes.find(p => p.stamp_free_tree === digest); + const hit = record.prefixes.find(p => p.exact ? p.tree === main.tree : main.digest !== null && p.stamp_free_tree === main.digest); if (!hit) continue; const checks = json<{ check_runs?: Array<{ name?: string; conclusion?: string | null }> }>( gh(['api', `repos/${repo}/commits/${run.head_sha}/check-runs?check_name=${encodeURIComponent(train.full_gate_check)}`], GH_MS)); @@ -77,7 +80,7 @@ export function coveringRecord(gh: GhRunner, repo: string, train: TrainPolicy, d lines.push(`train ${nonce} (run ${run.id}) lists this tree but its ${train.full_gate_check} check run is not a success`); continue; } - return { run, record, index: hit.index }; + return { run, record, index: hit.index, exact: !!hit.exact }; } return null; } finally { @@ -150,9 +153,10 @@ export function runBackstop(o: BackstopOptions): { action: BackstopAction; lines } const digest = stampFreeTree(git, o.sha, o.repoRoot, loaded.policy); if (typeof digest !== 'string') { lines.push(`BACKSTOP: no tree modulo stamps (${digest.error}); gating main`); } - const covered = typeof digest === 'string' ? coveringRecord(gh, o.repo, train, digest, lines) : null; + const tree = git(['rev-parse', `${o.sha}^{tree}`]).out; + const covered = coveringRecord(gh, o.repo, train, { digest: typeof digest === 'string' ? digest : null, tree }, lines); if (covered) { - lines.push(`BACKSTOP: covered: main ${o.sha.slice(0, 12)} is T${covered.index} of green train ${covered.record.nonce} (run ${covered.run.id}, tree modulo stamps ${String(digest).slice(0, 12)}); no gate`); + lines.push(`BACKSTOP: covered: main ${o.sha.slice(0, 12)} is ${covered.exact ? `S${covered.index}` : `T${covered.index}`} of green train ${covered.record.nonce} (run ${covered.run.id}, ${covered.exact ? `exact tree ${tree.slice(0, 12)}` : `tree modulo stamps ${String(digest).slice(0, 12)}`}); no gate`); return done('covered'); } const owned = noteRecords(git, 'freeze', repoKey).find(f => !f.cleared_at && f.main.sha === o.sha && f.backstop); diff --git a/scripts/train-gate.ts b/scripts/train-gate.ts index b5b433feb..1613dfe2b 100644 --- a/scripts/train-gate.ts +++ b/scripts/train-gate.ts @@ -49,7 +49,7 @@ export const MAX_BISECT_CASES = 40; /** A full gate's recorded cost (MEASUREMENT.md: $90.93 to $94.70; lib/gate-cost.ts uses the same scale). */ export const FULL_GATE_USD = 93.14; /** T1's prefix commits: `train T: merge # at ` (lib/ship-queue-train.ts buildChain). */ -const PREFIX_SUBJECT = /^train T(\d+): merge #(\d+) at [0-9a-f]{12}$/; +const PREFIX_SUBJECT = /^train (?:T(\d+): merge #(\d+) at [0-9a-f]{12}|S(\d+): merge #(\d+) at [0-9a-f]{12} v[0-9.]+)$/; export type Git = (args: string[]) => { ok: boolean; out: string }; export const gitIn = (cwd: string): Git => (args) => { @@ -185,9 +185,10 @@ export function stampFreeTree(git: Git, commit: string, repoRoot: string, policy return createHash('sha256').update(lines.sort().join('\n')).digest('hex'); } -export interface TrainPrefixTree { index: number; commit: string; tree: string; stamp_free_tree: string } +/** `exact`: a stamped prefix S_i (changelog_fragments trains), which covers only its exact tree. */ +export interface TrainPrefixTree { index: number; commit: string; tree: string; stamp_free_tree: string; exact?: true } -/** T1..Tk from the train head, walking first parents over T1's prefix commits. */ +/** T1..Tk (or stamped S1..Sk, never mixed) from the train head, walking first parents over the prefix commits. */ export function trainPrefixes(git: Git, head: string, repoRoot: string, policy: ShipPolicy): TrainPrefixTree[] | { error: string } { const log = git(['log', '--first-parent', `--max-count=${MAX_PREFIXES}`, '--format=%H%x00%P%x00%s', head]); if (!log.ok) return { error: `cannot walk ${head.slice(0, 12)}'s first parents (${log.out.split('\n')[0]})` }; @@ -196,11 +197,13 @@ export function trainPrefixes(git: Git, head: string, repoRoot: string, policy: const [commit, parents, subject] = line.split('\0') as [string, string, string]; const m = PREFIX_SUBJECT.exec(subject ?? ''); if (!m || parents.split(' ').length !== 2) break; - const index = Number(m[1]); + const index = Number(m[1] ?? m[3]); + const exact = m[3] !== undefined; + if (found.length && exact !== !!found[0]!.exact) return { error: `${commit.slice(0, 12)} mixes stamped and plain prefixes` }; if (found.length && index !== found.at(-1)!.index - 1) return { error: `prefix T${index} follows T${found.at(-1)!.index} at ${commit.slice(0, 12)}` }; const digest = stampFreeTree(git, commit, repoRoot, policy); if (typeof digest !== 'string') return digest; - found.push({ index, commit, tree: git(['rev-parse', `${commit}^{tree}`]).out, stamp_free_tree: digest }); + found.push({ index, commit, tree: git(['rev-parse', `${commit}^{tree}`]).out, stamp_free_tree: digest, ...(exact ? { exact: true as const } : {}) }); } if (!found.length || found.at(-1)!.index !== 1) return { error: `${head.slice(0, 12)} is not a train head (no T1..Tk prefix chain)` }; return found.reverse(); diff --git a/test/ship-queue-train-land.test.ts b/test/ship-queue-train-land.test.ts new file mode 100644 index 000000000..e8f2d0b36 --- /dev/null +++ b/test/ship-queue-train-land.test.ts @@ -0,0 +1,132 @@ +/** + * Stamped trains (changelog_fragments; GSTA-154 PR B): train build makes S_1..S_k beside T_1..T_k and gates + * S_k; train land publishes each slot's S_i exactly (a tool-made stamp commit on [pin, main], pushed under + * the pin's lease, labelled evals-no-debounce), confirms main is S_i, and freezes on any other main. + * Fixture repos with a bare origin and the train gh shim, wrapped for pr edit, pr merge and the PR's checks. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { EXIT } from '../lib/headless-artifacts'; +import { cleanup } from './helpers/restamp-fixture'; +import { + GREEN_GATE, TRAIN_POLICY, line, nonceOf, openPr, queue, readyReceipt, trainFixture, trainJson, type TrainFixture, +} from './helpers/ship-queue-train-fixture'; + +const POLICY = { ...TRAIN_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.92.0.0' }; +const fixtures: TrainFixture[] = []; +afterEach(() => { while (fixtures.length) cleanup(fixtures.pop()!.repo); }); + +/** pr view reads the head from origin's branch; pr edit and pr merge act on origin; any commit without recorded CI reads green. */ +function wrap(fx: TrainFixture): void { + const dir = path.join(fx.repo.root, 'gh-land-wrap'); + fs.mkdirSync(dir, { recursive: true }); + const shim = path.join(fx.repo.root, 'gh-train-shim', 'shim.js'); + fs.writeFileSync(path.join(dir, 'gh'), [ + '#!/usr/bin/env node', + "const fs=require('fs');const cp=require('child_process');const D=process.env.LAND_WRAP_DATA;const a=process.argv.slice(2);", + "const d=JSON.parse(fs.readFileSync(D,'utf8'));const save=()=>fs.writeFileSync(D,JSON.stringify(d));", + "const g=(...x)=>cp.execFileSync('git',['--git-dir',d.origin,...x],{encoding:'utf8'}).trim();", + "const rev=r=>{try{return g('rev-parse','--verify','--quiet',r)}catch{return ''}};", + "if(a[0]==='pr'&&a[1]==='view'&&d.prs[a[2]]){const p=d.prs[a[2]];const h=rev('refs/heads/'+p.headRefName);if(h)p.headRefOid=h;save();console.log(JSON.stringify(p));process.exit(0)}", + "if(a[0]==='pr'&&a[1]==='edit'){(d.labels=d.labels||{})[a[2]]=a[a.indexOf('--add-label')+1];save();process.exit(0)}", + "if(a[0]==='pr'&&a[1]==='merge'){const p=d.prs[a[2]];const want=a[a.indexOf('--match-head-commit')+1];if(rev('refs/heads/'+p.headRefName)!==want){console.error('Head branch was modified');process.exit(1)}", + " const before=rev('main');let tree=rev(want+'^{tree}');", + " if(d.outsideOnMerge){const blob=cp.execFileSync('git',['--git-dir',d.origin,'hash-object','-w','--stdin'],{input:'outside\\n',encoding:'utf8'}).trim();", + " const ls=g('ls-tree',before+'^{tree}')+'\\n100644 blob '+blob+'\\toutside.txt';const ot=cp.execFileSync('git',['--git-dir',d.origin,'mktree'],{input:ls+'\\n',encoding:'utf8'}).trim();", + " g('update-ref','refs/heads/main',g('commit-tree',ot,'-p',before,'-m','outside'));tree=g('merge-tree','--write-tree','--merge-base='+before,rev('main'),want).split('\\n')[0];d.outsideOnMerge=false}", + " g('update-ref','refs/heads/main',g('commit-tree',tree,'-p',rev('main'),'-m','squash #'+a[2]));p.state='MERGED';save();process.exit(0)}", + "const paged=/commits\\/([0-9a-f]+)\\/(check-runs|status)\\?.*page=(\\d+)/.exec(a[1]||'');", + "if(a[0]==='api'&&paged&&!(d.ci||{})[paged[1]]&&!((d.statuses||{})[paged[1]])){const list=paged[2]==='check-runs'?[{name:'free-tests',status:'completed',conclusion:'success',id:1,completed_at:'2026-10-26T00:00:00Z'}]:[];const key=paged[2]==='check-runs'?'check_runs':'statuses';console.log(JSON.stringify(paged[3]==='1'?{total_count:list.length,state:'success',[key]:list}:{total_count:list.length,[key]:[]}));process.exit(0)}", + `const r=cp.spawnSync('node',[${JSON.stringify(shim)},D,...a],{stdio:'inherit'});process.exit(r.status??1);`, + '', + ].join('\n'), { mode: 0o755 }); + fx.env.PATH = `${dir}:${fx.env.PATH}`; + (fx.env as Record).LAND_WRAP_DATA = fx.dataFile; +} + +function fragmentPr(fx: TrainFixture, pr: string, fragment = `- #${pr} change\n`): string { + const head = openPr(fx, pr, { file: `changelog.d/pr-${pr}.md`, text: fragment, changelog: false }); + fx.repo.git('push', '-q', 'origin', `${head}:refs/heads/pr-${pr}`); + readyReceipt(fx, pr, head); + return head; +} +function stampedTrain() { + const fx = trainFixture(POLICY, 'gstack-train-land-'); fixtures.push(fx); wrap(fx); + const h1 = fragmentPr(fx, '1'); + const h2 = fragmentPr(fx, '2', '---\nbump: minor\n---\n- #2 change\n'); + fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; }); + const built = queue(fx, ['train', 'build', '--prs', `1@${h1},2@${h2}`]); + const nonce = nonceOf(built.out); + return { fx, h1, h2, built, nonce }; +} +const land = (fx: TrainFixture, nonce: string, pr: string) => queue(fx, ['train', 'land', '--train', nonce, '--pr', pr, '--no-wait']); +const originMain = (fx: TrainFixture) => fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', 'main'); + +describe('stamped trains: build and land', () => { + test('build records S1, S2 and gates S2; land publishes each slot exactly, with the label, and records the merge', () => { + const { fx, h1, h2, built, nonce } = stampedTrain(); + expect(built.status, built.err).toBe(EXIT.ok); + expect(built.out).toMatch(/^S1: #1 v1\.2\.4\.0 [0-9a-f]{12} /m); + expect(built.out).toMatch(/^S2: #2 v1\.3\.0\.0 [0-9a-f]{12} /m); + const t = trainJson(fx, nonce); + expect(t.stamped.map((s: any) => [s.pr, s.version])).toEqual([['1', '1.2.4.0'], ['2', '1.3.0.0']]); + expect(t.head).toBe(t.stamped[1].commit); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + + const verify = queue(fx, ['verify', '--pr', '1', '--train', nonce]); + expect(verify.err).toMatch(/has stamped prefixes.*\(QUEUE_GATE_MISSING\)/); + expect(line(verify.out, 'NEXT:')).toBe(`NEXT: gstack-ship-queue train land --train ${nonce} --pr 1`); + const early = land(fx, nonce, '2'); + expect(early.err).toMatch(/is neither S1 nor S2 exactly.*not frozen.*\(QUEUE_TRAIN_TREE_MISMATCH\)/); + const pushed = land(fx, nonce, '1'); + expect(pushed.out, pushed.err).toContain('LABEL: evals-no-debounce added'); + const stamp = /^PUSHED: ([0-9a-f]{40})/m.exec(pushed.out)![1]!; + expect(fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', 'refs/heads/pr-1')).toBe(stamp); + expect(fx.repo.git('--git-dir', fx.repo.origin, 'rev-list', '--parents', '-n', '1', stamp).split(' ').slice(1)).toEqual([h1, originMain(fx)]); + expect(fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', stamp + '^{tree}')).toBe(t.stamped[0].tree); + const merged = land(fx, nonce, '1'); + expect(merged.status, merged.err).toBe(EXIT.ok); + expect(merged.out).toContain("main's tree is S1 exactly"); + expect(fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', 'main^{tree}')).toBe(t.stamped[0].tree); + land(fx, nonce, '2'); + const second = land(fx, nonce, '2'); + expect(second.status, second.err).toBe(EXIT.ok); + expect(fx.repo.git('--git-dir', fx.repo.origin, 'show', 'main:VERSION')).toBe('1.3.0.0'); + expect(trainJson(fx, nonce)).toMatchObject({ state: 'merged' }); + expect(fx.read().prs['2']).toMatchObject({ state: 'MERGED' }); + expect((fx.read() as any).labels).toEqual({ 1: 'evals-no-debounce', 2: 'evals-no-debounce' }); + void h2; + }, 300_000); + + test('a foreign push to the PR branch is never overwritten; a merge outside the train freezes the queue', () => { + const { fx, nonce } = stampedTrain(); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + fx.repo.git('push', '-q', '-f', 'origin', 'origin/main:refs/heads/pr-1'); + const moved = land(fx, nonce, '1'); + expect(moved.err).toContain('(QUEUE_TRAIN_MOVED)'); + expect(fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', 'refs/heads/pr-1')).toBe(fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', 'main')); + + const second = stampedTrain(); + expect(queue(second.fx, ['train', 'verdict', '--train', second.nonce]).status).toBe(EXIT.ok); + land(second.fx, second.nonce, '1'); + expect(land(second.fx, second.nonce, '1').status).toBe(EXIT.ok); + land(second.fx, second.nonce, '2'); + second.fx.write(d => { (d as any).outsideOnMerge = true; }); + const drift = land(second.fx, second.nonce, '2'); + expect(drift.err).toContain('(QUEUE_TRAIN_TREE_MISMATCH)'); + expect(line(drift.out, 'QUEUE_TRAIN_FROZEN:')).toMatch(/^QUEUE_TRAIN_FROZEN: train \S+ merged #1/); + }, 300_000); + + test('a plain train refuses train land and keeps the slot-stamp path', () => { + const fx = trainFixture(TRAIN_POLICY, 'gstack-train-land-'); fixtures.push(fx); wrap(fx); + const h1 = openPr(fx, '1'); + readyReceipt(fx, '1', h1); + fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; }); + const nonce = nonceOf(queue(fx, ['train', 'build', '--prs', `1@${h1}`, '--urgent']).out); + expect(trainJson(fx, nonce).stamped).toBeUndefined(); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + const r = land(fx, nonce, '1'); + expect(r.err).toMatch(/was built without stamped prefixes.*\(QUEUE_GATE_MISSING\)/); + }, 180_000); +}); diff --git a/test/train-backstop.test.ts b/test/train-backstop.test.ts index b71244faf..ecdf6aeed 100644 --- a/test/train-backstop.test.ts +++ b/test/train-backstop.test.ts @@ -223,3 +223,50 @@ describe('backstop: debounce while a train merges', () => { expect(dispatches()).toEqual([]); }); }); + +describe('backstop: a stamped train (changelog_fragments) covers only exact trees', () => { + test('main equal to S1 is covered as S1; the same tree with another VERSION is gated', () => { + const fx = trainFixture(); + const { repo, main0, prs } = fx; + let parent = main0; + const stamped = prs.slice(0, 2).map((head, i) => { + repo.git('checkout', '-q', '-f', '--detach', parent); + repo.git('merge', '-q', '--no-ff', '--no-commit', head); + fs.writeFileSync(path.join(repo.work, 'VERSION'), `1.2.${4 + i}.0\n`); + repo.git('add', '-A'); + parent = repo.git('commit-tree', repo.git('write-tree'), '-p', parent, '-p', head, '-m', `train S${i + 1}: merge #${101 + i} at ${head.slice(0, 12)} v1.2.${4 + i}.0`); + repo.git('merge', '--abort'); + return parent; + }); + repo.git('checkout', '-q', '-f', 'main'); + const prefixes = trainPrefixes(gitIn(repo.work), parent, repo.work, loadPolicyFromHead(repo.work).policy); + if (!Array.isArray(prefixes)) throw new Error(prefixes.error); + expect(prefixes.map(p => [p.index, p.exact])).toEqual([[1, true], [2, true]]); + const record = { ...fx.record, head_sha: parent, tree: repo.git('rev-parse', `${parent}^{tree}`), prefixes }; + const push = (tree: string) => { + const sha = repo.git('commit-tree', tree, '-p', repo.git('rev-parse', 'main'), '-m', 'squash'); + repo.git('update-ref', 'refs/heads/main', sha); + repo.git('push', '-q', 'origin', 'main'); + return sha; + }; + const s1 = push(repo.git('rev-parse', `${stamped[0]}^{tree}`)); + const covered = backstop({ ...fx, record }, s1, fakeGh({ ...fx, record }, { record }).gh); + expect(covered.action, covered.lines.join('\n')).toBe('covered'); + expect(covered.lines.at(-1)).toContain(`is S1 of green train ${TRAIN_NONCE} (run 900, exact tree`); + repo.git('checkout', '-q', '-f', '--detach', stamped[0]!); + fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.9.0\n'); + repo.git('add', '-A'); + const moduloStamps = push(repo.git('write-tree')); + repo.git('checkout', '-q', '-f', 'main'); + const gated = backstop({ ...fx, record }, moduloStamps, fakeGh({ ...fx, record }, { record, gate: 'success' }).gh); + expect(gated.lines.join('\n')).toContain("no green-train record lists main's tree"); + expect(gated.action).not.toBe('covered'); + }); + + test('a chain that mixes stamped and plain prefixes is not a train head', () => { + const fx = trainFixture(); + const { repo, train, prs } = fx; + const s = repo.git('commit-tree', repo.git('rev-parse', `${train[0]}^{tree}`), '-p', train[0]!, '-p', prs[1]!, '-m', `train S2: merge #102 at ${prs[1]!.slice(0, 12)} v1.2.5.0`); + expect(trainPrefixes(gitIn(repo.work), s, repo.work, loadPolicyFromHead(repo.work).policy)).toEqual({ error: `${train[0]!.slice(0, 12)} mixes stamped and plain prefixes` }); + }); +}); From 7567f3c8d11cd6bfc306b7af3dbde3c44a125026 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 04:49:15 +0000 Subject: [PATCH 18/31] docs: stamped trains and train land; QUEUE_TRAIN_STAMP_MISMATCH anchor Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 17 +++++++++++------ docs/captain.md | 7 +++++++ docs/ship-policy.md | 39 +++++++++++++++++++++++++++++++++++++++ docs/troubleshooting.md | 14 ++++++++++++++ 4 files changed, 71 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a298b1e6..67cc56664 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,21 +5,26 @@ **A repository can now assign its version when a PR merges instead of inside the PR: PRs carry unversioned CHANGELOG fragments and `gstack-ship-queue land` stamps, pushes and merges.** Today every PR picks a version before it merges, so each merge makes the next PR's version stale and its lane redoes the stamp (a median 14.5 minutes from stamp commit to merge over 30 slots, Oct 27-29; the queue froze on hand-made stamps in #3422 and #3487). With `changelog_fragments` on, two PRs cut from one base cannot collide, and nobody stamps by hand. gstack's own policy does not turn it on in this release (docs/ship-policy.md#changelog-fragments). ### Added -- **`changelog_fragments: { dir }`** (`lib/ship-policy.ts`, `lib/changelog-fragments.ts`): a PR adds `/.md` with its entry text and optional `bump: minor|major` front matter. Fragments are read as Git blobs only; a symlink, gitlink, nested path, empty or oversized fragment, one with a release heading, a reused name, or one whose text is already released on the base (a parent PR's fragment carried in by stacked history) is `CHANGELOG_FRAGMENT_INVALID` before any version is allocated. +- **`changelog_fragments: { dir }`** (`lib/ship-policy.ts`, `lib/changelog-fragments.ts`): a PR adds `/.md` with its entry text and optional `bump: minor|major` front matter. Fragments are read as Git blobs only; a symlink, gitlink, nested path, empty or oversized fragment, one with a release heading, or a reused name is `CHANGELOG_FRAGMENT_INVALID` before any version is allocated. - **`gstack-restamp` folds fragments**: their bodies go above any legacy `[Unreleased]` section under one new heading, the files are deleted, and the highest declared bump wins over the policy's. The tree receipt's `changelog-fragments` rule accepts exactly that fold and nothing else. -- **`gstack-ship-queue land --pr `** (`lib/ship-queue-land.ts`): the per-PR merge step. It takes the land lock (`refs/notes/gstack-land`), checks the /ship receipt, the READY receipt and checks at the pin, merges the base resolving release fields only (a clean dependency edit survives; a product conflict refuses), stamps, pushes a commit with parents [pin, base] under an exact lease, waits for checks, restamps if the base moved (at most twice), squashes with `--match-head-commit` (merges with `--merge` under `history: merge-only`) and confirms the base tree. A PR whose base is another branch refuses before the lock; a refusal before the push releases the lock. Every step is journaled and resumable, also from the stamp commit's pin when the local journal is gone. `--repair` opens the follow-up PR after `QUEUE_LAND_DRIFT`; `--census` prints what land would do with every open PR, writing nothing. +- **`gstack-ship-queue land --pr `** (`lib/ship-queue-land.ts`): the per-PR merge step. It takes the land lock (`refs/notes/gstack-land`), checks the /ship receipt and checks at the pin, merges the base resolving release fields only (a clean dependency edit survives; a product conflict refuses), stamps, pushes a commit with parents [pin, base] under an exact lease, waits for checks, restamps if the base moved (at most twice), squashes with `--match-head-commit` and confirms the base tree. Every step is journaled and resumable. `--repair` opens the follow-up PR after `QUEUE_LAND_DRIFT`; `--census` prints what land would do with every open PR, writing nothing. - **/ship writes the fragment** (`lib/ship-policy.ts`, `ship/SKILL.md.tmpl`): under the key, `show-effective` prints a `ship: changelog_fragments` line, so Step 12 writes no version and Step 13 writes `/.md` (linted with `gstack-prose-lint --gate`) instead of an `[Unreleased]` section; the skill's policy paragraph now says its `ship:` lines bind Steps 12–16. - **`restamp_command` gets `GSTACK_RESTAMP_BASE_SHA`**, the release predecessor, so a repo counter such as a SAVE_VERSION computes predecessor + 1 from the base and never from the branch. - New result codes with troubleshooting anchors: `CHANGELOG_FRAGMENT_INVALID`, `QUEUE_LAND_LOCKED`, `QUEUE_LAND_CONFLICT`, `QUEUE_LAND_DRIFT`, `QUEUE_LAND_BASE_BUSY`. +- **Stamped merge trains** (`lib/ship-queue-train-stamp.ts`, `lib/ship-queue-train-land.ts`): under `changelog_fragments`, `train build` also builds S_i = stamp(merge(S_(i-1), pin_i)) for every slot, checks each is the merge modulo stamps, keeps every earlier release entry byte for byte and equals the plain prefix outside release paths (`QUEUE_TRAIN_STAMP_MISMATCH`), and gates S_k. `train land --train --pr ` publishes slot i's S_i exactly: a stamp commit on [pin, main] pushed under the pin's lease with the `evals-no-debounce` label, checks, `--match-head-commit`, main's tree confirmed; any other main freezes the queue as before. `verify --train` refuses a stamped train and names `train land`. ### Changed -- **Version Gate runs on every PR** (the path filter is gone), so under `changelog_fragments` a PR that touches no release file still proves it carries a fragment. An unstamped head keeps its release fields at its merge base and adds a valid fragment (legacy `[Unreleased]` passes; a `[bot]` author needs none); a merge-step stamp commit must have parents [pin, base] and carry the base plus its fragments' effective bump, so a lane that stamps its own head fails. Without the key nothing changes. +- **The main backstop covers a stamped train's merges by exact tree** (`scripts/train-backstop.ts`, `scripts/train-gate.ts`): a record's S_i prefixes cover main only when its tree is exactly S_i; plain T_i prefixes still cover modulo stamps. +- **Version Gate runs on every PR** (the path filter is gone), so under `changelog_fragments` a PR that touches no release file still proves it carries a fragment. An unstamped head keeps its release fields at its merge base and adds a valid fragment (legacy `[Unreleased]` passes; a `[bot]` author needs none); a merge-step stamp commit must carry the base plus its fragments' effective bump. Without the key nothing changes. ### Tests -- `test/changelog-fragments.test.ts`: blob-only reads and refusals, a carried-back fragment refused on whole released lines only, bump precedence, fold order with a legacy section, the restamp fold and its tree receipt, an edited fold refused. -- `test/ship-queue-land.test.ts` (bare origin and a gh shim): happy path; two PRs from one base without a collision; a legacy stamp plus a dependency edit; a title-derived entry; a product conflict; the lock and `--break-lock`; the base moving during CI; a foreign push never overwritten; a missing receipt; drift, `--repair` and the lock release; the census; a receipt that fails #3567's depth check refuses; a base with a four-part package.json in `stamp_files` lands four parts (#3557), gstack's own shape stays three; one run without `--no-wait` merges; a PR on another base refuses; `--break-lock` refuses a drift lock; a conflict releases the lock; a stacked parent's fragment refuses; a lost journal resumes; `merge-only` merges with `--merge`; a merge GitHub completed behind an error reply reconciles; `--break-lock` refuses a holder mid-merge; `--repair` resumes after a failed PR create and its PR takes over from a clean checkout; a symlinked legacy VERSION refuses in `--dry-run`; a `package.json` version source keeps its dependency edits. -- `test/version-gate-fragments.test.ts`: fragment heads, premature version changes, a bot author, an advanced base, an invalid fragment, the stamp commit's effective bump (open version claims do not apply to it), and the workflow's missing path filter. +- `test/changelog-fragments.test.ts`: blob-only reads and refusals, bump precedence, fold order with a legacy section, the restamp fold and its tree receipt, an edited fold refused. +- `test/ship-queue-land.test.ts` (bare origin and a gh shim): happy path; two PRs from one base without a collision; a legacy stamp plus a dependency edit; a title-derived entry; a product conflict; the lock and `--break-lock`; the base moving during CI; a foreign push never overwritten; a missing receipt; drift, `--repair` and the lock release; the census; a receipt that fails #3567's depth check refuses; a base with a four-part package.json in `stamp_files` lands four parts (#3557), gstack's own shape stays three. +- `test/version-gate-fragments.test.ts`: fragment heads, premature version changes, a bot author, an advanced base, an invalid fragment, the stamp commit's effective bump, and the workflow's missing path filter. - `test/restamp-command-boundary.test.ts`: the hook reads its counter from `GSTACK_RESTAMP_BASE_SHA`, not the branch. +- `test/ship-queue-train-stamp.test.ts`: S2 keeps S1's release and runs the versions in order with each slot's bump; a stacked child folds only its own fragment; a tampered earlier version or a product change is refused. +- `test/ship-queue-train-land.test.ts`: build records S1 and S2 and gates S2; land publishes each slot exactly with the label; a foreign push is never overwritten; a merge outside the train freezes the queue; a plain train refuses `train land`. +- `test/train-backstop.test.ts`: main equal to S1 is covered; the same tree with another VERSION is gated; a chain mixing stamped and plain prefixes is refused. ## [1.91.312.0] - 2026-10-29 diff --git a/docs/captain.md b/docs/captain.md index 7c0b7aadf..296c4c1d4 100644 --- a/docs/captain.md +++ b/docs/captain.md @@ -254,6 +254,13 @@ loop, per train: `gh pr merge --match-head-commit`; `verify --train` again. 4. `train prune` when every slot has merged. +Under `changelog_fragments` ([ship-policy.md](ship-policy.md#train-land)) lanes READY with a +fragment (`changelog.d/.md`) instead of an `[Unreleased]` section, there is no slot +call and no lane stamp, and step 3 is one command per slot: `train land --train --pr +` commits the stamped prefix S_i on [pin, main], pushes it, waits for checks, merges and +confirms main is S_i exactly. Outside a train, `gstack-ship-queue land --pr ` is the same +step for one PR ([ship-policy.md](ship-policy.md#land)). + A red train: `train bisect` on the red cases over every prefix. An attributed PR leaves the train and goes back to its lane; red on the base branch itself is fixed forward through an urgent one-PR train (or `train build --repair` while the queue is frozen, which diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 075aabcb3..291f63fba 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -365,6 +365,45 @@ are mirrored to origin as git notes on `refs/notes/gstack-trains` before any remote effect, so a lost machine loses nothing: `train status` on a clean state root rebuilds them. `GSTACK_SHIP_QUEUE_NOW` pins the clock for tests. + +### Stamped trains under `changelog_fragments`: `train land` + +When the base policy also declares [`changelog_fragments`](#changelog-fragments), +`train build` builds every prefix twice. T_i is the plain chain above. S_i is +what slot i publishes: S_0 is the base and S_i = stamp(merge(S_(i-1), pin_i)), +stamped by `gstack-restamp` with S_(i-1) as its base, so slot i folds only its +own fragments (a stacked pin's parent fragments are already folded) and the +versions run in slot order, each with its slot's effective bump. Before +anything is pushed, each S_i must be merge(S_(i-1), pin_i) modulo stamps, +keep every release entry of S_(i-1) byte for byte, and equal T_i outside +release paths (version files, CHANGELOG, fragments, `release_outputs`, +`stamp_paths`, `stamp_files`); otherwise `QUEUE_TRAIN_STAMP_MISMATCH`. Pass +`--allow-repo-commands` when the policy has a `restamp_command` or +`release_tool`. The record keeps both chains (`prefixes` and `stamped`), the +train head and branch are S_k, and the one full gate runs on S_k, the exact +tree the last merge publishes. + +Slots merge with `train land --train --pr `, never a slot call: + +1. main's tree must equal S_(i-1)'s exactly (slot 1: the train base), and the + PR's head its pin, with its /ship receipt. +2. It commits S_i's tree with parents [pin, main], adds the + `evals-no-debounce` label (a tool-made head gets no later push, so the + 10-minute quiet window protects nothing), and pushes under + `--force-with-lease=:`; a foreign push is never overwritten + (`QUEUE_TRAIN_MOVED`). +3. It waits for the PR's checks on that commit (`--no-wait` prints the + command). Required checks run live at the slot until GSTA-156's exact-tree + reuse is on main. +4. It refuses if main moved during the checks, merges with + `--match-head-commit`, and confirms main's tree is S_i exactly. Any other + main is the freeze path above (`QUEUE_TRAIN_TREE_MISMATCH`, backstop). + +`verify --train` refuses a stamped train and names `train land`. The backstop +covers main only when its exact tree is some S_i of a green record (plain +T_i records still cover modulo stamps). A dropped slot changes every later +version, so the train is rebuilt, as today. + **0. Lane: /ship before READY.** Every READY follows a /ship run on the branch at the head being READY'd, including a re-READY after a push (Garry, diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index f9d6aabb8..f936db6cb 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -4267,6 +4267,20 @@ the two allowed restamps, so `land` stopped instead of restamping again. it), then rerun `gstack-ship-queue land --pr `; the lock was released and the rerun restamps from the pin. + +### `QUEUE_TRAIN_STAMP_MISMATCH` + +**Meaning.** While building a stamped train (`changelog_fragments`), a prefix +S_i could not be stamped, or it is not the merge of S_(i-1) and its pin +modulo stamps, or it rewrote an earlier release entry, or it differs from the +plain prefix T_i outside release paths. Nothing was pushed or dispatched. See +[stamped trains](ship-policy.md#train-land). + +**Fix.** Read the named slot and files. A stamp hook (`restamp_command`, +`release_tool`) that writes product files is fixed in the hook; an invalid +fragment goes back to its lane. Otherwise rebuild the train without that slot +and report it. + ### `QUEUE_TRAIN_MOVED` From 359d2b3c6f8410ae71ecafb4ef9b9bc19dc84f79 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 04:49:39 +0000 Subject: [PATCH 19/31] captain templates: fragments instead of [Unreleased]; no slot call for stamped trains Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- captain/templates/READY.md | 4 ++++ captain/templates/slot-call.md | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/captain/templates/READY.md b/captain/templates/READY.md index 73ce6de02..bbeb09fa3 100644 --- a/captain/templates/READY.md +++ b/captain/templates/READY.md @@ -20,6 +20,10 @@ SHIP_RECEIPT: pr= head= version=- base= tree= session_kind READY_RECEIPT: () ``` +Under `changelog_fragments` (docs/ship-policy.md#changelog-fragments) the PR's entry is a +fragment, `changelog.d/.md`, never an `[Unreleased]` section or a version, and the +controller merges with `train land` or `land`, so no slot call follows this block. + The lane's own lines, after the block: ```text diff --git a/captain/templates/slot-call.md b/captain/templates/slot-call.md index 16d6cd855..f1908884a 100644 --- a/captain/templates/slot-call.md +++ b/captain/templates/slot-call.md @@ -1,7 +1,9 @@ # Slot call Sent by the controller to one lane at a time, in train order, after the train verdict is -GREEN and the previous slot has merged and verified. +GREEN and the previous slot has merged and verified. Plain trains only: a train built under +`changelog_fragments` has no slot call; the controller runs `train land --train --pr ` +(docs/ship-policy.md#train-land). ```text # slot , base , order From e50f38045d961cf116bf4dfcdb6e584148058ae3 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 04:50:20 +0000 Subject: [PATCH 20/31] test: timeouts on the train-land gh wrapper's sync spawns Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- test/ship-queue-train-land.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/ship-queue-train-land.test.ts b/test/ship-queue-train-land.test.ts index e8f2d0b36..683a751c4 100644 --- a/test/ship-queue-train-land.test.ts +++ b/test/ship-queue-train-land.test.ts @@ -26,19 +26,19 @@ function wrap(fx: TrainFixture): void { '#!/usr/bin/env node', "const fs=require('fs');const cp=require('child_process');const D=process.env.LAND_WRAP_DATA;const a=process.argv.slice(2);", "const d=JSON.parse(fs.readFileSync(D,'utf8'));const save=()=>fs.writeFileSync(D,JSON.stringify(d));", - "const g=(...x)=>cp.execFileSync('git',['--git-dir',d.origin,...x],{encoding:'utf8'}).trim();", + "const g=(...x)=>cp.execFileSync('git',['--git-dir',d.origin,...x],{encoding:'utf8',timeout:30000}).trim();", "const rev=r=>{try{return g('rev-parse','--verify','--quiet',r)}catch{return ''}};", "if(a[0]==='pr'&&a[1]==='view'&&d.prs[a[2]]){const p=d.prs[a[2]];const h=rev('refs/heads/'+p.headRefName);if(h)p.headRefOid=h;save();console.log(JSON.stringify(p));process.exit(0)}", "if(a[0]==='pr'&&a[1]==='edit'){(d.labels=d.labels||{})[a[2]]=a[a.indexOf('--add-label')+1];save();process.exit(0)}", "if(a[0]==='pr'&&a[1]==='merge'){const p=d.prs[a[2]];const want=a[a.indexOf('--match-head-commit')+1];if(rev('refs/heads/'+p.headRefName)!==want){console.error('Head branch was modified');process.exit(1)}", " const before=rev('main');let tree=rev(want+'^{tree}');", - " if(d.outsideOnMerge){const blob=cp.execFileSync('git',['--git-dir',d.origin,'hash-object','-w','--stdin'],{input:'outside\\n',encoding:'utf8'}).trim();", - " const ls=g('ls-tree',before+'^{tree}')+'\\n100644 blob '+blob+'\\toutside.txt';const ot=cp.execFileSync('git',['--git-dir',d.origin,'mktree'],{input:ls+'\\n',encoding:'utf8'}).trim();", + " if(d.outsideOnMerge){const blob=cp.execFileSync('git',['--git-dir',d.origin,'hash-object','-w','--stdin'],{input:'outside\\n',encoding:'utf8',timeout:30000}).trim();", + " const ls=g('ls-tree',before+'^{tree}')+'\\n100644 blob '+blob+'\\toutside.txt';const ot=cp.execFileSync('git',['--git-dir',d.origin,'mktree'],{input:ls+'\\n',encoding:'utf8',timeout:30000}).trim();", " g('update-ref','refs/heads/main',g('commit-tree',ot,'-p',before,'-m','outside'));tree=g('merge-tree','--write-tree','--merge-base='+before,rev('main'),want).split('\\n')[0];d.outsideOnMerge=false}", " g('update-ref','refs/heads/main',g('commit-tree',tree,'-p',rev('main'),'-m','squash #'+a[2]));p.state='MERGED';save();process.exit(0)}", "const paged=/commits\\/([0-9a-f]+)\\/(check-runs|status)\\?.*page=(\\d+)/.exec(a[1]||'');", "if(a[0]==='api'&&paged&&!(d.ci||{})[paged[1]]&&!((d.statuses||{})[paged[1]])){const list=paged[2]==='check-runs'?[{name:'free-tests',status:'completed',conclusion:'success',id:1,completed_at:'2026-10-26T00:00:00Z'}]:[];const key=paged[2]==='check-runs'?'check_runs':'statuses';console.log(JSON.stringify(paged[3]==='1'?{total_count:list.length,state:'success',[key]:list}:{total_count:list.length,[key]:[]}));process.exit(0)}", - `const r=cp.spawnSync('node',[${JSON.stringify(shim)},D,...a],{stdio:'inherit'});process.exit(r.status??1);`, + `const r=cp.spawnSync('node',[${JSON.stringify(shim)},D,...a],{stdio:'inherit',timeout:120000});process.exit(r.status??1);`, '', ].join('\n'), { mode: 0o755 }); fx.env.PATH = `${dir}:${fx.env.PATH}`; From 64a9db8f6371392ee36ea823914beed95f7782c1 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 05:03:29 +0000 Subject: [PATCH 21/31] feat(ship-queue): train land checks the depth-checked receipt (#3567); regenerate the doctor anchors Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-doctor-components.sh | 1 + lib/ship-queue-train-land.ts | 6 +++--- test/ship-queue-train-land.test.ts | 12 +++++++++++- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh index d02a688e8..09a121523 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -281,6 +281,7 @@ gstack_result_anchor() { QUEUE_TRAIN_BISECT_CAPPED) printf '%s' 'queue-train-bisect-capped' ;; QUEUE_TRAIN_PREFIX_INVALID) printf '%s' 'queue-train-prefix-invalid' ;; QUEUE_TRAIN_REBUILD_REFUSED) printf '%s' 'queue-train-rebuild-refused' ;; + QUEUE_TRAIN_STAMP_MISMATCH) printf '%s' 'queue-train-stamp-mismatch' ;; QUEUE_TRAIN_TREE_MISMATCH) printf '%s' 'queue-train-tree-mismatch' ;; QUEUE_TRAIN_STALE) printf '%s' 'queue-train-stale' ;; QUEUE_TRAIN_FROZEN) printf '%s' 'queue-train-frozen' ;; diff --git a/lib/ship-queue-train-land.ts b/lib/ship-queue-train-land.ts index 46bf53677..907f38d7b 100644 --- a/lib/ship-queue-train-land.ts +++ b/lib/ship-queue-train-land.ts @@ -13,7 +13,7 @@ import * as path from 'node:path'; import { ciAtHead, type QueueContext } from './ship-queue'; import { git, writeJsonAtomic } from './ship-queue-gate'; import { waitChecks } from './ship-queue-land'; -import { bindReceipt } from './ship-receipt'; +import { bindShippedReceipt } from './ship-receipt-depth'; import { checkRun, prInfo } from './ship-queue-train-github'; import { activeFreeze, attemptsOf, trainFile } from './ship-queue-train-record'; import { fetchBase, fetchTrainBranch, findTrain, frozenLines, nextFor, outFailed, outOk, outPending, outRefused, trainEnv, type TrainOut } from './ship-queue-train'; @@ -59,8 +59,8 @@ export async function trainLand(ctx: QueueContext, o: { train: string; pr: strin if (info.isCrossRepository) return outRefused({ code: 'QUEUE_TRAIN_UNTRUSTED', message: `#${o.pr} is a fork head; train land pushes only crew branches` }, lines, 'none', run); if (!j || j.base !== main.sha) { if (info.headRefOid !== pin && info.headRefOid !== j?.stamp_commit) return stopped(env, t, 'QUEUE_TRAIN_MOVED', `#${o.pr}'s head is ${info.headRefOid.slice(0, 12)}, not its pin ${pin.slice(0, 12)}`, main.sha, lines, true); - const shipped = bindReceipt(info.body, o.pr, [pin]); - lines.push(shipped.line); + const shipped = bindShippedReceipt(root, `refs/remotes/${env.remote}/${ctx.base}`, info.body, o.pr, [pin]); + lines.push(shipped.line, ...(shipped.depthLine ? [shipped.depthLine] : [])); if (!shipped.ok) return outRefused({ code: shipped.code, message: shipped.message }, lines, `#${o.pr}'s lane runs /ship at its pin and READYs again`, run); const commit = git(root, ['commit-tree', s.tree, '-p', pin, '-p', main.sha, '-m', `v${s.version} stamp: train ${t.nonce} S${s.index} (#${o.pr})`]).out; const label = ctx.gh(['pr', 'edit', o.pr, '--add-label', 'evals-no-debounce']); diff --git a/test/ship-queue-train-land.test.ts b/test/ship-queue-train-land.test.ts index 683a751c4..896eb1f68 100644 --- a/test/ship-queue-train-land.test.ts +++ b/test/ship-queue-train-land.test.ts @@ -10,7 +10,7 @@ import * as path from 'node:path'; import { EXIT } from '../lib/headless-artifacts'; import { cleanup } from './helpers/restamp-fixture'; import { - GREEN_GATE, TRAIN_POLICY, line, nonceOf, openPr, queue, readyReceipt, trainFixture, trainJson, type TrainFixture, + GREEN_GATE, TRAIN_POLICY, line, nonceOf, openPr, queue, readyReceipt, shippedBody, trainFixture, trainJson, type TrainFixture, } from './helpers/ship-queue-train-fixture'; const POLICY = { ...TRAIN_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.92.0.0' }; @@ -118,6 +118,16 @@ describe('stamped trains: build and land', () => { expect(line(drift.out, 'QUEUE_TRAIN_FROZEN:')).toMatch(/^QUEUE_TRAIN_FROZEN: train \S+ merged #1/); }, 300_000); + test('the receipt check is the depth-checked one train build uses: a receipt whose writer records depth but which records none refuses, nothing pushed', () => { + const { fx, h1, nonce } = stampedTrain(); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + fx.write(d => { (d.prs['1'] as any).body = shippedBody('1', h1, { gstack_version: '1.91.307.0' }); }); + const r = land(fx, nonce, '1'); + expect(r.err).toContain('(QUEUE_RECEIPT_DEPTH_RAISED)'); + expect(r.out).toMatch(/^REVIEW_DEPTH: receipt=none required=/m); + expect(fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', 'refs/heads/pr-1')).toBe(h1); + }, 180_000); + test('a plain train refuses train land and keeps the slot-stamp path', () => { const fx = trainFixture(TRAIN_POLICY, 'gstack-train-land-'); fixtures.push(fx); wrap(fx); const h1 = openPr(fx, '1'); From 6b602a9673d099a88ea75dea3efc24377db847e8 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 05:39:15 +0000 Subject: [PATCH 22/31] test(train-stamp): S_i keeps the base's four-part package.json (#3557) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 2 +- test/ship-queue-train-stamp.test.ts | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67cc56664..9d6e6d262 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ - `test/ship-queue-land.test.ts` (bare origin and a gh shim): happy path; two PRs from one base without a collision; a legacy stamp plus a dependency edit; a title-derived entry; a product conflict; the lock and `--break-lock`; the base moving during CI; a foreign push never overwritten; a missing receipt; drift, `--repair` and the lock release; the census; a receipt that fails #3567's depth check refuses; a base with a four-part package.json in `stamp_files` lands four parts (#3557), gstack's own shape stays three. - `test/version-gate-fragments.test.ts`: fragment heads, premature version changes, a bot author, an advanced base, an invalid fragment, the stamp commit's effective bump, and the workflow's missing path filter. - `test/restamp-command-boundary.test.ts`: the hook reads its counter from `GSTACK_RESTAMP_BASE_SHA`, not the branch. -- `test/ship-queue-train-stamp.test.ts`: S2 keeps S1's release and runs the versions in order with each slot's bump; a stacked child folds only its own fragment; a tampered earlier version or a product change is refused. +- `test/ship-queue-train-stamp.test.ts`: S2 keeps S1's release and runs the versions in order with each slot's bump; a stacked child folds only its own fragment; a four-part package.json base keeps four parts in every S_i, read from S_(i-1); a tampered earlier version or a product change is refused. - `test/ship-queue-train-land.test.ts`: build records S1 and S2 and gates S2; land publishes each slot exactly with the label; a foreign push is never overwritten; a merge outside the train freezes the queue; a plain train refuses `train land`. - `test/train-backstop.test.ts`: main equal to S1 is covered; the same tree with another VERSION is gated; a chain mixing stamped and plain prefixes is refused. diff --git a/test/ship-queue-train-stamp.test.ts b/test/ship-queue-train-stamp.test.ts index eb58f7a6d..9431bfc21 100644 --- a/test/ship-queue-train-stamp.test.ts +++ b/test/ship-queue-train-stamp.test.ts @@ -67,6 +67,21 @@ describe('stamped train prefixes', () => { expect(top).not.toContain('- parent change'); }, 60_000); + test('a base with a four-part package.json listed in stamp_files keeps four parts in every S_i, read from S_(i-1)', () => { + const policy = { ...POLICY, stamp_files: ['VERSION', 'package.json', 'CHANGELOG.md', 'changelog.d/*.md'], stamp_patterns: ['"version": "{version}"'] }; + const r = makeFixtureRepo({ policy, prefix: 'train-stamp-' }); repos.push(r); + const pkg = JSON.parse(fs.readFileSync(path.join(r.work, 'package.json'), 'utf8')); + fs.writeFileSync(path.join(r.work, 'package.json'), JSON.stringify({ ...pkg, version: '1.2.3.0' }, null, 2) + '\n'); + fs.rmSync(path.join(r.work, 'package-lock.json')); + r.git('add', '-A'); r.git('commit', '-q', '-m', 'four-part manifest'); r.git('push', '-q', 'origin', 'main'); + const a = pin(r, 'a', 'origin/main', '- a change\n'); + const b = pin(r, 'b', 'origin/main', '- b change\n'); + const o = { ...chain(r, [['1', a], ['2', b]]), policy: mergePolicy(policy, 'test').policy }; + const out = stampChain(o); + if ('code' in out) throw new Error(out.message); + expect(out.stamped.map(s => JSON.parse(show(r, s.commit + ':package.json')).version)).toEqual(['1.2.4.0', '1.2.5.0']); + }, 60_000); + test('a tampered earlier version or a product change in S_i is refused', () => { const r = makeFixtureRepo({ policy: POLICY, prefix: 'train-stamp-' }); repos.push(r); const a = pin(r, 'a', 'origin/main', '- a change\n'); From 2a0ccf23e92461f653562ef3ed5b581a1b809c08 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 06:05:41 +0000 Subject: [PATCH 23/31] test(windows): leave test/ship-queue-train-land.test.ts out of the Windows-safe subset (same gh shim as the train tests) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- scripts/lib/windows-curation.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/lib/windows-curation.ts b/scripts/lib/windows-curation.ts index 30a1a3368..6687ef3c2 100644 --- a/scripts/lib/windows-curation.ts +++ b/scripts/lib/windows-curation.ts @@ -291,6 +291,10 @@ export const KNOWN_WINDOWS_INCOMPATIBLE: Array<{ file: string; reason: string }> reason: 'spawns bin/gstack-ship-queue (shebang) and a #!/bin/sh gh shim through test/helpers/restamp-fixture.ts and test/helpers/ship-queue-fixture.ts', }, // GSTA-49 T1 (merge trains): the train lib logic runs on Windows in test/ship-queue-train-logic.test.ts. + { + file: 'test/ship-queue-train-land.test.ts', + reason: 'drives train land against test/helpers/ship-queue-train-fixture.ts plus an extensionless node gh wrapper on a colon-joined PATH; on windows-latest spawnSync(\'gh\') resolves the runner\'s gh.exe instead, as for test/ship-queue-train.test.ts and test/ship-queue-land.test.ts (windows-free-shard 6, run 40824965988)', + }, { file: 'test/ship-queue-train.test.ts', reason: 'drives bin/gstack-ship-queue against the stateful #!/bin/sh gh shim in test/helpers/ship-queue-train-fixture.ts; on windows-latest spawnSync(\'gh\') resolves the runner\'s gh.exe instead of the shim (windows-free-shard run 39152702056)', From 23a17b4b7e7dfa590c854697ef8a29af87d07a3b Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 08:39:58 +0000 Subject: [PATCH 24/31] fix(train-land): close pre-landing review and outside-voice findings - train land checks commit-tree and stamps with a fixed identity and date, journals before the push, and adopts its own stamp head on a rerun; a failed commit-tree never pushes a branch deletion - a symlinked release file in the merged tree refuses before the train stamp writes - mergeStep merges a conflicted manifest three-way with the predecessor's version (mergeManifest, shared with land) - train land refuses a PR retargeted off the base and a failed base refresh - Version Gate skips a released fragment carried in by a stacked pin instead of counting its bump - bisect of a stamped train tests T0 and S_1..S_k Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- lib/changelog-fragments.ts | 5 +-- lib/ship-queue-land.ts | 51 +++++++++++++++++++---------- lib/ship-queue-train-github.ts | 6 ++-- lib/ship-queue-train-land.ts | 43 +++++++++++++++++------- lib/ship-queue-train-stamp.ts | 4 +++ lib/ship-queue-train-verdict.ts | 7 ++-- lib/ship-queue-train.ts | 13 ++++++-- lib/version-gate-train.ts | 2 +- test/ship-queue-train-land.test.ts | 49 +++++++++++++++++++++++++-- test/ship-queue-train-stamp.test.ts | 32 ++++++++++++++++++ test/version-gate-fragments.test.ts | 26 +++++++++++++++ 11 files changed, 195 insertions(+), 43 deletions(-) diff --git a/lib/changelog-fragments.ts b/lib/changelog-fragments.ts index 5fb0a1000..3f5404cf8 100644 --- a/lib/changelog-fragments.ts +++ b/lib/changelog-fragments.ts @@ -60,8 +60,9 @@ export function parseFragment(rel: string, text: string): Fragment | FragmentErr * stacked pin's parent): a path present there with the same blob is skipped, a path present there * with other bytes is an error. With `changelog`, a fragment whose lines appear whole and in order in a released * section of that file at `exclude` is an error too: a landed PR's fragment carried back in by stacked history. + * `onReleased: 'skip'` drops such a fragment instead: a merge-step stamp commit never folds it. */ -export function readFragments(cwd: string, treeish: string | null, dir: string, exclude?: string | null, changelog?: string | null): FragmentSet { +export function readFragments(cwd: string, treeish: string | null, dir: string, exclude?: string | null, changelog?: string | null, onReleased: 'error' | 'skip' = 'error'): FragmentSet { const prefix = dir.replace(/\/+$/, '') + '/'; const list = (t: string | null) => { const r = t === null ? git(cwd, ['ls-files', '-s', '-z', '--', prefix]) : git(cwd, ['ls-tree', '-r', '-z', '--full-tree', t, '--', prefix]); @@ -98,7 +99,7 @@ export function readFragments(cwd: string, treeish: string | null, dir: string, if (!blob.ok) { err('blob unreadable'); continue; } const parsed = parseFragment(e.path, blob.out); if ('code' in parsed) errors.push(parsed); - else if (isReleased(parsed.body)) err(`its text is already released in ${changelog} on the base (a landed PR's fragment carried in by stacked history); delete it from this branch`); + else if (isReleased(parsed.body)) { if (onReleased === 'error') err(`its text is already released in ${changelog} on the base (a landed PR's fragment carried in by stacked history); delete it from this branch`); } else fragments.push(parsed); } return { fragments, errors }; diff --git a/lib/ship-queue-land.ts b/lib/ship-queue-land.ts index 6d8142292..ee71af2f6 100644 --- a/lib/ship-queue-land.ts +++ b/lib/ship-queue-land.ts @@ -35,6 +35,13 @@ function linkedSegment(wt: string, rel: string): string | null { return at < 0 ? null : segs.slice(0, at + 1).join('/'); } +/** A release file the merge step writes that is a symlink, or sits under one, in the checkout `wt`; null when none. */ +export function linkedReleaseFile(wt: string, policy: ShipPolicy, versionRel: string): string | null { + const exact = [versionRel, ...MANIFESTS, ...(policy.changelog ? [policy.changelog] : [])]; + return git(wt, ['ls-files', '-s', '-z']).out.split('\0').filter(rec => rec.startsWith('120000 ')).map(rec => rec.slice(rec.indexOf('\t') + 1)) + .find(rel => exact.includes(rel) || matchesAny([...policy.release_outputs, ...policy.stamp_paths], rel)) ?? exact.map(rel => linkedSegment(wt, rel)).find(Boolean) ?? null; +} + export interface LandJournal { schema_version: 1; pr: string; branch: string; reviewed_pin: string; last_pushed_head: string | null; stamp_commit: string | null; stamp_tree: string | null; @@ -91,27 +98,37 @@ export function resolveReleaseConflicts(wt: string, conflicts: string[], policy: if (!git(wt, ['checkout', '--theirs', '--', rel]).ok) { left.push(rel); continue; } } else if (MANIFESTS.includes(base)) { const sides = [1, 2, 3].map(n => stage(n, rel)); - if (sides.some(s => !s.ok)) { left.push(rel); continue; } - const theirs = JSON.parse(sides[2]!.out); - const pin = (text: string) => { - const o = JSON.parse(text); - if ('version' in o) o.version = theirs.version; - if (o.packages?.[''] && theirs.packages?.['']) o.packages[''].version = theirs.packages[''].version; - const indent = /^\{\n( +|\t)/.exec(text)?.[1] ?? 2; - return JSON.stringify(o, null, indent) + '\n'; - }; - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-land-merge-')); - const [b, o, t] = ['base', 'ours', 'theirs'].map((n, i) => { const f = path.join(tmp, n); fs.writeFileSync(f, pin(sides[i === 0 ? 0 : i]!.out)); return f; }); - const m = spawnSync('git', ['merge-file', '-p', o!, b!, t!], { encoding: 'utf8', timeout: 30_000 }); - fs.rmSync(tmp, { recursive: true, force: true }); - if (m.status !== 0) { left.push(rel); continue; } - fs.writeFileSync(path.join(wt, rel), m.stdout); + const merged = sides.every(s => s.ok) ? mergeManifest(sides[0]!.out, sides[1]!.out, sides[2]!.out) : null; + if (merged === null) { left.push(rel); continue; } + fs.writeFileSync(path.join(wt, rel), merged); } else { left.push(rel); continue; } git(wt, ['add', '--', rel]); } return left; } +/** Three-way merge of a JSON manifest with every side's release version set to theirs'; null when anything else conflicts. */ +export function mergeManifest(base: string, ours: string, theirs: string): string | null { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-land-merge-')); + try { + const want = JSON.parse(theirs); + const pin = (text: string) => { + const o = JSON.parse(text); + if ('version' in o) o.version = want.version; + if (o.packages?.[''] && want.packages?.['']) o.packages[''].version = want.packages[''].version; + const indent = /^\{\n( +|\t)/.exec(text)?.[1] ?? 2; + return JSON.stringify(o, null, indent) + '\n'; + }; + const [b, o, t] = [base, ours, theirs].map((text, i) => { const f = path.join(tmp, String(i)); fs.writeFileSync(f, pin(text)); return f; }); + const m = spawnSync('git', ['merge-file', '-p', o!, b!, t!], { encoding: 'utf8', timeout: 30_000 }); + return m.status === 0 ? m.stdout : null; + } catch { + return null; + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +} + /** * A legacy per-PR stamp (a claim-at-open repo): the pin moved VERSION past its merge base and headed its * own entry with that version. Before the merge, put the pin's release fields back to the merge base's @@ -192,9 +209,7 @@ function buildStamp(ctx: QueueContext, policy: ShipPolicy, o: { pin: string; bas } } if (!git(wt, ['commit', '-q', '--no-verify', '--allow-empty', '-m', `land #${o.pr}: merge origin/${ctx.base}`]).ok) return { code: 'QUEUE_LAND_CONFLICT', message: 'cannot commit the merge in the scratch worktree' }; - const exact = [versionRel, ...MANIFESTS, ...(policy.changelog ? [policy.changelog] : [])]; - const linked = git(wt, ['ls-files', '-s', '-z']).out.split('\0').filter(rec => rec.startsWith('120000 ')).map(rec => rec.slice(rec.indexOf('\t') + 1)) - .find(rel => exact.includes(rel) || matchesAny([...policy.release_outputs, ...policy.stamp_paths], rel)) ?? exact.map(rel => linkedSegment(wt, rel)).find(Boolean); + const linked = linkedReleaseFile(wt, policy, versionRel); if (linked) return { code: 'QUEUE_LAND_CONFLICT', message: `${linked} is a symlink in the merged tree; the merge step writes release files only inside the repository` }; const r = restamp({ repoRoot: wt, base: ctx.base, next: true, policyFrom: 'base', allowRepoCommands: o.allowRepoCommands, dryRun: false, now: o.now, env: process.env }); if (r.status !== 'stamped' && r.status !== 'noop') return { code: r.code ?? 'QUEUE_LAND_CONFLICT', message: `restamp ${r.status}: ${r.message ?? ''}`, fix: r.fix }; diff --git a/lib/ship-queue-train-github.ts b/lib/ship-queue-train-github.ts index 592a125b5..ef6d8527c 100644 --- a/lib/ship-queue-train-github.ts +++ b/lib/ship-queue-train-github.ts @@ -10,7 +10,7 @@ import * as path from 'node:path'; import type { Exec, RunSummary } from '../scripts/eval-bg'; import type { QueueContext } from './ship-queue'; -export interface PrInfo { headRefOid: string; isCrossRepository: boolean; state: string; headRefName: string; body: string } +export interface PrInfo { headRefOid: string; isCrossRepository: boolean; state: string; headRefName: string; body: string; baseRefName?: string } export interface RunInfo { databaseId: number; status: string; conclusion: string | null; url: string; headSha: string; event: string; displayTitle: string } export interface CheckInfo { status: string; conclusion: string | null } @@ -34,10 +34,10 @@ export function repoSlug(ctx: QueueContext): string | null { } export function prInfo(ctx: QueueContext, pr: string): PrInfo | { error: string } { - const r = ctx.gh(['pr', 'view', pr, '--json', 'headRefOid,isCrossRepository,state,headRefName,body']); + const r = ctx.gh(['pr', 'view', pr, '--json', 'headRefOid,isCrossRepository,state,headRefName,body,baseRefName']); const p = json>(r); if (!p?.headRefOid) return { error: `cannot read PR #${pr} through gh (${(r.err ?? r.out).trim().slice(0, 120) || 'no headRefOid'})` }; - return { headRefOid: p.headRefOid, isCrossRepository: p.isCrossRepository !== false, state: p.state ?? 'OPEN', headRefName: p.headRefName ?? '', body: p.body ?? '' }; + return { headRefOid: p.headRefOid, isCrossRepository: p.isCrossRepository !== false, state: p.state ?? 'OPEN', headRefName: p.headRefName ?? '', body: p.body ?? '', ...(p.baseRefName ? { baseRefName: p.baseRefName } : {}) }; } const RUN_FIELDS = 'databaseId,status,conclusion,url,headSha,event,displayTitle'; diff --git a/lib/ship-queue-train-land.ts b/lib/ship-queue-train-land.ts index 907f38d7b..475be1697 100644 --- a/lib/ship-queue-train-land.ts +++ b/lib/ship-queue-train-land.ts @@ -20,6 +20,9 @@ import { fetchBase, fetchTrainBranch, findTrain, frozenLines, nextFor, outFailed import { confirmMerged, stopped } from './ship-queue-train-verify'; const Q = 'gstack-ship-queue'; +const STAMP_IDENTITY = { 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 isSha = (s: string) => /^[0-9a-f]{40,64}$/.test(s); interface SlotJournal { schema_version: 1; pr: string; pin: string; stamp_commit: string; base: string } export async function trainLand(ctx: QueueContext, o: { train: string; pr: string; noWait?: boolean; timeoutMin?: number }): Promise { @@ -45,9 +48,16 @@ export async function trainLand(ctx: QueueContext, o: { train: string; pr: strin const s = t.stamped[i]!; const pred = i === 0 ? { index: 0, tree: t.prefixes[0]!.tree } : t.stamped[i - 1]!; const pin = t.slots[i]!.head; - const mainNow = () => { fetchBase(ctx); const sha = git(root, ['rev-parse', `${env.remote}/${ctx.base}`]).out; return { sha, tree: git(root, ['rev-parse', `${sha}^{tree}`]).out }; }; + const mainNow = (): { sha: string; tree: string } | { error: string } => { + const error = fetchBase(ctx); + if (error) return { error }; + const sha = git(root, ['rev-parse', `${env.remote}/${ctx.base}`]).out; + return { sha, tree: git(root, ['rev-parse', `${sha}^{tree}`]).out }; + }; + const rerun = `${Q} train land --train ${t.nonce} --pr ${o.pr}`; const lines = [`TRAIN_LAND: ${t.nonce} slot ${i + 1}/${t.slots.length} #${o.pr} pin ${pin} -> S${s.index} v${s.version} tree ${s.tree}`]; let main = mainNow(); + if ('error' in main) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: main.error }, lines, rerun, run); if (main.tree === s.tree) return confirmMerged(env, t, o.pr, main.sha, s.index, [...lines, `main: ${main.sha} is S${s.index} exactly`]); if (main.tree !== pred.tree) return stopped(env, t, 'QUEUE_TRAIN_TREE_MISMATCH', `main ${main.sha.slice(0, 12)} is neither S${pred.index} nor S${s.index} exactly`, main.sha, lines, true); @@ -55,37 +65,46 @@ export async function trainLand(ctx: QueueContext, o: { train: string; pr: strin let j: SlotJournal | null = null; try { j = JSON.parse(fs.readFileSync(file, 'utf8')) as SlotJournal; } catch { j = null; } const info = prInfo(ctx, o.pr); - if ('error' in info) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: info.error }, lines, `${Q} train land --train ${t.nonce} --pr ${o.pr}`, run); + if ('error' in info) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: info.error }, lines, rerun, run); if (info.isCrossRepository) return outRefused({ code: 'QUEUE_TRAIN_UNTRUSTED', message: `#${o.pr} is a fork head; train land pushes only crew branches` }, lines, 'none', run); + const retargeted = (b?: string) => b && b !== ctx.base ? outRefused({ code: 'QUEUE_NOT_READY', message: `#${o.pr} targets ${b}, not ${ctx.base}; train land merges only PRs on the base the train stamped` }, lines, `retarget #${o.pr} to ${ctx.base}, then ${rerun}`, run) : null; + const wrongBase = retargeted(info.baseRefName); + if (wrongBase) return wrongBase; if (!j || j.base !== main.sha) { - if (info.headRefOid !== pin && info.headRefOid !== j?.stamp_commit) return stopped(env, t, 'QUEUE_TRAIN_MOVED', `#${o.pr}'s head is ${info.headRefOid.slice(0, 12)}, not its pin ${pin.slice(0, 12)}`, main.sha, lines, true); + const commit = git(root, ['commit-tree', s.tree, '-p', pin, '-p', main.sha, '-m', `v${s.version} stamp: train ${t.nonce} S${s.index} (#${o.pr})`], { ...process.env, ...STAMP_IDENTITY }).out; + if (!isSha(commit)) return outFailed({ code: 'QUEUE_LAND_CONFLICT', message: `cannot create the stamp commit for tree ${s.tree.slice(0, 12)}; nothing pushed` }, lines, `${Q} train status --train ${t.nonce}`, run); + if (![pin, commit, j?.stamp_commit].includes(info.headRefOid)) return stopped(env, t, 'QUEUE_TRAIN_MOVED', `#${o.pr}'s head is ${info.headRefOid.slice(0, 12)}, not its pin ${pin.slice(0, 12)}`, main.sha, lines, true); const shipped = bindShippedReceipt(root, `refs/remotes/${env.remote}/${ctx.base}`, info.body, o.pr, [pin]); lines.push(shipped.line, ...(shipped.depthLine ? [shipped.depthLine] : [])); if (!shipped.ok) return outRefused({ code: shipped.code, message: shipped.message }, lines, `#${o.pr}'s lane runs /ship at its pin and READYs again`, run); - const commit = git(root, ['commit-tree', s.tree, '-p', pin, '-p', main.sha, '-m', `v${s.version} stamp: train ${t.nonce} S${s.index} (#${o.pr})`]).out; const label = ctx.gh(['pr', 'edit', o.pr, '--add-label', 'evals-no-debounce']); lines.push(`LABEL: evals-no-debounce ${label.ok ? 'added' : `not added (${(label.err ?? label.out).trim().slice(0, 80)}); the 10-minute quiet window applies`}`); - const lease = j?.stamp_commit && info.headRefOid === j.stamp_commit ? j.stamp_commit : pin; - const push = git(root, ['push', '-q', `--force-with-lease=refs/heads/${info.headRefName}:${lease}`, env.remote, `${commit}:refs/heads/${info.headRefName}`]); - if (!push.ok) return stopped(env, t, 'QUEUE_TRAIN_MOVED', `#${o.pr}'s branch moved off ${lease.slice(0, 12)} before the stamp push; train land never overwrites it`, main.sha, lines, true); j = { schema_version: 1, pr: o.pr, pin, stamp_commit: commit, base: main.sha }; - fs.mkdirSync(path.dirname(file), { recursive: true }); writeJsonAtomic(file, j); - lines.push(`PUSHED: ${commit} (tree S${s.index}, parents [pin, main ${main.sha.slice(0, 12)}]) to ${info.headRefName}`); + const pushed = info.headRefOid !== commit; + if (pushed) { + const push = git(root, ['push', '-q', `--force-with-lease=refs/heads/${info.headRefName}:${info.headRefOid}`, env.remote, `${commit}:refs/heads/${info.headRefName}`]); + if (!push.ok) return stopped(env, t, 'QUEUE_TRAIN_MOVED', `#${o.pr}'s branch moved off ${info.headRefOid.slice(0, 12)} before the stamp push; train land never overwrites it`, main.sha, lines, true); + } + lines.push(`${pushed ? 'PUSHED' : 'RESUMED'}: ${commit} (tree S${s.index}, parents [pin, main ${main.sha.slice(0, 12)}]) ${pushed ? 'to' : 'is already the head of'} ${info.headRefName}`); const next = `gstack-pr-wait ${o.pr} --until green && ${Q} train land --train ${t.nonce} --pr ${o.pr}`; - if (o.noWait) return outPending({ code: 'QUEUE_NOT_READY', message: `checks run on the stamp commit ${commit.slice(0, 12)}` }, lines, next, run); - waitChecks(ctx, commit, o.timeoutMin ?? 60); + if (o.noWait && pushed) return outPending({ code: 'QUEUE_NOT_READY', message: `checks run on the stamp commit ${commit.slice(0, 12)}` }, lines, next, run); + if (!o.noWait) waitChecks(ctx, commit, o.timeoutMin ?? 60); } const head = prInfo(ctx, o.pr); if ('error' in head || head.headRefOid !== j.stamp_commit) return stopped(env, t, 'QUEUE_TRAIN_MOVED', `#${o.pr}'s head is not the stamp commit ${j.stamp_commit.slice(0, 12)}`, main.sha, lines, true); + const movedBase = retargeted(head.baseRefName); + if (movedBase) return movedBase; const ci = ciAtHead(ctx, j.stamp_commit); lines.push(`CI: ${ci.line}`); if (!ci.green) return outPending({ code: 'QUEUE_NOT_READY', message: `checks on the stamp commit ${j.stamp_commit.slice(0, 12)} are not green` }, lines, `gstack-pr-wait ${o.pr} --until green && ${Q} train land --train ${t.nonce} --pr ${o.pr}`, run); main = mainNow(); + if ('error' in main) return outPending({ code: 'QUEUE_PROOF_MISSING', message: main.error }, lines, rerun, run); if (main.sha !== j.base) return stopped(env, t, 'QUEUE_TRAIN_TREE_MISMATCH', `main moved to ${main.sha.slice(0, 12)} during the slot's checks (stamped on ${j.base.slice(0, 12)})`, main.sha, lines, true); const m = ctx.gh(['pr', 'merge', o.pr, '--squash', '--match-head-commit', j.stamp_commit]); - if (!m.ok) return outFailed({ code: 'QUEUE_NOT_READY', message: `gh pr merge refused: ${(m.err ?? m.out).trim().slice(0, 200)}` }, lines, `${Q} train land --train ${t.nonce} --pr ${o.pr}`, run); + if (!m.ok) return outFailed({ code: 'QUEUE_NOT_READY', message: `gh pr merge refused: ${(m.err ?? m.out).trim().slice(0, 200)}` }, lines, rerun, run); main = mainNow(); + if ('error' in main) return outPending({ code: 'QUEUE_PROOF_MISSING', message: `merged; ${main.error}` }, lines, rerun, run); if (main.tree !== s.tree) return stopped(env, t, 'QUEUE_TRAIN_TREE_MISMATCH', `after the merge main ${main.sha.slice(0, 12)} is not S${s.index} exactly`, main.sha, lines, true); return confirmMerged(env, t, o.pr, main.sha, s.index, [...lines, `MERGED: #${o.pr} as ${main.sha}; main's tree is S${s.index} exactly`]); } diff --git a/lib/ship-queue-train-stamp.ts b/lib/ship-queue-train-stamp.ts index 16554bd6f..8c23c108b 100644 --- a/lib/ship-queue-train-stamp.ts +++ b/lib/ship-queue-train-stamp.ts @@ -16,6 +16,7 @@ import { parseChangelog } from './changelog-check'; import { restamp, versionAtRef, versionFiles } from './restamp'; import { matchesAny, type ShipPolicy } from './ship-policy'; import { git, type QueueFailure } from './ship-queue-gate'; +import { linkedReleaseFile } from './ship-queue-land'; import { mergeCommit } from './ship-queue-train'; import type { StampedPrefix, TrainPrefix, TrainSlot } from './ship-queue-train-record'; import { buildReceipt } from './tree-receipt'; @@ -77,6 +78,9 @@ export function stampChain(o: StampChainOptions): { stamped: StampedPrefix[]; li let tree = ''; let version = ''; try { + const at = versionAtRef(o.repoRoot, m.commit); + const linked = linkedReleaseFile(wt, o.policy, 'rel' in at ? at.rel : 'VERSION'); + if (linked) return { code: 'QUEUE_TRAIN_STAMP_MISMATCH', message: `S${i + 1} (#${slot.pr}): ${linked} is a symlink in the merged tree; the stamp writes release files only inside the repository` }; const r = restamp({ repoRoot: wt, base: o.base, baseRef: pred, next: true, policyFrom: 'base', allowRepoCommands: o.allowRepoCommands, dryRun: false, now: new Date(`${date}T12:00:00Z`), env: process.env }); if (r.status !== 'stamped') return { code: r.code ?? 'QUEUE_TRAIN_STAMP_MISMATCH', message: `S${i + 1} (#${slot.pr}): restamp ${r.status}: ${r.message ?? ''}`, fix: r.fix }; git(wt, ['add', '-A']); diff --git a/lib/ship-queue-train-verdict.ts b/lib/ship-queue-train-verdict.ts index 3aa226431..ed8fdc3fd 100644 --- a/lib/ship-queue-train-verdict.ts +++ b/lib/ship-queue-train-verdict.ts @@ -217,6 +217,9 @@ function bisectOutcome(env: TrainEnv, t: TrainRecord, b: BisectRecord): TrainOut [...lines, `BISECT: UNATTRIBUTED (${c.all_green ? 'all green' : 'not monotone'})`], nextFor(t), run); } +/** The commits a bisect tests: T0 and the plain prefixes, or for a stamped train T0 and S_1..S_k, the chain its gate ran. */ +const bisectPrefixes = (t: TrainRecord) => t.stamped ? [t.prefixes[0]!, ...t.stamped] : t.prefixes; + /** Validate bisect inputs: red cases from the gate's own plan, every prefix an ancestor of train/'s head. */ function bisectInputs(env: TrainEnv, t: TrainRecord, red: AttemptRecord): string | null { for (const id of red.red_cases) { @@ -227,7 +230,7 @@ function bisectInputs(env: TrainEnv, t: TrainRecord, red: AttemptRecord): string if (!f.ok) return `cannot fetch ${t.branch}: ${f.err.slice(0, 120)}`; const tip = git(env.ctx.repoRoot, ['rev-parse', ref]).out; if (tip !== t.head) return `${t.branch} is at ${tip.slice(0, 12)}, not the recorded train head ${t.head.slice(0, 12)}`; - for (const p of t.prefixes) { + for (const p of bisectPrefixes(t)) { if (!git(env.ctx.repoRoot, ['merge-base', '--is-ancestor', p.commit, tip]).ok) return `prefix T${p.index} ${p.commit.slice(0, 12)} is not an ancestor of ${t.branch}`; } return null; @@ -253,7 +256,7 @@ export async function trainBisect(ctx: QueueContext, o: { train: string; spendCa const bad = bisectInputs(env, t, red); if (bad) return outRefused({ code: 'QUEUE_TRAIN_PREFIX_INVALID', message: bad }, [], `${Q} train status --train ${t.nonce}`, run); const b: BisectRecord = prior ?? { schema: 1, kind: 'train-bisect', train: t.nonce, cases: red.red_cases, spend_cap: o.spendCap, spent_usd: 0, - prefixes: t.prefixes.map(p => ({ index: p.index, sha: p.commit, results: {}, attempts: [] })), outcome: null, updated_at: nowIso() }; + prefixes: bisectPrefixes(t).map(p => ({ index: p.index, sha: p.commit, results: {}, attempts: [] })), outcome: null, updated_at: nowIso() }; b.spend_cap = o.spendCap; const slug = repoSlug(ctx); if (!slug) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: 'gh repo view could not name the repository' }, [], `${Q} train bisect --train ${t.nonce} --spend-cap ${o.spendCap}`, run); diff --git a/lib/ship-queue-train.ts b/lib/ship-queue-train.ts index a55a36c2b..bcfad7447 100644 --- a/lib/ship-queue-train.ts +++ b/lib/ship-queue-train.ts @@ -22,6 +22,7 @@ import { baseOverlapLine } from './pr-wait'; import { versionAtRef, versionFiles } from './restamp'; import type { LoadedPolicy, ShipPolicy, TrainPolicy } from './ship-policy'; import { loadBasePolicy, type QueueContext } from './ship-queue'; +import { mergeManifest } from './ship-queue-land'; import { bindShippedReceipt } from './ship-receipt-depth'; import { git, sha256, withIndex, type QueueFailure } from './ship-queue-gate'; import { recordVerdict, screenVerdict, verdictLines } from './ship-queue-screen'; @@ -144,7 +145,8 @@ function unionChangelog(ours: string, theirs: string): string { /** * One train merge, in object storage: the PR head onto the previous prefix. - * Version files take the prefix's side; the CHANGELOG merges by section (the + * VERSION takes the prefix's side, a manifest merges three-way with the prefix's + * version (mergeManifest), and the CHANGELOG merges by section (the * gate-ahead rule with the merge base, which also carries a stacked child * past its released parent, else a union of sections, since each heading is * restamped at merge; the union takes the carried side too, so a prefix that @@ -185,7 +187,14 @@ export function mergeStep(repoRoot: string, pred: string, head: string, policy: const own = (base && carryReleasedParent(text(ours), text(theirs), text(base))?.text) || text(ours); const blob = git(repoRoot, ['hash-object', '-w', '--stdin'], undefined, merged ?? unionChangelog(own, text(theirs))); if (blob.ok) resolved.push({ path: file, blob: blob.out }); else conflicts.push(file); - } else if (stampFiles.includes(file)) resolved.push({ path: file, blob: theirs }); + } else if (file === stampFiles[0]) resolved.push({ path: file, blob: theirs }); + else if (stampFiles.includes(file)) { + const base = entry.get(1); + const text = (sha: string) => git(repoRoot, ['cat-file', 'blob', sha]).out + '\n'; + const merged = base ? mergeManifest(text(base), text(ours), text(theirs)) : null; + const blob = merged === null ? null : git(repoRoot, ['hash-object', '-w', '--stdin'], undefined, merged); + if (blob?.ok) resolved.push({ path: file, blob: blob.out }); else conflicts.push(file); + } else conflicts.push(file); } if (conflicts.length) return { conflicts: conflicts.sort() }; diff --git a/lib/version-gate-train.ts b/lib/version-gate-train.ts index 9ef0790e5..90a4f7ff3 100644 --- a/lib/version-gate-train.ts +++ b/lib/version-gate-train.ts @@ -119,7 +119,7 @@ export function classifyTrainHead(o: { repoRoot: string; baseRef: string; head: const second = git(o.repoRoot, ['rev-parse', '--verify', '--quiet', `${o.head}^2`])?.trim(); const pin = second ? `${o.head}^1` : mergeBase; if (fragDir && (!second || git(o.repoRoot, ['merge-base', '--is-ancestor', second, base]) === null)) return { kind: 'invalid', reason: `under changelog_fragments only the merge step stamps (a commit with parents [pin, ${base}]); leave VERSION, package.json and the release outputs at the merge base's values and add a fragment` }; - const folded = fragDir ? readFragments(o.repoRoot, pin, fragDir, base) : null; + const folded = fragDir ? readFragments(o.repoRoot, pin, fragDir, base, typeof policy.changelog === 'string' ? policy.changelog : 'CHANGELOG.md', 'skip') : null; if (folded?.errors.length) return { kind: 'invalid', reason: `${folded.errors[0]!.path}: ${folded.errors[0]!.message}` }; const level = policy.bump === 'patch' ? 'patch' : 'micro'; const expected = bumpVersion(baseVersion, folded ? effectiveBump(level, folded.fragments) : (policy.bump ?? 'patch')); diff --git a/test/ship-queue-train-land.test.ts b/test/ship-queue-train-land.test.ts index 896eb1f68..68818f998 100644 --- a/test/ship-queue-train-land.test.ts +++ b/test/ship-queue-train-land.test.ts @@ -10,7 +10,7 @@ import * as path from 'node:path'; import { EXIT } from '../lib/headless-artifacts'; import { cleanup } from './helpers/restamp-fixture'; import { - GREEN_GATE, TRAIN_POLICY, line, nonceOf, openPr, queue, readyReceipt, shippedBody, trainFixture, trainJson, type TrainFixture, + GREEN_GATE, RED_GATE, TRAIN_POLICY, line, nonceOf, openPr, queue, readyReceipt, shippedBody, trainFixture, trainJson, type TrainFixture, } from './helpers/ship-queue-train-fixture'; const POLICY = { ...TRAIN_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.92.0.0' }; @@ -51,11 +51,11 @@ function fragmentPr(fx: TrainFixture, pr: string, fragment = `- #${pr} change\n` readyReceipt(fx, pr, head); return head; } -function stampedTrain() { +function stampedTrain(gate = GREEN_GATE) { const fx = trainFixture(POLICY, 'gstack-train-land-'); fixtures.push(fx); wrap(fx); const h1 = fragmentPr(fx, '1'); const h2 = fragmentPr(fx, '2', '---\nbump: minor\n---\n- #2 change\n'); - fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; }); + fx.write(d => { d.rules['evals.yml'] = gate; }); const built = queue(fx, ['train', 'build', '--prs', `1@${h1},2@${h2}`]); const nonce = nonceOf(built.out); return { fx, h1, h2, built, nonce }; @@ -118,6 +118,49 @@ describe('stamped trains: build and land', () => { expect(line(drift.out, 'QUEUE_TRAIN_FROZEN:')).toMatch(/^QUEUE_TRAIN_FROZEN: train \S+ merged #1/); }, 300_000); + test('a stamp commit that cannot be written pushes nothing and leaves the PR branch in place', () => { + const { fx, h1, nonce } = stampedTrain(); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + const file = path.join(fx.state, 'ship-queue', 'trains', nonce, 'train.json'); + const t = trainJson(fx, nonce); + t.stamped[0].tree = 'f'.repeat(40); + fs.writeFileSync(file, JSON.stringify(t)); + const r = land(fx, nonce, '1'); + expect(r.status).not.toBe(EXIT.ok); + expect(r.out).not.toMatch(/^PUSHED:/m); + expect(fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', 'refs/heads/pr-1')).toBe(h1); + }, 180_000); + + test('a rerun after the stamp push but before the journal recognizes its own stamp and merges', () => { + const { fx, nonce } = stampedTrain(); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + const pushed = land(fx, nonce, '1'); + const stamp = /^PUSHED: ([0-9a-f]{40})/m.exec(pushed.out)![1]!; + fs.rmSync(path.join(fx.state, 'ship-queue', 'trains', nonce, 'land-1.json')); + const resumed = land(fx, nonce, '1'); + expect(resumed.status, resumed.err).toBe(EXIT.ok); + expect(resumed.out).toContain("main's tree is S1 exactly"); + expect(fx.read().prs['1']).toMatchObject({ state: 'MERGED' }); + expect(fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', 'main^{tree}')).toBe(fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', stamp + '^{tree}')); + }, 300_000); + + test('a PR retargeted off the train base refuses before any push', () => { + const { fx, h1, nonce } = stampedTrain(); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + fx.write(d => { (d.prs['1'] as any).baseRefName = 'release'; }); + const r = land(fx, nonce, '1'); + expect(r.err).toMatch(/targets release, not main.*\(QUEUE_NOT_READY\)/); + expect(fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', 'refs/heads/pr-1')).toBe(h1); + }, 180_000); + + test('a red stamped train bisects its stamped chain: T0 and S1..Sk, each an ancestor of the train head', () => { + const { fx, nonce } = stampedTrain(RED_GATE); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.fail); + const dry = queue(fx, ['train', 'bisect', '--train', nonce, '--spend-cap', '5', '--dry-run']); + expect(dry.err).not.toContain('QUEUE_TRAIN_PREFIX_INVALID'); + expect(dry.out).toContain('BISECT: cases c1 prefixes T0,T1,T2 need results'); + }, 180_000); + test('the receipt check is the depth-checked one train build uses: a receipt whose writer records depth but which records none refuses, nothing pushed', () => { const { fx, h1, nonce } = stampedTrain(); expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); diff --git a/test/ship-queue-train-stamp.test.ts b/test/ship-queue-train-stamp.test.ts index 9431bfc21..37caacac4 100644 --- a/test/ship-queue-train-stamp.test.ts +++ b/test/ship-queue-train-stamp.test.ts @@ -82,6 +82,38 @@ describe('stamped train prefixes', () => { expect(out.stamped.map(s => JSON.parse(show(r, s.commit + ':package.json')).version)).toEqual(['1.2.4.0', '1.2.5.0']); }, 60_000); + test('a pin\'s manifest edit beside the version line survives the predecessor\'s bump', () => { + const r = makeFixtureRepo({ policy: POLICY, prefix: 'train-stamp-' }); repos.push(r); + const a = pin(r, 'a', 'origin/main', '- a change\n'); + r.git('checkout', '-q', '-B', 'b', 'origin/main'); + const file = path.join(r.work, 'package.json'); + fs.writeFileSync(file, JSON.stringify({ ...JSON.parse(fs.readFileSync(file, 'utf8')), name: 'fixture-b' }, null, 2) + '\n'); + fs.mkdirSync(path.join(r.work, 'changelog.d'), { recursive: true }); + fs.writeFileSync(path.join(r.work, 'changelog.d', 'b.md'), '- b change\n'); + r.git('add', '-A'); r.git('commit', '-q', '-m', 'b'); + const out = stampChain(chain(r, [['1', a], ['2', r.git('rev-parse', 'HEAD')]])); + if ('code' in out) throw new Error(out.message); + const s1 = JSON.parse(show(r, out.stamped[0]!.commit + ':package.json')); + const s2 = JSON.parse(show(r, out.stamped[1]!.commit + ':package.json')); + expect(s1.version).toBe('1.2.4'); + expect(s2).toMatchObject({ name: 'fixture-b', version: '1.2.5', dependencies: { 'left-pad': '1.2.3' } }); + }, 60_000); + + test('a pin whose CHANGELOG is a symlink refuses before the stamp writes anything outside', () => { + const r = makeFixtureRepo({ policy: POLICY, prefix: 'train-stamp-' }); repos.push(r); + const outside = path.join(r.root, 'outside.md'); + fs.writeFileSync(outside, 'keep\n'); + r.git('checkout', '-q', '-B', 'l', 'origin/main'); + fs.rmSync(path.join(r.work, 'CHANGELOG.md')); + fs.symlinkSync(outside, path.join(r.work, 'CHANGELOG.md')); + fs.mkdirSync(path.join(r.work, 'changelog.d'), { recursive: true }); + fs.writeFileSync(path.join(r.work, 'changelog.d', 'l.md'), '- l change\n'); + r.git('add', '-A'); r.git('commit', '-q', '-m', 'l'); + const out = stampChain(chain(r, [['1', r.git('rev-parse', 'HEAD')]])); + expect('code' in out ? out.message : 'stamped').toMatch(/CHANGELOG\.md is a symlink in the merged tree/); + expect(fs.readFileSync(outside, 'utf8')).toBe('keep\n'); + }, 60_000); + test('a tampered earlier version or a product change in S_i is refused', () => { const r = makeFixtureRepo({ policy: POLICY, prefix: 'train-stamp-' }); repos.push(r); const a = pin(r, 'a', 'origin/main', '- a change\n'); diff --git a/test/version-gate-fragments.test.ts b/test/version-gate-fragments.test.ts index 8f65a748f..76eb21081 100644 --- a/test/version-gate-fragments.test.ts +++ b/test/version-gate-fragments.test.ts @@ -97,6 +97,32 @@ describe('version gate: changelog fragments', () => { expect(wrong.out).toContain('must carry v1.3.0.0'); }); + test('a stacked child\'s stamp counts only its own fragment once its parent has landed', () => { + const r = fixture(); + const release = (version: string, line: string) => write(r, 'CHANGELOG.md', fs.readFileSync(path.join(r.work, 'CHANGELOG.md'), 'utf8').replace('# Changelog\n\n', '# Changelog\n\n## [' + version + '] - 2026-10-29\n\n' + line + '\n\n')); + r.git('checkout', '-q', '-b', 'p'); + write(r, 'changelog.d/p.md', '---\nbump: minor\n---\n- p\n'); + commit(r, 'p'); + r.git('checkout', '-q', '-b', 'c'); + write(r, 'changelog.d/c.md', '- c\n'); + const child = commit(r, 'c'); + r.git('checkout', '-q', '-B', 'main', 'origin/main'); + write(r, 'VERSION', '1.3.0.0\n'); + release('1.3.0.0', '- p'); + commit(r, 'v1.3.0.0 (#p)'); + r.git('push', '-q', 'origin', 'main'); + r.git('checkout', '-q', '-f', '--detach', child); + fs.rmSync(path.join(r.work, 'changelog.d'), { recursive: true }); + r.git('checkout', 'origin/main', '--', 'CHANGELOG.md'); + write(r, 'VERSION', '1.3.1.0\n'); + release('1.3.1.0', '- c'); + r.git('add', '-A'); + const stamp = r.git('commit-tree', r.git('write-tree'), '-p', child, '-p', r.git('rev-parse', 'origin/main'), '-m', 'v1.3.1.0 stamp'); + const res = run(r, stamp); + expect(res.code, res.out).toBe(0); + expect(res.out).toContain('carries v1.3.1.0'); + }); + test('the workflow runs on every PR (no path filter), so a PR without a release file still proves its fragment', () => { const workflow = Bun.YAML.parse(fs.readFileSync(path.join(ROOT, '.github', 'workflows', 'version-gate.yml'), 'utf8')) as any; expect(workflow.on.pull_request.paths).toBeUndefined(); From 739a37409002365fe2a85c5f701e42bcf1f1e91a Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 08:52:02 +0000 Subject: [PATCH 25/31] fix(train-land): a rerun whose head is still the pin re-enters the stamp step after a journaled, unpushed stamp Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- lib/ship-queue-train-land.ts | 2 +- test/ship-queue-train-land.test.ts | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/lib/ship-queue-train-land.ts b/lib/ship-queue-train-land.ts index 475be1697..d8d248529 100644 --- a/lib/ship-queue-train-land.ts +++ b/lib/ship-queue-train-land.ts @@ -70,7 +70,7 @@ export async function trainLand(ctx: QueueContext, o: { train: string; pr: strin const retargeted = (b?: string) => b && b !== ctx.base ? outRefused({ code: 'QUEUE_NOT_READY', message: `#${o.pr} targets ${b}, not ${ctx.base}; train land merges only PRs on the base the train stamped` }, lines, `retarget #${o.pr} to ${ctx.base}, then ${rerun}`, run) : null; const wrongBase = retargeted(info.baseRefName); if (wrongBase) return wrongBase; - if (!j || j.base !== main.sha) { + if (!j || j.base !== main.sha || info.headRefOid !== j.stamp_commit) { const commit = git(root, ['commit-tree', s.tree, '-p', pin, '-p', main.sha, '-m', `v${s.version} stamp: train ${t.nonce} S${s.index} (#${o.pr})`], { ...process.env, ...STAMP_IDENTITY }).out; if (!isSha(commit)) return outFailed({ code: 'QUEUE_LAND_CONFLICT', message: `cannot create the stamp commit for tree ${s.tree.slice(0, 12)}; nothing pushed` }, lines, `${Q} train status --train ${t.nonce}`, run); if (![pin, commit, j?.stamp_commit].includes(info.headRefOid)) return stopped(env, t, 'QUEUE_TRAIN_MOVED', `#${o.pr}'s head is ${info.headRefOid.slice(0, 12)}, not its pin ${pin.slice(0, 12)}`, main.sha, lines, true); diff --git a/test/ship-queue-train-land.test.ts b/test/ship-queue-train-land.test.ts index 68818f998..eaf569e6d 100644 --- a/test/ship-queue-train-land.test.ts +++ b/test/ship-queue-train-land.test.ts @@ -144,6 +144,17 @@ describe('stamped trains: build and land', () => { expect(fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', 'main^{tree}')).toBe(fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', stamp + '^{tree}')); }, 300_000); + test('a rerun after the journal but before the stamp push pushes the stamp instead of stopping', () => { + const { fx, h1, nonce } = stampedTrain(); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + const stamp = /^PUSHED: ([0-9a-f]{40})/m.exec(land(fx, nonce, '1').out)![1]!; + fx.repo.git('push', '-q', '-f', 'origin', `${h1}:refs/heads/pr-1`); + const again = land(fx, nonce, '1'); + expect(again.err).not.toContain('QUEUE_TRAIN_MOVED'); + expect(again.out).toContain(`PUSHED: ${stamp}`); + expect(land(fx, nonce, '1').status).toBe(EXIT.ok); + }, 300_000); + test('a PR retargeted off the train base refuses before any push', () => { const { fx, h1, nonce } = stampedTrain(); expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); From 472dd924ae35ba190deedf37525bf141cb126ab6 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 09:01:05 +0000 Subject: [PATCH 26/31] fix(train-land): prove the record's S_i is on the gated chain before stamping; a merge main does not show yet pends Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- lib/ship-queue-train-land.ts | 2 ++ lib/ship-queue-train-stamp.ts | 3 ++- test/ship-queue-train-land.test.ts | 4 ++-- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/ship-queue-train-land.ts b/lib/ship-queue-train-land.ts index d8d248529..c3d0e8e92 100644 --- a/lib/ship-queue-train-land.ts +++ b/lib/ship-queue-train-land.ts @@ -56,6 +56,7 @@ export async function trainLand(ctx: QueueContext, o: { train: string; pr: strin }; const rerun = `${Q} train land --train ${t.nonce} --pr ${o.pr}`; const lines = [`TRAIN_LAND: ${t.nonce} slot ${i + 1}/${t.slots.length} #${o.pr} pin ${pin} -> S${s.index} v${s.version} tree ${s.tree}`]; + if (git(root, ['rev-parse', `${s.commit}^{tree}`]).out !== s.tree || !git(root, ['merge-base', '--is-ancestor', s.commit, t.head]).ok) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `the record's S${s.index} is not a commit with tree ${s.tree.slice(0, 12)} on the gated head ${t.head.slice(0, 12)}` }, lines, `${Q} train status --train ${t.nonce}`, run); let main = mainNow(); if ('error' in main) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: main.error }, lines, rerun, run); if (main.tree === s.tree) return confirmMerged(env, t, o.pr, main.sha, s.index, [...lines, `main: ${main.sha} is S${s.index} exactly`]); @@ -105,6 +106,7 @@ export async function trainLand(ctx: QueueContext, o: { train: string; pr: strin if (!m.ok) return outFailed({ code: 'QUEUE_NOT_READY', message: `gh pr merge refused: ${(m.err ?? m.out).trim().slice(0, 200)}` }, lines, rerun, run); main = mainNow(); if ('error' in main) return outPending({ code: 'QUEUE_PROOF_MISSING', message: `merged; ${main.error}` }, lines, rerun, run); + if (main.sha === j.base) return outPending({ code: 'QUEUE_NOT_READY', message: `merged; main still reads ${j.base.slice(0, 12)}, so the merge is not visible yet` }, lines, rerun, run); if (main.tree !== s.tree) return stopped(env, t, 'QUEUE_TRAIN_TREE_MISMATCH', `after the merge main ${main.sha.slice(0, 12)} is not S${s.index} exactly`, main.sha, lines, true); return confirmMerged(env, t, o.pr, main.sha, s.index, [...lines, `MERGED: #${o.pr} as ${main.sha}; main's tree is S${s.index} exactly`]); } diff --git a/lib/ship-queue-train-stamp.ts b/lib/ship-queue-train-stamp.ts index 8c23c108b..4a7d7faee 100644 --- a/lib/ship-queue-train-stamp.ts +++ b/lib/ship-queue-train-stamp.ts @@ -83,8 +83,9 @@ export function stampChain(o: StampChainOptions): { stamped: StampedPrefix[]; li if (linked) return { code: 'QUEUE_TRAIN_STAMP_MISMATCH', message: `S${i + 1} (#${slot.pr}): ${linked} is a symlink in the merged tree; the stamp writes release files only inside the repository` }; const r = restamp({ repoRoot: wt, base: o.base, baseRef: pred, next: true, policyFrom: 'base', allowRepoCommands: o.allowRepoCommands, dryRun: false, now: new Date(`${date}T12:00:00Z`), env: process.env }); if (r.status !== 'stamped') return { code: r.code ?? 'QUEUE_TRAIN_STAMP_MISMATCH', message: `S${i + 1} (#${slot.pr}): restamp ${r.status}: ${r.message ?? ''}`, fix: r.fix }; - git(wt, ['add', '-A']); + if (!git(wt, ['add', '-A']).ok) return { code: 'QUEUE_TRAIN_STAMP_MISMATCH', message: `cannot stage the stamp for S${i + 1}` }; tree = git(wt, ['write-tree']).out; + if (!/^[0-9a-f]{40,64}$/.test(tree)) return { code: 'QUEUE_TRAIN_STAMP_MISMATCH', message: `cannot write the stamped tree for S${i + 1}` }; version = r.newVersion!; } finally { git(o.repoRoot, ['worktree', 'remove', '--force', wt]); diff --git a/test/ship-queue-train-land.test.ts b/test/ship-queue-train-land.test.ts index eaf569e6d..b02e1936a 100644 --- a/test/ship-queue-train-land.test.ts +++ b/test/ship-queue-train-land.test.ts @@ -118,7 +118,7 @@ describe('stamped trains: build and land', () => { expect(line(drift.out, 'QUEUE_TRAIN_FROZEN:')).toMatch(/^QUEUE_TRAIN_FROZEN: train \S+ merged #1/); }, 300_000); - test('a stamp commit that cannot be written pushes nothing and leaves the PR branch in place', () => { + test('a record whose S_i tree is not on the gated chain pushes nothing and leaves the PR branch in place', () => { const { fx, h1, nonce } = stampedTrain(); expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); const file = path.join(fx.state, 'ship-queue', 'trains', nonce, 'train.json'); @@ -126,7 +126,7 @@ describe('stamped trains: build and land', () => { t.stamped[0].tree = 'f'.repeat(40); fs.writeFileSync(file, JSON.stringify(t)); const r = land(fx, nonce, '1'); - expect(r.status).not.toBe(EXIT.ok); + expect(r.err).toMatch(/is not a commit with tree ffffffffffff on the gated head.*\(QUEUE_PROOF_MISSING\)/); expect(r.out).not.toMatch(/^PUSHED:/m); expect(fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', 'refs/heads/pr-1')).toBe(h1); }, 180_000); From 47b93f8dc25a804d5dc02cf1f9e552f4fa6c1098 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 09:10:06 +0000 Subject: [PATCH 27/31] fix(train-land): pin S_i to the gated head's exact prefix; an out-of-order slot refuses without freezing; stamps never sign Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- lib/ship-queue-train-land.ts | 7 +++++-- lib/ship-queue-train-stamp.ts | 2 +- test/ship-queue-train-land.test.ts | 21 ++++++++++++++++++--- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/lib/ship-queue-train-land.ts b/lib/ship-queue-train-land.ts index c3d0e8e92..ef193cdf1 100644 --- a/lib/ship-queue-train-land.ts +++ b/lib/ship-queue-train-land.ts @@ -56,10 +56,13 @@ export async function trainLand(ctx: QueueContext, o: { train: string; pr: strin }; const rerun = `${Q} train land --train ${t.nonce} --pr ${o.pr}`; const lines = [`TRAIN_LAND: ${t.nonce} slot ${i + 1}/${t.slots.length} #${o.pr} pin ${pin} -> S${s.index} v${s.version} tree ${s.tree}`]; - if (git(root, ['rev-parse', `${s.commit}^{tree}`]).out !== s.tree || !git(root, ['merge-base', '--is-ancestor', s.commit, t.head]).ok) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `the record's S${s.index} is not a commit with tree ${s.tree.slice(0, 12)} on the gated head ${t.head.slice(0, 12)}` }, lines, `${Q} train status --train ${t.nonce}`, run); + if (git(root, ['rev-parse', `${t.head}~${t.stamped.length - s.index}`]).out !== s.commit || git(root, ['rev-parse', `${s.commit}^{tree}`]).out !== s.tree) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `the record's S${s.index} is not the gated head ${t.head.slice(0, 12)}'s prefix ${s.index} with tree ${s.tree.slice(0, 12)}` }, lines, `${Q} train status --train ${t.nonce}`, run); let main = mainNow(); if ('error' in main) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: main.error }, lines, rerun, run); if (main.tree === s.tree) return confirmMerged(env, t, o.pr, main.sha, s.index, [...lines, `main: ${main.sha} is S${s.index} exactly`]); + const mainTree = main.tree; + const earlier = t.stamped.slice(0, Math.max(i - 1, 0)).find(p => p.tree === mainTree); + if (earlier) return outRefused({ code: 'QUEUE_TRAIN_TREE_MISMATCH', message: `main is S${earlier.index}; land #${t.stamped[earlier.index]!.pr} (slot ${earlier.index + 1}) first` }, lines, `${Q} train land --train ${t.nonce} --pr ${t.stamped[earlier.index]!.pr}`, run); if (main.tree !== pred.tree) return stopped(env, t, 'QUEUE_TRAIN_TREE_MISMATCH', `main ${main.sha.slice(0, 12)} is neither S${pred.index} nor S${s.index} exactly`, main.sha, lines, true); const file = path.join(path.dirname(run), `land-${o.pr}.json`); @@ -72,7 +75,7 @@ export async function trainLand(ctx: QueueContext, o: { train: string; pr: strin const wrongBase = retargeted(info.baseRefName); if (wrongBase) return wrongBase; if (!j || j.base !== main.sha || info.headRefOid !== j.stamp_commit) { - const commit = git(root, ['commit-tree', s.tree, '-p', pin, '-p', main.sha, '-m', `v${s.version} stamp: train ${t.nonce} S${s.index} (#${o.pr})`], { ...process.env, ...STAMP_IDENTITY }).out; + const commit = git(root, ['commit-tree', '--no-gpg-sign', s.tree, '-p', pin, '-p', main.sha, '-m', `v${s.version} stamp: train ${t.nonce} S${s.index} (#${o.pr})`], { ...process.env, ...STAMP_IDENTITY }).out; if (!isSha(commit)) return outFailed({ code: 'QUEUE_LAND_CONFLICT', message: `cannot create the stamp commit for tree ${s.tree.slice(0, 12)}; nothing pushed` }, lines, `${Q} train status --train ${t.nonce}`, run); if (![pin, commit, j?.stamp_commit].includes(info.headRefOid)) return stopped(env, t, 'QUEUE_TRAIN_MOVED', `#${o.pr}'s head is ${info.headRefOid.slice(0, 12)}, not its pin ${pin.slice(0, 12)}`, main.sha, lines, true); const shipped = bindShippedReceipt(root, `refs/remotes/${env.remote}/${ctx.base}`, info.body, o.pr, [pin]); diff --git a/lib/ship-queue-train-stamp.ts b/lib/ship-queue-train-stamp.ts index 4a7d7faee..19549fe83 100644 --- a/lib/ship-queue-train-stamp.ts +++ b/lib/ship-queue-train-stamp.ts @@ -91,7 +91,7 @@ export function stampChain(o: StampChainOptions): { stamped: StampedPrefix[]; li git(o.repoRoot, ['worktree', 'remove', '--force', wt]); fs.rmSync(wt, { recursive: true, force: true }); } - const c = git(o.repoRoot, ['commit-tree', tree, '-p', pred, '-p', slot.head, '-m', `train S${i + 1}: merge #${slot.pr} at ${slot.head.slice(0, 12)} v${version}`], { ...process.env, ...IDENTITY }); + const c = git(o.repoRoot, ['commit-tree', '--no-gpg-sign', tree, '-p', pred, '-p', slot.head, '-m', `train S${i + 1}: merge #${slot.pr} at ${slot.head.slice(0, 12)} v${version}`], { ...process.env, ...IDENTITY }); if (!c.ok) return { code: 'QUEUE_TRAIN_STAMP_MISMATCH', message: `commit-tree failed for S${i + 1}: ${c.err.slice(0, 120)}` }; const plain = o.plain[i + 1]; const why = plain ? stampMismatch(o, m.commit, c.out, plain.tree) : `no plain prefix T${i + 1}`; diff --git a/test/ship-queue-train-land.test.ts b/test/ship-queue-train-land.test.ts index b02e1936a..82033eaee 100644 --- a/test/ship-queue-train-land.test.ts +++ b/test/ship-queue-train-land.test.ts @@ -118,15 +118,16 @@ describe('stamped trains: build and land', () => { expect(line(drift.out, 'QUEUE_TRAIN_FROZEN:')).toMatch(/^QUEUE_TRAIN_FROZEN: train \S+ merged #1/); }, 300_000); - test('a record whose S_i tree is not on the gated chain pushes nothing and leaves the PR branch in place', () => { + test('a record whose S_i is not the gated head\'s prefix i pushes nothing and leaves the PR branch in place', () => { const { fx, h1, nonce } = stampedTrain(); expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); const file = path.join(fx.state, 'ship-queue', 'trains', nonce, 'train.json'); const t = trainJson(fx, nonce); - t.stamped[0].tree = 'f'.repeat(40); + t.stamped[0] = { ...t.stamped[0], commit: h1, tree: fx.repo.git('rev-parse', h1 + '^{tree}') }; fs.writeFileSync(file, JSON.stringify(t)); const r = land(fx, nonce, '1'); - expect(r.err).toMatch(/is not a commit with tree ffffffffffff on the gated head.*\(QUEUE_PROOF_MISSING\)/); + expect(r.status).toBe(EXIT.refused); + expect(r.err).toMatch(/is not the gated head \S+'s prefix 1 with tree.*\(QUEUE_PROOF_MISSING\)/); expect(r.out).not.toMatch(/^PUSHED:/m); expect(fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', 'refs/heads/pr-1')).toBe(h1); }, 180_000); @@ -155,6 +156,20 @@ describe('stamped trains: build and land', () => { expect(land(fx, nonce, '1').status).toBe(EXIT.ok); }, 300_000); + test('a slot landed out of order after an earlier slot merged refuses without freezing and names the next slot', () => { + const fx = trainFixture(POLICY, 'gstack-train-land-'); fixtures.push(fx); wrap(fx); + const heads = ['1', '2', '3'].map(pr => fragmentPr(fx, pr)); + fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; }); + const nonce = nonceOf(queue(fx, ['train', 'build', '--prs', heads.map((h, i) => `${i + 1}@${h}`).join(',')]).out); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + land(fx, nonce, '1'); + expect(land(fx, nonce, '1').status).toBe(EXIT.ok); + const early = land(fx, nonce, '3'); + expect(early.err).toMatch(/main is S1; land #2 \(slot 2\) first.*\(QUEUE_TRAIN_TREE_MISMATCH\)/); + expect(early.out).not.toMatch(/^QUEUE_TRAIN_FROZEN:/m); + expect(line(early.out, 'NEXT:')).toBe(`NEXT: gstack-ship-queue train land --train ${nonce} --pr 2`); + }, 300_000); + test('a PR retargeted off the train base refuses before any push', () => { const { fx, h1, nonce } = stampedTrain(); expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); From b1c6e9f1d972e04ead22a3caba3cc55a8ff3dc5f Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 09:20:06 +0000 Subject: [PATCH 28/31] fix(train-land): pin S_i to slot position; a failed push or unreadable PR never reads as a move; reruns of landed slots confirm; T0 in the out-of-order refusal Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- lib/ship-queue-train-land.ts | 19 +++++++++---- test/ship-queue-train-land.test.ts | 43 ++++++++++++++++++++++++------ 2 files changed, 49 insertions(+), 13 deletions(-) diff --git a/lib/ship-queue-train-land.ts b/lib/ship-queue-train-land.ts index ef193cdf1..6156132be 100644 --- a/lib/ship-queue-train-land.ts +++ b/lib/ship-queue-train-land.ts @@ -56,13 +56,16 @@ export async function trainLand(ctx: QueueContext, o: { train: string; pr: strin }; const rerun = `${Q} train land --train ${t.nonce} --pr ${o.pr}`; const lines = [`TRAIN_LAND: ${t.nonce} slot ${i + 1}/${t.slots.length} #${o.pr} pin ${pin} -> S${s.index} v${s.version} tree ${s.tree}`]; - if (git(root, ['rev-parse', `${t.head}~${t.stamped.length - s.index}`]).out !== s.commit || git(root, ['rev-parse', `${s.commit}^{tree}`]).out !== s.tree) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `the record's S${s.index} is not the gated head ${t.head.slice(0, 12)}'s prefix ${s.index} with tree ${s.tree.slice(0, 12)}` }, lines, `${Q} train status --train ${t.nonce}`, run); + if (t.stamped.length !== t.slots.length || s.index !== i + 1 || git(root, ['rev-parse', `${t.head}~${t.slots.length - i - 1}`]).out !== s.commit || git(root, ['rev-parse', `${s.commit}^{tree}`]).out !== s.tree) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `the record's S${i + 1} is not the gated head ${t.head.slice(0, 12)}'s prefix ${i + 1} with tree ${s.tree.slice(0, 12)}` }, lines, `${Q} train status --train ${t.nonce}`, run); let main = mainNow(); if ('error' in main) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: main.error }, lines, rerun, run); if (main.tree === s.tree) return confirmMerged(env, t, o.pr, main.sha, s.index, [...lines, `main: ${main.sha} is S${s.index} exactly`]); const mainTree = main.tree; - const earlier = t.stamped.slice(0, Math.max(i - 1, 0)).find(p => p.tree === mainTree); - if (earlier) return outRefused({ code: 'QUEUE_TRAIN_TREE_MISMATCH', message: `main is S${earlier.index}; land #${t.stamped[earlier.index]!.pr} (slot ${earlier.index + 1}) first` }, lines, `${Q} train land --train ${t.nonce} --pr ${t.stamped[earlier.index]!.pr}`, run); + const chain = [{ index: 0, tree: t.prefixes[0]!.tree }, ...t.stamped]; + const earlier = chain.slice(0, i).find(p => p.tree === mainTree); + if (earlier) return outRefused({ code: 'QUEUE_TRAIN_TREE_MISMATCH', message: `main is ${earlier.index ? `S${earlier.index}` : 'the train base'}; land #${t.slots[earlier.index]!.pr} (slot ${earlier.index + 1}) first` }, lines, `${Q} train land --train ${t.nonce} --pr ${t.slots[earlier.index]!.pr}`, run); + const later = chain.slice(i + 2).find(p => p.tree === mainTree); + if (later) return confirmMerged(env, t, o.pr, main.sha, s.index, [...lines, `main: ${main.sha} is S${later.index}, past S${s.index}`]); if (main.tree !== pred.tree) return stopped(env, t, 'QUEUE_TRAIN_TREE_MISMATCH', `main ${main.sha.slice(0, 12)} is neither S${pred.index} nor S${s.index} exactly`, main.sha, lines, true); const file = path.join(path.dirname(run), `land-${o.pr}.json`); @@ -88,7 +91,12 @@ export async function trainLand(ctx: QueueContext, o: { train: string; pr: strin const pushed = info.headRefOid !== commit; if (pushed) { const push = git(root, ['push', '-q', `--force-with-lease=refs/heads/${info.headRefName}:${info.headRefOid}`, env.remote, `${commit}:refs/heads/${info.headRefName}`]); - if (!push.ok) return stopped(env, t, 'QUEUE_TRAIN_MOVED', `#${o.pr}'s branch moved off ${info.headRefOid.slice(0, 12)} before the stamp push; train land never overwrites it`, main.sha, lines, true); + if (!push.ok) { + const remote = git(root, ['ls-remote', env.remote, `refs/heads/${info.headRefName}`]); + const now = remote.ok ? remote.out.split(/\s/)[0] ?? '' : ''; + if (remote.ok && now !== info.headRefOid) return stopped(env, t, 'QUEUE_TRAIN_MOVED', `#${o.pr}'s branch moved off ${info.headRefOid.slice(0, 12)} before the stamp push; train land never overwrites it`, main.sha, lines, true); + return outFailed({ code: 'QUEUE_NOT_READY', message: `the stamp push failed and ${info.headRefName} still reads ${remote.ok ? info.headRefOid.slice(0, 12) : 'unknown'}: ${push.err.split('\n')[0]}` }, lines, rerun, run); + } } lines.push(`${pushed ? 'PUSHED' : 'RESUMED'}: ${commit} (tree S${s.index}, parents [pin, main ${main.sha.slice(0, 12)}]) ${pushed ? 'to' : 'is already the head of'} ${info.headRefName}`); const next = `gstack-pr-wait ${o.pr} --until green && ${Q} train land --train ${t.nonce} --pr ${o.pr}`; @@ -96,7 +104,8 @@ export async function trainLand(ctx: QueueContext, o: { train: string; pr: strin if (!o.noWait) waitChecks(ctx, commit, o.timeoutMin ?? 60); } const head = prInfo(ctx, o.pr); - if ('error' in head || head.headRefOid !== j.stamp_commit) return stopped(env, t, 'QUEUE_TRAIN_MOVED', `#${o.pr}'s head is not the stamp commit ${j.stamp_commit.slice(0, 12)}`, main.sha, lines, true); + if ('error' in head) return outPending({ code: 'QUEUE_PROOF_MISSING', message: head.error }, lines, rerun, run); + if (head.headRefOid !== j.stamp_commit) return stopped(env, t, 'QUEUE_TRAIN_MOVED', `#${o.pr}'s head is not the stamp commit ${j.stamp_commit.slice(0, 12)}`, main.sha, lines, true); const movedBase = retargeted(head.baseRefName); if (movedBase) return movedBase; const ci = ciAtHead(ctx, j.stamp_commit); diff --git a/test/ship-queue-train-land.test.ts b/test/ship-queue-train-land.test.ts index 82033eaee..3602629c0 100644 --- a/test/ship-queue-train-land.test.ts +++ b/test/ship-queue-train-land.test.ts @@ -78,7 +78,7 @@ describe('stamped trains: build and land', () => { expect(verify.err).toMatch(/has stamped prefixes.*\(QUEUE_GATE_MISSING\)/); expect(line(verify.out, 'NEXT:')).toBe(`NEXT: gstack-ship-queue train land --train ${nonce} --pr 1`); const early = land(fx, nonce, '2'); - expect(early.err).toMatch(/is neither S1 nor S2 exactly.*not frozen.*\(QUEUE_TRAIN_TREE_MISMATCH\)/); + expect(early.err).toMatch(/main is the train base; land #1 \(slot 1\) first.*\(QUEUE_TRAIN_TREE_MISMATCH\)/); const pushed = land(fx, nonce, '1'); expect(pushed.out, pushed.err).toContain('LABEL: evals-no-debounce added'); const stamp = /^PUSHED: ([0-9a-f]{40})/m.exec(pushed.out)![1]!; @@ -123,12 +123,21 @@ describe('stamped trains: build and land', () => { expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); const file = path.join(fx.state, 'ship-queue', 'trains', nonce, 'train.json'); const t = trainJson(fx, nonce); - t.stamped[0] = { ...t.stamped[0], commit: h1, tree: fx.repo.git('rev-parse', h1 + '^{tree}') }; - fs.writeFileSync(file, JSON.stringify(t)); - const r = land(fx, nonce, '1'); - expect(r.status).toBe(EXIT.refused); - expect(r.err).toMatch(/is not the gated head \S+'s prefix 1 with tree.*\(QUEUE_PROOF_MISSING\)/); - expect(r.out).not.toMatch(/^PUSHED:/m); + const base = trainJson(fx, nonce).prefixes[0]; + const forged = [ + { ...t.stamped[0], commit: h1, tree: fx.repo.git('rev-parse', h1 + '^{tree}') }, + { ...t.stamped[0], tree: 'f'.repeat(40) }, + { ...t.stamped[1] }, + { ...t.stamped[0], index: 0, commit: base.commit, tree: base.tree }, + ]; + for (const entry of forged) { + fs.writeFileSync(file, JSON.stringify({ ...t, stamped: [entry, t.stamped[1]] })); + const r = land(fx, nonce, '1'); + expect(r.status).toBe(EXIT.refused); + expect(r.err).toMatch(/is not the gated head \S+'s prefix 1 with tree.*\(QUEUE_PROOF_MISSING\)/); + expect(r.out).not.toMatch(/^PUSHED:/m); + } + expect(fx.read().prs['1']).not.toMatchObject({ state: 'MERGED' }); expect(fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', 'refs/heads/pr-1')).toBe(h1); }, 180_000); @@ -156,7 +165,7 @@ describe('stamped trains: build and land', () => { expect(land(fx, nonce, '1').status).toBe(EXIT.ok); }, 300_000); - test('a slot landed out of order after an earlier slot merged refuses without freezing and names the next slot', () => { + test('a slot landed out of order after an earlier slot merged refuses without freezing and names the next slot; a rerun of a landed slot confirms', () => { const fx = trainFixture(POLICY, 'gstack-train-land-'); fixtures.push(fx); wrap(fx); const heads = ['1', '2', '3'].map(pr => fragmentPr(fx, pr)); fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; }); @@ -168,6 +177,24 @@ describe('stamped trains: build and land', () => { expect(early.err).toMatch(/main is S1; land #2 \(slot 2\) first.*\(QUEUE_TRAIN_TREE_MISMATCH\)/); expect(early.out).not.toMatch(/^QUEUE_TRAIN_FROZEN:/m); expect(line(early.out, 'NEXT:')).toBe(`NEXT: gstack-ship-queue train land --train ${nonce} --pr 2`); + land(fx, nonce, '2'); + expect(land(fx, nonce, '2').status).toBe(EXIT.ok); + const rerun = land(fx, nonce, '1'); + expect(rerun.status, rerun.err).toBe(EXIT.ok); + expect(rerun.out).toContain('is S2, past S1'); + expect(rerun.out).not.toMatch(/^QUEUE_TRAIN_FROZEN:/m); + }, 300_000); + + test('a failed stamp push with the branch still at the pin fails retryably instead of reporting a move', () => { + const { fx, nonce } = stampedTrain(); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + const hook = path.join(fx.repo.origin, 'hooks', 'pre-receive'); + fs.writeFileSync(hook, '#!/bin/sh\nexit 1\n', { mode: 0o755 }); + const failed = land(fx, nonce, '1'); + expect(failed.err).toMatch(/the stamp push failed and pr-1 still reads.*\(QUEUE_NOT_READY\)/); + expect(failed.err).not.toContain('QUEUE_TRAIN_MOVED'); + fs.rmSync(hook); + expect(land(fx, nonce, '1').out).toMatch(/^PUSHED: [0-9a-f]{40}/m); }, 300_000); test('a PR retargeted off the train base refuses before any push', () => { From d414d9db24ace0d19ea503bb53c50d95d10a0d37 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 09:30:31 +0000 Subject: [PATCH 29/31] feat(train-land): verify the whole stamped chain from git before landing; require changelog_fragments.required_checks green on the stamp commit Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 21 +++++++++-------- docs/ship-policy.md | 18 +++++++++++---- lib/ship-policy.ts | 10 ++++---- lib/ship-queue-train-land.ts | 26 ++++++++++++++++----- lib/ship-queue.ts | 8 +++---- test/changelog-fragments.test.ts | 5 ++++ test/ship-queue-train-land.test.ts | 37 +++++++++++++++++++++--------- 7 files changed, 87 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d6e6d262..a3a660a9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,26 +5,29 @@ **A repository can now assign its version when a PR merges instead of inside the PR: PRs carry unversioned CHANGELOG fragments and `gstack-ship-queue land` stamps, pushes and merges.** Today every PR picks a version before it merges, so each merge makes the next PR's version stale and its lane redoes the stamp (a median 14.5 minutes from stamp commit to merge over 30 slots, Oct 27-29; the queue froze on hand-made stamps in #3422 and #3487). With `changelog_fragments` on, two PRs cut from one base cannot collide, and nobody stamps by hand. gstack's own policy does not turn it on in this release (docs/ship-policy.md#changelog-fragments). ### Added -- **`changelog_fragments: { dir }`** (`lib/ship-policy.ts`, `lib/changelog-fragments.ts`): a PR adds `/.md` with its entry text and optional `bump: minor|major` front matter. Fragments are read as Git blobs only; a symlink, gitlink, nested path, empty or oversized fragment, one with a release heading, or a reused name is `CHANGELOG_FRAGMENT_INVALID` before any version is allocated. +- **`changelog_fragments: { dir }`** (`lib/ship-policy.ts`, `lib/changelog-fragments.ts`): a PR adds `/.md` with its entry text and optional `bump: minor|major` front matter. Fragments are read as Git blobs only; a symlink, gitlink, nested path, empty or oversized fragment, one with a release heading, a reused name, or one whose text is already released on the base (a parent PR's fragment carried in by stacked history) is `CHANGELOG_FRAGMENT_INVALID` before any version is allocated. - **`gstack-restamp` folds fragments**: their bodies go above any legacy `[Unreleased]` section under one new heading, the files are deleted, and the highest declared bump wins over the policy's. The tree receipt's `changelog-fragments` rule accepts exactly that fold and nothing else. -- **`gstack-ship-queue land --pr `** (`lib/ship-queue-land.ts`): the per-PR merge step. It takes the land lock (`refs/notes/gstack-land`), checks the /ship receipt and checks at the pin, merges the base resolving release fields only (a clean dependency edit survives; a product conflict refuses), stamps, pushes a commit with parents [pin, base] under an exact lease, waits for checks, restamps if the base moved (at most twice), squashes with `--match-head-commit` and confirms the base tree. Every step is journaled and resumable. `--repair` opens the follow-up PR after `QUEUE_LAND_DRIFT`; `--census` prints what land would do with every open PR, writing nothing. +- **`gstack-ship-queue land --pr `** (`lib/ship-queue-land.ts`): the per-PR merge step. It takes the land lock (`refs/notes/gstack-land`), checks the /ship receipt, the READY receipt and checks at the pin, merges the base resolving release fields only (a clean dependency edit survives; a product conflict refuses), stamps, pushes a commit with parents [pin, base] under an exact lease, waits for checks, restamps if the base moved (at most twice), squashes with `--match-head-commit` (merges with `--merge` under `history: merge-only`) and confirms the base tree. A PR whose base is another branch refuses before the lock; a refusal before the push releases the lock. Every step is journaled and resumable, also from the stamp commit's pin when the local journal is gone. `--repair` opens the follow-up PR after `QUEUE_LAND_DRIFT`; `--census` prints what land would do with every open PR, writing nothing. - **/ship writes the fragment** (`lib/ship-policy.ts`, `ship/SKILL.md.tmpl`): under the key, `show-effective` prints a `ship: changelog_fragments` line, so Step 12 writes no version and Step 13 writes `/.md` (linted with `gstack-prose-lint --gate`) instead of an `[Unreleased]` section; the skill's policy paragraph now says its `ship:` lines bind Steps 12–16. - **`restamp_command` gets `GSTACK_RESTAMP_BASE_SHA`**, the release predecessor, so a repo counter such as a SAVE_VERSION computes predecessor + 1 from the base and never from the branch. - New result codes with troubleshooting anchors: `CHANGELOG_FRAGMENT_INVALID`, `QUEUE_LAND_LOCKED`, `QUEUE_LAND_CONFLICT`, `QUEUE_LAND_DRIFT`, `QUEUE_LAND_BASE_BUSY`. -- **Stamped merge trains** (`lib/ship-queue-train-stamp.ts`, `lib/ship-queue-train-land.ts`): under `changelog_fragments`, `train build` also builds S_i = stamp(merge(S_(i-1), pin_i)) for every slot, checks each is the merge modulo stamps, keeps every earlier release entry byte for byte and equals the plain prefix outside release paths (`QUEUE_TRAIN_STAMP_MISMATCH`), and gates S_k. `train land --train --pr ` publishes slot i's S_i exactly: a stamp commit on [pin, main] pushed under the pin's lease with the `evals-no-debounce` label, checks, `--match-head-commit`, main's tree confirmed; any other main freezes the queue as before. `verify --train` refuses a stamped train and names `train land`. +- **Stamped merge trains** (`lib/ship-queue-train-stamp.ts`, `lib/ship-queue-train-land.ts`): under `changelog_fragments`, `train build` also builds S_i = stamp(merge(S_(i-1), pin_i)) for every slot, checks each is the merge modulo stamps, keeps every earlier release entry byte for byte and equals the plain prefix outside release paths (`QUEUE_TRAIN_STAMP_MISMATCH`), refuses a symlinked release file before stamping, and gates S_k. `train land --train --pr ` publishes slot i's S_i exactly. It makes a deterministic stamp commit on [pin, main], journals it before it is pushed under the pin's lease with the `evals-no-debounce` label, waits for checks, merges with `--match-head-commit` and confirms main's tree; a rerun adopts its own stamp head, a PR retargeted off the base refuses, and any other main freezes the queue as before. It first verifies the whole record against git (T0 and S_1..S_k must be the gated head's first-parent chain, each S_i's second parent slot i's pin) and uses only that chain, and it merges only when every name in the new `changelog_fragments.required_checks` is present and green on the stamp commit. `verify --train` refuses a stamped train and names `train land`; `train bisect` of a stamped train tests T0 and S_1..S_k. ### Changed - **The main backstop covers a stamped train's merges by exact tree** (`scripts/train-backstop.ts`, `scripts/train-gate.ts`): a record's S_i prefixes cover main only when its tree is exactly S_i; plain T_i prefixes still cover modulo stamps. -- **Version Gate runs on every PR** (the path filter is gone), so under `changelog_fragments` a PR that touches no release file still proves it carries a fragment. An unstamped head keeps its release fields at its merge base and adds a valid fragment (legacy `[Unreleased]` passes; a `[bot]` author needs none); a merge-step stamp commit must carry the base plus its fragments' effective bump. Without the key nothing changes. +- **A train merge keeps a pin's manifest edits** (`lib/ship-queue-train.ts`): a `package.json` or lockfile conflict against the previous prefix merges three-way with the prefix's version (the same `mergeManifest` land uses) instead of taking the prefix's whole file; anything else that conflicts is a train conflict. +- **Version Gate reads a stacked child's stamp commit by its own fragments**: a parent's fragment the base has already released is skipped rather than counted again. +- **Version Gate runs on every PR** (the path filter is gone), so under `changelog_fragments` a PR that touches no release file still proves it carries a fragment. An unstamped head keeps its release fields at its merge base and adds a valid fragment (legacy `[Unreleased]` passes; a `[bot]` author needs none); a merge-step stamp commit must have parents [pin, base] and carry the base plus its fragments' effective bump, so a lane that stamps its own head fails. Without the key nothing changes. ### Tests -- `test/changelog-fragments.test.ts`: blob-only reads and refusals, bump precedence, fold order with a legacy section, the restamp fold and its tree receipt, an edited fold refused. -- `test/ship-queue-land.test.ts` (bare origin and a gh shim): happy path; two PRs from one base without a collision; a legacy stamp plus a dependency edit; a title-derived entry; a product conflict; the lock and `--break-lock`; the base moving during CI; a foreign push never overwritten; a missing receipt; drift, `--repair` and the lock release; the census; a receipt that fails #3567's depth check refuses; a base with a four-part package.json in `stamp_files` lands four parts (#3557), gstack's own shape stays three. -- `test/version-gate-fragments.test.ts`: fragment heads, premature version changes, a bot author, an advanced base, an invalid fragment, the stamp commit's effective bump, and the workflow's missing path filter. +- `test/changelog-fragments.test.ts`: blob-only reads and refusals, a carried-back fragment refused on whole released lines only, bump precedence, fold order with a legacy section, the restamp fold and its tree receipt, an edited fold refused. +- `test/ship-queue-land.test.ts` (bare origin and a gh shim): happy path; two PRs from one base without a collision; a legacy stamp plus a dependency edit; a title-derived entry; a product conflict; the lock and `--break-lock`; the base moving during CI; a foreign push never overwritten; a missing receipt; drift, `--repair` and the lock release; the census; a receipt that fails #3567's depth check refuses; a base with a four-part package.json in `stamp_files` lands four parts (#3557), gstack's own shape stays three; one run without `--no-wait` merges; a PR on another base refuses; `--break-lock` refuses a drift lock; a conflict releases the lock; a stacked parent's fragment refuses; a lost journal resumes; `merge-only` merges with `--merge`; a merge GitHub completed behind an error reply reconciles; `--break-lock` refuses a holder mid-merge; `--repair` resumes after a failed PR create and its PR takes over from a clean checkout; a symlinked legacy VERSION refuses in `--dry-run`; a `package.json` version source keeps its dependency edits. +- `test/version-gate-fragments.test.ts`: fragment heads, premature version changes, a bot author, an advanced base, an invalid fragment, the stamp commit's effective bump (open version claims do not apply to it), and the workflow's missing path filter. - `test/restamp-command-boundary.test.ts`: the hook reads its counter from `GSTACK_RESTAMP_BASE_SHA`, not the branch. -- `test/ship-queue-train-stamp.test.ts`: S2 keeps S1's release and runs the versions in order with each slot's bump; a stacked child folds only its own fragment; a four-part package.json base keeps four parts in every S_i, read from S_(i-1); a tampered earlier version or a product change is refused. -- `test/ship-queue-train-land.test.ts`: build records S1 and S2 and gates S2; land publishes each slot exactly with the label; a foreign push is never overwritten; a merge outside the train freezes the queue; a plain train refuses `train land`. +- `test/ship-queue-train-stamp.test.ts`: S2 keeps S1's release and runs the versions in order with each slot's bump; a stacked child folds only its own fragment; a four-part package.json base keeps four parts in every S_i, read from S_(i-1); a pin's manifest edit beside the version line survives the previous slot's bump; a symlinked CHANGELOG refuses before anything is written outside; a tampered earlier version or a product change is refused. +- `test/ship-queue-train-land.test.ts`: build records S1 and S2 and gates S2; land publishes each slot exactly with the label; a foreign push is never overwritten; a merge outside the train freezes the queue; a failed stamp commit pushes nothing; a rerun after the push or after the journal resumes; a retargeted PR refuses; a red stamped train bisects its stamped chain; a plain train refuses `train land`. - `test/train-backstop.test.ts`: main equal to S1 is covered; the same tree with another VERSION is gated; a chain mixing stamped and plain prefixes is refused. +- `test/version-gate-fragments.test.ts`: a stacked child's stamp counts only its own fragment once its parent has landed. ## [1.91.312.0] - 2026-10-29 diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 291f63fba..dc40e0d53 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -61,7 +61,7 @@ refuses the file (`POLICY_GSTACK_TOO_OLD`). `validate` ends with | `min_gstack_version` | version or `null` | `null` | The oldest gstack that may read the file; see the compatibility rule above. | | `docs` | `{ caps: [{ path, max_bytes }] }` or `null` | `null` | Documentation files with a byte cap. `/ship` Step 14.5 runs `gstack-ship-receipt docs-caps --base ` (the caps come from `origin/`'s policy, never the branch's copy; `--policy ` is for a repository bootstrapping its policy) before the verification gate and prints one `DOCS_CAP: / ok\|over` line per cap; any `over` (an absent file counts) exits 1 and blocks the ship until the file is trimmed or the cap raised on the base branch (`DOCS_AUDIT_BLOCKED`). At most 64 caps, each path contained. See [the ship receipt](#ship-receipt). Needs `min_gstack_version`. | | `train` | `{ gate_workflow, free_workflow, full_gate_check, record_artifact, max_size, fill_hours, screen_selection, trial_reservation_usd }` or `null` | `null` | Turns on [merge trains](#run-a-merge-train), read from the base revision only. Every field is required and an unknown field is `POLICY_INVALID`: `max_size` 1 to 6, `fill_hours` above 0 and at most 24, `screen_selection` `gstack-paid` \| `none`, `trial_reservation_usd` above 0 and at most 100. `null` keeps the per-PR queue exactly as it is. Needs `min_gstack_version`. | -| `changelog_fragments` | `{ dir }` or `null` | `null` | PRs carry unversioned [CHANGELOG fragments](#changelog-fragments) in `dir` (one flat, repo-relative directory, no `..`) and the merge step ([`land`](#land)) assigns the version. `null` keeps `[Unreleased]` sections and per-PR stamps. Needs `min_gstack_version`. | +| `changelog_fragments` | `{ dir, required_checks? }` or `null` | `null` | PRs carry unversioned [CHANGELOG fragments](#changelog-fragments) in `dir` (one flat, repo-relative directory, no `..`) and the merge step ([`land`](#land)) assigns the version. `required_checks` (up to 50 distinct names) are the checks [`train land`](#stamped-trains-under-changelog_fragments-train-land) requires present and green on a stamp commit. `null` keeps `[Unreleased]` sections and per-PR stamps. Needs `min_gstack_version`. | Containment, checked by `validate` and again at write time: paths and globs are repo-relative with no `..` segment, under 256 characters, at most 64 per list, @@ -385,8 +385,14 @@ tree the last merge publishes. Slots merge with `train land --train --pr `, never a slot call: -1. main's tree must equal S_(i-1)'s exactly (slot 1: the train base), and the - PR's head its pin, with its /ship receipt. +1. The record must be the gated head's own chain. Walking first parents from + S_k gives T0 and S_1..S_k with the recorded commits and trees, and each + S_i's second parent is slot i's pin. A record rebuilt from notes that + differs anywhere refuses (`QUEUE_PROOF_MISSING`), and every later step uses + only that verified chain. main's tree must equal S_(i-1)'s exactly (slot 1: + the train base), and the PR's head its pin, with its /ship receipt. main + equal to an earlier prefix refuses and names the slot to land first; main + equal to a later prefix confirms the slot (a rerun). 2. It commits S_i's tree with parents [pin, main], adds the `evals-no-debounce` label (a tool-made head gets no later push, so the 10-minute quiet window protects nothing), and pushes under @@ -394,7 +400,11 @@ Slots merge with `train land --train --pr `, never a slot call: (`QUEUE_TRAIN_MOVED`). 3. It waits for the PR's checks on that commit (`--no-wait` prints the command). Required checks run live at the slot until GSTA-156's exact-tree - reuse is on main. + reuse is on main. Every name in `changelog_fragments.required_checks` must + be present and green on the stamp commit (a check run of that name, all of + its ` (…)` matrix runs, or a status context); a missing one holds the + merge (`QUEUE_NOT_READY`) and the fix names it. Without the list, any green + set counts, so set it to the checks branch protection does not enforce. 4. It refuses if main moved during the checks, merges with `--match-head-commit`, and confirms main's tree is S_i exactly. Any other main is the freeze path above (`QUEUE_TRAIN_TREE_MISMATCH`, backstop). diff --git a/lib/ship-policy.ts b/lib/ship-policy.ts index 62ece4e94..424321cd7 100644 --- a/lib/ship-policy.ts +++ b/lib/ship-policy.ts @@ -42,7 +42,7 @@ export interface ShipPolicy { /** The CHANGELOG re-headed on a restamp; null for repos without one. */ changelog: string | null; /** Unversioned CHANGELOG fragments (`/.md`) a PR adds instead of an `[Unreleased]` section; null keeps `[Unreleased]`. */ - changelog_fragments: { dir: string } | null; + changelog_fragments: { dir: string; required_checks?: string[] } | null; /** Path of a paired repo to restamp identically (tier 2). */ mirror: string | null; /** `merge-only` refuses a squash or rebase plan in /ship. */ @@ -134,7 +134,7 @@ export const POLICY_EXPLANATIONS: Record = { release_outputs: 'Globs the release tool may write beyond the version files, lockfiles, CHANGELOG and stamp_paths. Any other output refuses the restamp.', stamp_paths: 'Globs (repo-relative, no `..`) where exact `(was X)`, `since: X` and `vX` mentions of the old version are rewritten.', changelog: 'CHANGELOG path re-headed on a restamp (the PR’s `[Unreleased]` or old-version section moves to the top under the new version). null to skip.', - changelog_fragments: '{ dir } or null: a PR adds one unversioned `/.md` fragment (optional `bump: minor|major` front matter) instead of an `[Unreleased]` section and leaves VERSION, the manifest version and released CHANGELOG sections at its merge base; the restamp folds every fragment under the allocated version and deletes the files (docs/ship-policy.md#changelog-fragments). null keeps `[Unreleased]`.', + changelog_fragments: '{ dir, required_checks? } or null: a PR adds one unversioned `/.md` fragment (optional `bump: minor|major` front matter) instead of an `[Unreleased]` section and leaves VERSION, the manifest version and released CHANGELOG sections at its merge base; the restamp folds every fragment under the allocated version and deletes the files (docs/ship-policy.md#changelog-fragments). `required_checks` lists the check runs or status contexts `train land` requires present and green on a stamp commit. null keeps `[Unreleased]`.', mirror: 'Relative path of a paired repo to restamp identically; writes are confined to it. null to skip.', history: 'merge-only | squash-ok. merge-only makes /ship refuse a squash or rebase plan.', preregistration_shas: 'Commits that must be ancestors of HEAD before push (preregistered evals); checked by `gstack-evidence ancestor`.', @@ -450,8 +450,10 @@ function docsErrors(raw: unknown, errors: PolicyError[]): void { function fragmentsErrors(raw: unknown, errors: PolicyError[]): void { const d = raw as Record | null; - if (!d || typeof d !== 'object' || Array.isArray(d)) { errors.push({ code: 'POLICY_INVALID', path: 'changelog_fragments', message: 'must be { dir } or null' }); return; } - for (const k of Object.keys(d)) if (k !== 'dir') errors.push({ code: 'POLICY_INVALID', path: `changelog_fragments.${k}`, message: 'unknown key' }); + if (!d || typeof d !== 'object' || Array.isArray(d)) { errors.push({ code: 'POLICY_INVALID', path: 'changelog_fragments', message: 'must be { dir, required_checks? } or null' }); return; } + const rc = d.required_checks; + if (rc !== undefined && (!Array.isArray(rc) || rc.length > 50 || new Set(rc).size !== rc.length || rc.some(n => typeof n !== 'string' || !/^[^\n]{1,100}$/.test(n)))) errors.push({ code: 'POLICY_INVALID', path: 'changelog_fragments.required_checks', message: `${JSON.stringify(rc)} must be up to 50 distinct check names of 1-100 characters` }); + for (const k of Object.keys(d)) if (k !== 'dir' && k !== 'required_checks') errors.push({ code: 'POLICY_INVALID', path: `changelog_fragments.${k}`, message: 'unknown key' }); const why = typeof d.dir === 'string' ? containedRelative(d.dir) ?? (/[*?[\]{}]/.test(d.dir) || d.dir.replace(/\/+$/, '') === '.' ? 'must be one directory, not a glob or the repository root' : null) : 'must be a string'; if (why) errors.push({ code: 'POLICY_CONTAINMENT', path: 'changelog_fragments.dir', message: `${JSON.stringify(d.dir)} ${why}` }); } diff --git a/lib/ship-queue-train-land.ts b/lib/ship-queue-train-land.ts index 6156132be..df3b28bec 100644 --- a/lib/ship-queue-train-land.ts +++ b/lib/ship-queue-train-land.ts @@ -45,8 +45,15 @@ export async function trainLand(ctx: QueueContext, o: { train: string; pr: strin if (freeze && freeze.repair?.train !== t.nonce) return outRefused({ code: 'QUEUE_TRAIN_FROZEN', message: `the queue is frozen (${freeze.reason}); only the admitted repair train merges` }, frozenLines(freeze), `${Q} train build --repair --prs @`, run); const root = ctx.repoRoot; - const s = t.stamped[i]!; - const pred = i === 0 ? { index: 0, tree: t.prefixes[0]!.tree } : t.stamped[i - 1]!; + const k = t.slots.length; + const chain = Array.from({ length: k + 1 }, (_, n) => { + const commit = git(root, ['rev-parse', '--verify', '-q', `${t.head}~${k - n}^{commit}`]).out; + return { index: n, commit, tree: isSha(commit) ? git(root, ['rev-parse', `${commit}^{tree}`]).out : '', pin: n && isSha(commit) ? git(root, ['rev-parse', '--verify', '-q', `${commit}^2`]).out : '' }; + }); + const recorded = [t.prefixes[0], ...t.stamped]; + if (t.stamped.length !== k || chain.some((c, n) => !isSha(c.commit) || c.commit !== recorded[n]?.commit || c.tree !== recorded[n]?.tree || (n > 0 && c.pin !== t.slots[n - 1]!.head))) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `the record's T0 and S1..S${k} (with each slot's pin as second parent) are not the first-parent chain of the gated head ${t.head.slice(0, 12)}` }, [], `${Q} train status --train ${t.nonce}`, run); + const s = { ...chain[i + 1]!, version: t.stamped[i]!.version }; + const pred = chain[i]!; const pin = t.slots[i]!.head; const mainNow = (): { sha: string; tree: string } | { error: string } => { const error = fetchBase(ctx); @@ -56,12 +63,10 @@ export async function trainLand(ctx: QueueContext, o: { train: string; pr: strin }; const rerun = `${Q} train land --train ${t.nonce} --pr ${o.pr}`; const lines = [`TRAIN_LAND: ${t.nonce} slot ${i + 1}/${t.slots.length} #${o.pr} pin ${pin} -> S${s.index} v${s.version} tree ${s.tree}`]; - if (t.stamped.length !== t.slots.length || s.index !== i + 1 || git(root, ['rev-parse', `${t.head}~${t.slots.length - i - 1}`]).out !== s.commit || git(root, ['rev-parse', `${s.commit}^{tree}`]).out !== s.tree) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `the record's S${i + 1} is not the gated head ${t.head.slice(0, 12)}'s prefix ${i + 1} with tree ${s.tree.slice(0, 12)}` }, lines, `${Q} train status --train ${t.nonce}`, run); let main = mainNow(); if ('error' in main) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: main.error }, lines, rerun, run); if (main.tree === s.tree) return confirmMerged(env, t, o.pr, main.sha, s.index, [...lines, `main: ${main.sha} is S${s.index} exactly`]); const mainTree = main.tree; - const chain = [{ index: 0, tree: t.prefixes[0]!.tree }, ...t.stamped]; const earlier = chain.slice(0, i).find(p => p.tree === mainTree); if (earlier) return outRefused({ code: 'QUEUE_TRAIN_TREE_MISMATCH', message: `main is ${earlier.index ? `S${earlier.index}` : 'the train base'}; land #${t.slots[earlier.index]!.pr} (slot ${earlier.index + 1}) first` }, lines, `${Q} train land --train ${t.nonce} --pr ${t.slots[earlier.index]!.pr}`, run); const later = chain.slice(i + 2).find(p => p.tree === mainTree); @@ -94,8 +99,8 @@ export async function trainLand(ctx: QueueContext, o: { train: string; pr: strin if (!push.ok) { const remote = git(root, ['ls-remote', env.remote, `refs/heads/${info.headRefName}`]); const now = remote.ok ? remote.out.split(/\s/)[0] ?? '' : ''; - if (remote.ok && now !== info.headRefOid) return stopped(env, t, 'QUEUE_TRAIN_MOVED', `#${o.pr}'s branch moved off ${info.headRefOid.slice(0, 12)} before the stamp push; train land never overwrites it`, main.sha, lines, true); - return outFailed({ code: 'QUEUE_NOT_READY', message: `the stamp push failed and ${info.headRefName} still reads ${remote.ok ? info.headRefOid.slice(0, 12) : 'unknown'}: ${push.err.split('\n')[0]}` }, lines, rerun, run); + if (remote.ok && now !== info.headRefOid && now !== commit) return stopped(env, t, 'QUEUE_TRAIN_MOVED', `#${o.pr}'s branch moved off ${info.headRefOid.slice(0, 12)} before the stamp push; train land never overwrites it`, main.sha, lines, true); + if (!remote.ok || now !== commit) return outFailed({ code: 'QUEUE_NOT_READY', message: `the stamp push failed and ${info.headRefName} still reads ${remote.ok ? info.headRefOid.slice(0, 12) : 'unknown'}: ${push.err.split('\n')[0]}` }, lines, rerun, run); } } lines.push(`${pushed ? 'PUSHED' : 'RESUMED'}: ${commit} (tree S${s.index}, parents [pin, main ${main.sha.slice(0, 12)}]) ${pushed ? 'to' : 'is already the head of'} ${info.headRefName}`); @@ -110,6 +115,15 @@ export async function trainLand(ctx: QueueContext, o: { train: string; pr: strin if (movedBase) return movedBase; const ci = ciAtHead(ctx, j.stamp_commit); lines.push(`CI: ${ci.line}`); + const required = env.policy.policy.changelog_fragments?.required_checks ?? []; + const missing = required.filter(name => { + const runs = ci.checks.filter(c => c.name === name || c.name.startsWith(`${name} (`)); + const contexts = ci.contexts.filter(c => c.name === name); + return !(runs.length || contexts.length) || runs.some(c => c.status !== 'completed' || c.conclusion !== 'success') || contexts.some(c => c.state !== 'success'); + }); + if (missing.length) lines.push(`REQUIRED_CHECKS: not green at ${j.stamp_commit.slice(0, 12)}: ${missing.join(', ')}`); + const waitFix = `wait for ${missing.join(', ')} to pass on ${j.stamp_commit.slice(0, 12)} (dispatch the workflow on ${head.headRefName} if it never ran), then ${rerun}`; + if (missing.length) return outPending({ code: 'QUEUE_NOT_READY', message: `required checks missing or not green on the stamp commit ${j.stamp_commit.slice(0, 12)}: ${missing.join(', ')}`, fix: waitFix }, lines, waitFix, run); if (!ci.green) return outPending({ code: 'QUEUE_NOT_READY', message: `checks on the stamp commit ${j.stamp_commit.slice(0, 12)} are not green` }, lines, `gstack-pr-wait ${o.pr} --until green && ${Q} train land --train ${t.nonce} --pr ${o.pr}`, run); main = mainNow(); if ('error' in main) return outPending({ code: 'QUEUE_PROOF_MISSING', message: main.error }, lines, rerun, run); diff --git a/lib/ship-queue.ts b/lib/ship-queue.ts index 68ae3cf26..73c309fa0 100644 --- a/lib/ship-queue.ts +++ b/lib/ship-queue.ts @@ -148,12 +148,12 @@ export function latestRunPerName } { +export function ciAtHead(ctx: QueueContext, sha: string): { line: string; green: boolean; none: boolean; checks: Array<{ name: string; status: string; conclusion: string | null }>; contexts: Array<{ name: string; state: string }> } { const at = sha.slice(0, 12); const runs = readAllPages(ctx, `repos/{owner}/{repo}/commits/${sha}/check-runs`, 'check_runs'); - if ('error' in runs) return { line: `unknown at ${at} (${runs.error})`, green: false, none: false, checks: [] }; + if ('error' in runs) return { line: `unknown at ${at} (${runs.error})`, green: false, none: false, checks: [], contexts: [] }; const statuses = readAllPages(ctx, `repos/{owner}/{repo}/commits/${sha}/status`, 'statuses'); - if ('error' in statuses) return { line: `unknown at ${at} (${statuses.error})`, green: false, none: false, checks: [] }; + if ('error' in statuses) return { line: `unknown at ${at} (${statuses.error})`, green: false, none: false, checks: [], contexts: [] }; const checks = latestRunPerName(runs.items).map(c => ({ name: String(c.name ?? ''), status: String(c.status ?? ''), conclusion: c.conclusion === undefined || c.conclusion === null ? null : String(c.conclusion) })); const verdicts = [ ...checks.map(c => c.status !== 'completed' ? 'pending' : c.conclusion === 'success' ? 'passed' : c.conclusion === 'skipped' || c.conclusion === 'neutral' ? 'skipped' : 'failed'), @@ -162,7 +162,7 @@ export function ciAtHead(ctx: QueueContext, sha: string): { line: string; green: const count = (v: string) => verdicts.filter(x => x === v).length; const [passed, failed, pending, skipped] = [count('passed'), count('failed'), count('pending'), count('skipped')]; const green = verdicts.length > 0 && pending === 0 && failed === 0; - return { line: `${green ? 'green' : pending ? 'pending' : verdicts.length ? 'red' : 'none'} at ${at} (passed=${passed} failed=${failed} pending=${pending} skipped=${skipped})`, green, none: verdicts.length === 0, checks }; + return { line: `${green ? 'green' : pending ? 'pending' : verdicts.length ? 'red' : 'none'} at ${at} (passed=${passed} failed=${failed} pending=${pending} skipped=${skipped})`, green, none: verdicts.length === 0, checks, contexts: statuses.items.map(s => ({ name: String(s.context ?? ''), state: String(s.state ?? '') })) }; } /** diff --git a/test/changelog-fragments.test.ts b/test/changelog-fragments.test.ts index a35b99d90..f02c72f7e 100644 --- a/test/changelog-fragments.test.ts +++ b/test/changelog-fragments.test.ts @@ -87,6 +87,11 @@ describe('policy key', () => { expect(validatePolicy({ ...POLICY, changelog_fragments: { dir: '../x' } }).map(e => e.path)).toContain('changelog_fragments.dir'); expect(validatePolicy({ ...POLICY, changelog_fragments: { dir: 'changelog.d/*' } }).map(e => e.path)).toContain('changelog_fragments.dir'); expect(validatePolicy({ ...POLICY, changelog_fragments: { dir: 'd', glob: '*' } }).map(e => e.path)).toContain('changelog_fragments.glob'); + expect(validatePolicy({ ...POLICY, changelog_fragments: { dir: 'd', required_checks: ['free-tests', 'windows-free-shard'] } })).toEqual([]); + for (const bad of [[], ['a', 'a'], [''], ['x\ny'], 'free-tests']) { + if (Array.isArray(bad) && !bad.length) continue; + expect(validatePolicy({ ...POLICY, changelog_fragments: { dir: 'd', required_checks: bad } }).map(e => e.path)).toContain('changelog_fragments.required_checks'); + } const { min_gstack_version: _drop, ...noMin } = POLICY; expect(validatePolicy(noMin).map(e => e.path)).toContain('changelog_fragments'); }); diff --git a/test/ship-queue-train-land.test.ts b/test/ship-queue-train-land.test.ts index 3602629c0..dbc240bec 100644 --- a/test/ship-queue-train-land.test.ts +++ b/test/ship-queue-train-land.test.ts @@ -51,8 +51,8 @@ function fragmentPr(fx: TrainFixture, pr: string, fragment = `- #${pr} change\n` readyReceipt(fx, pr, head); return head; } -function stampedTrain(gate = GREEN_GATE) { - const fx = trainFixture(POLICY, 'gstack-train-land-'); fixtures.push(fx); wrap(fx); +function stampedTrain(gate = GREEN_GATE, policy: Record = POLICY) { + const fx = trainFixture(policy, 'gstack-train-land-'); fixtures.push(fx); wrap(fx); const h1 = fragmentPr(fx, '1'); const h2 = fragmentPr(fx, '2', '---\nbump: minor\n---\n- #2 change\n'); fx.write(d => { d.rules['evals.yml'] = gate; }); @@ -118,23 +118,27 @@ describe('stamped trains: build and land', () => { expect(line(drift.out, 'QUEUE_TRAIN_FROZEN:')).toMatch(/^QUEUE_TRAIN_FROZEN: train \S+ merged #1/); }, 300_000); - test('a record whose S_i is not the gated head\'s prefix i pushes nothing and leaves the PR branch in place', () => { + test('a record that is not the gated head\'s first-parent chain (any slot\'s commit, tree or pin) pushes nothing, merges nothing and leaves the PR branch in place', () => { const { fx, h1, nonce } = stampedTrain(); expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); const file = path.join(fx.state, 'ship-queue', 'trains', nonce, 'train.json'); const t = trainJson(fx, nonce); - const base = trainJson(fx, nonce).prefixes[0]; + const base = t.prefixes[0]; + const [s1, s2] = t.stamped; const forged = [ - { ...t.stamped[0], commit: h1, tree: fx.repo.git('rev-parse', h1 + '^{tree}') }, - { ...t.stamped[0], tree: 'f'.repeat(40) }, - { ...t.stamped[1] }, - { ...t.stamped[0], index: 0, commit: base.commit, tree: base.tree }, + { ...t, stamped: [{ ...s1, commit: h1, tree: fx.repo.git('rev-parse', h1 + '^{tree}') }, s2] }, + { ...t, stamped: [{ ...s1, tree: 'f'.repeat(40) }, s2] }, + { ...t, stamped: [{ ...s2 }, s2] }, + { ...t, stamped: [{ ...s1, index: 0, commit: base.commit, tree: base.tree }, s2] }, + { ...t, stamped: [s1, { ...s2, tree: base.tree }] }, + { ...t, stamped: [s1] }, + { ...t, slots: [{ ...t.slots[0], head: t.slots[1].head }, t.slots[1]] }, ]; - for (const entry of forged) { - fs.writeFileSync(file, JSON.stringify({ ...t, stamped: [entry, t.stamped[1]] })); + for (const record of forged) { + fs.writeFileSync(file, JSON.stringify(record)); const r = land(fx, nonce, '1'); expect(r.status).toBe(EXIT.refused); - expect(r.err).toMatch(/is not the gated head \S+'s prefix 1 with tree.*\(QUEUE_PROOF_MISSING\)/); + expect(r.err).toMatch(/are not the first-parent chain of the gated head.*\(QUEUE_PROOF_MISSING\)/); expect(r.out).not.toMatch(/^PUSHED:/m); } expect(fx.read().prs['1']).not.toMatchObject({ state: 'MERGED' }); @@ -197,6 +201,17 @@ describe('stamped trains: build and land', () => { expect(land(fx, nonce, '1').out).toMatch(/^PUSHED: [0-9a-f]{40}/m); }, 300_000); + test('a required check that never reported on the stamp commit holds the merge and names the check', () => { + const { fx, nonce } = stampedTrain(GREEN_GATE, { ...POLICY, changelog_fragments: { dir: 'changelog.d', required_checks: ['free-tests', 'windows-free-shard'] } }); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + const stamp = /^PUSHED: ([0-9a-f]{40})/m.exec(land(fx, nonce, '1').out)![1]!; + const held = land(fx, nonce, '1'); + expect(held.err).toMatch(/required checks missing or not green on the stamp commit \S+: windows-free-shard; fix: wait for windows-free-shard to pass on \S+ \(dispatch the workflow on pr-1 if it never ran\).*\(QUEUE_NOT_READY\)/); + expect(held.out).toContain(`REQUIRED_CHECKS: not green at ${stamp.slice(0, 12)}: windows-free-shard`); + expect(line(held.out, 'NEXT:')).toMatch(/^NEXT: wait for windows-free-shard to pass on /); + expect(fx.read().prs['1']).not.toMatchObject({ state: 'MERGED' }); + }, 300_000); + test('a PR retargeted off the train base refuses before any push', () => { const { fx, h1, nonce } = stampedTrain(); expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); From fa62ea355565f5e224659df9f06dcb1ef1b10358 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 09:43:12 +0000 Subject: [PATCH 30/31] docs(ship-policy, troubleshooting, harness): train land refusals, resume and required checks; stamped bisect; three-way manifest merges Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- docs/recommended-harness.md | 12 +++++++++--- docs/ship-policy.md | 25 +++++++++++++++++-------- docs/troubleshooting.md | 29 ++++++++++++++++++++++------- 3 files changed, 48 insertions(+), 18 deletions(-) diff --git a/docs/recommended-harness.md b/docs/recommended-harness.md index 687ccb0fe..4b74b85d3 100644 --- a/docs/recommended-harness.md +++ b/docs/recommended-harness.md @@ -129,8 +129,9 @@ A lane (one crew thread, one branch) does this, in order. Every command is harness-generic; only the thread and the PR tool are Capy. 1. **Branch from the default branch, build, test with focused checks.** Write - the CHANGELOG entry under `## [Unreleased]`; leave VERSION at main's value. - Never pre-pick a version. + the CHANGELOG entry under `## [Unreleased]` (under `changelog_fragments`, a + fragment in the policy's fragment directory instead); leave VERSION at + main's value. Never pre-pick a version. 2. **Push the final head.** Everything below binds to the pushed head; a later push invalidates the receipt, the pregate record and the review binding. 3. **Open the PR** with the host's PR tool (Capy: `tools.pr.create`, never @@ -182,6 +183,11 @@ reference: `, or `--merge` where the repository keeps merge commits), and verifies again. A stamp commit that holds anything beyond the stamp files fails `verify --train`, freezes the queue and costs a backstop gate. + Under `changelog_fragments` there is no slot call and no lane stamp: the + controller runs `gstack-ship-queue train land --train --pr 41` per + slot, which pushes the stamped prefix the gate covered, waits for checks, + merges and confirms main + ([ship-policy.md#train-land](ship-policy.md#train-land)). 4. `train prune` when every slot has merged. **Who merges.** One controller, never the lane. Both crews that ran this @@ -317,7 +323,7 @@ closed) holds the longer history behind these numbers; from here on | Running a workflow | the SKILL.md files, `GSTACK_SESSION_KIND=unattended`, `GSTACK_RESULT:` lines | the `GSTACK_LAUNCH` assignments, `GSTACK_CAPY=1` and the `capy-asks` block, no enforced hooks (safety skills are advisory) | | Approvals | `gstack-approvals`, `gstack-owner-brief render --captain`, the `## Approvals` table | the Captain relay (`needs_you`), the Captain drive `approvals/` mirror | | Pull requests | `/ship`, the ship receipt, `gstack-pr-shots` | `tools.pr.create` (callbacks), `tools.pr.uploadAsset` (before/after images) | -| Merging | `gstack-ship-queue` (ready, train build, verdict, verify), `gstack-restamp`, `gh pr merge --match-head-commit` | the controller is a Captain thread; slot calls are thread messages | +| Merging | `gstack-ship-queue` (ready, train build, verdict, verify; train land and land under `changelog_fragments`), `gstack-restamp`, `gh pr merge --match-head-commit` | the controller is a Captain thread; slot calls are thread messages | | Flaky pass | the rerun rule, `flake-records-a*` and `flake-ledger-*` artifacts, `test:health` | the daily automation and the ledger in the Captain's Drive | | Release | `release-cut`, `weekly-release.yml`, the `stable` channel | nothing | | Scorecard | `gstack-factory-scorecard`, `/retro` Step 7.5 | nothing (the CSV can be kept in the Captain's Drive) | diff --git a/docs/ship-policy.md b/docs/ship-policy.md index dc40e0d53..ec51c5feb 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -392,12 +392,18 @@ Slots merge with `train land --train --pr `, never a slot call: only that verified chain. main's tree must equal S_(i-1)'s exactly (slot 1: the train base), and the PR's head its pin, with its /ship receipt. main equal to an earlier prefix refuses and names the slot to land first; main - equal to a later prefix confirms the slot (a rerun). + equal to a later prefix confirms the slot (a rerun). A base that cannot be + fetched refuses (`QUEUE_PROOF_MISSING`), a fork head refuses + (`QUEUE_TRAIN_UNTRUSTED`), and a PR whose base is no longer the train's + base refuses (`QUEUE_NOT_READY`), here and again before the merge. 2. It commits S_i's tree with parents [pin, main], adds the `evals-no-debounce` label (a tool-made head gets no later push, so the 10-minute quiet window protects nothing), and pushes under `--force-with-lease=:`; a foreign push is never overwritten - (`QUEUE_TRAIN_MOVED`). + (`QUEUE_TRAIN_MOVED`). The stamp commit is deterministic and journaled + (`land-.json` beside the train record) before the push, so a rerun + adopts its own stamp head instead of reading it as a move. A push that + fails while the branch still reads the pin is `QUEUE_NOT_READY`; rerun. 3. It waits for the PR's checks on that commit (`--no-wait` prints the command). Required checks run live at the slot until GSTA-156's exact-tree reuse is on main. Every name in `changelog_fragments.required_checks` must @@ -548,9 +554,10 @@ records does not stand; see [the ship receipt](#ship-receipt)), `QUEUE_TRAIN_CONFLICT: train stopped at # (conflicts: ) after merging #,#; nothing dispatched, no branch pushed; fix: …` line names the build without that PR) and `QUEUE_TRAIN_FREE_RED` (free tests red, -missing, cancelled or on another SHA). In a merge, version files take the -prefix's side and the CHANGELOG merges by section, since the stamp at merge -rewrites both; a PR whose CHANGELOG drops or renames a released heading (or +missing, cancelled or on another SHA). In a merge, the version file takes the +prefix's side, a manifest or npm lockfile conflict merges three-way with the +prefix's version (so a pin's dependency edit survives), and the CHANGELOG +merges by section, since the stamp at merge rewrites them; a PR whose CHANGELOG drops or renames a released heading (or carries a released entry's text in a section of its own) is a CHANGELOG conflict, never merged with the damage, except the stacked child whose parent was released since their merge base, which `mergeChangelogs` carries (see *A @@ -585,7 +592,8 @@ gstack-ship-queue train rebuild --train ``` The bisect dispatches the red cases (`cases`) on every prefix T0..Tk -(`prefix_shas`), one trial each, after checking every prefix is an ancestor +(`prefix_shas`; for a [stamped train](#train-land), T0 and S_1..S_k, the +chain its gate ran), one trial each, after checking every prefix is an ancestor of `train/` (`QUEUE_TRAIN_PREFIX_INVALID`). It reserves every case×prefix trial (unknown costs at `trial_reservation_usd`) and launches only the prefixes that fit the cap (`QUEUE_TRAIN_BISECT_CAPPED`; completed prefixes @@ -1200,7 +1208,7 @@ writes only stamps. The command also gets `GSTACK_RESTAMP_BASE_SHA`, the release predecessor: the base branch commit the version is allocated after (origin/ for a per-PR -restamp and for `land`). A repo counter that must be predecessor + 1 (a +restamp and for `land`, S_(i-1) for a [stamped train](#train-land)'s S_i). A repo counter that must be predecessor + 1 (a SAVE_VERSION, a rules number) reads its old value from that commit (`git show "$GSTACK_RESTAMP_BASE_SHA:"`), never from the branch, so two PRs cut from one base never both claim the same number. Regenerated goldens go in @@ -1262,7 +1270,8 @@ valid fragment (a legacy `[Unreleased]`-only entry passes during migration; a `[bot]` author, such as Dependabot, needs none). Other manifest edits, a dependency or a script, pass. A head made by the merge step, a commit whose parents are [pin, base], must carry the base's version plus the effective -bump of the fragments its pin adds. Under a train policy `gstack-ship-queue +bump of the fragments its pin adds; a stacked pin's parent fragment whose +text the base already released is skipped, not counted again. Under a train policy `gstack-ship-queue ready` applies the same rule itself; without one, `ready` requires the head's checks green, Version Gate included. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index f936db6cb..f6d1b51fa 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -4104,12 +4104,18 @@ HEAD is not the PR's pushed head (read through `gh pr view`), CI at that exact head is not green (failed, pending or unreadable), or the pregate result is missing, failed or recorded on another tree (`PREGATE_STALE`). Under `--dispatch-ci` it is also a dispatched workflow run that concluded red (a red -run clears no pregate lane) or a `gh workflow run` that failed. +run clears no pregate lane) or a `gh workflow run` that failed. `train land` +uses it while a slot cannot merge yet: checks on the stamp commit are not +green, a name in `changelog_fragments.required_checks` is missing or not green +there, the PR's base is no longer the train's base, the stamp push failed while +the branch still read the pin, `gh pr merge` refused, or the merge is not yet +visible on main. **Fix.** Commit and push, wait for CI on that head, rerun `gstack-pregate run --allow-repo-commands` when the tree changed, then rerun `gstack-ship-queue ready --pr `. The block it prints before refusing shows -which line is not yet true. +which line is not yet true. For `train land`, do what `NEXT:` says, then rerun +the same `train land` command; it adopts its own journaled stamp commit. ### `QUEUE_PROOF_MISSING` @@ -4125,8 +4131,10 @@ train still building, running or merging, `train watch` on an unknown record, unreadable run), `verify --pr --train ` when main's tree mismatches and whether a slot merged on GitHub cannot be read (so whether the queue must freeze is unknown; it never answers "nothing merged" from an unreadable -state), and `ready --screen` when the scratch merge branch cannot be -pushed. Nothing was written unless the `QUEUE_STAMP_REFUSED:` line says +state), `train land` when the base or the train branch cannot be fetched or +the record's T0 and S_1..S_k are not the gated head's first-parent chain +(each S_i's second parent slot i's pin), and `ready --screen` when the +scratch merge branch cannot be pushed. Nothing was written unless the `QUEUE_STAMP_REFUSED:` line says `committed=yes`. **Fix.** Supply the named input (fetch the gated head, pass the current base @@ -4165,7 +4173,9 @@ predecessor PR moved after the gate. **Meaning.** The ship policy sets `full_e2e: true`, and no passing full-gate result is bound to the tree being stamped or verified: no recorded gate-ahead attempt for that gated head, no `--gate-exit 0 --gate-log ` from a run -on it, or the recorded run exited non-zero. +on it, or the recorded run exited non-zero. The train commands use it too: a +PR that is not in the train, a train without a green gate, `verify --train` on +a stamped train and `train land` on a plain one (each names the other). **Fix.** Run the gate the policy's `gate_command` names in the gate-ahead worktree and bind it with `gstack-ship-queue gate-ahead --record`, or pass the @@ -4203,7 +4213,7 @@ a `train` block with `min_gstack_version` on the base branch first **Meaning.** A pinned PR's head repository is not the base repository. The train branch and its dispatches run with repository secrets, so fork work never rides a train. `ready --dispatch-ci` refuses a fork head for the same -reason and dispatches nothing. +reason and dispatches nothing, and `train land` refuses one and pushes nothing. **Fix.** Run the printed `train build --prs `; the fork PR stays on the trusted-branch path. @@ -4287,7 +4297,8 @@ and report it. **Meaning.** A PR's head is no longer the SHA the train pinned (at build, or past its pin beyond stamps at verify), so the train would not test what merges. At verify after earlier slots merged, the queue also freezes -(`QUEUE_TRAIN_FROZEN`). +(`QUEUE_TRAIN_FROZEN`). At `train land` it is a PR head or branch that moved +off the pin (or off land's own stamp commit); land never overwrites it. **Fix.** Ask the lane for a fresh READY on the new head, then build the train with the new pin (`train build --prs @,…`). @@ -4440,6 +4451,10 @@ tested. After a merge, a main that differs from the prefix after the PR in the CHANGELOG alone, while being its own first parent's CHANGELOG plus the PR's own entry (the rule the pre-merge verify applied to the head), is not a mismatch ([one CHANGELOG rule, before and after](ship-policy.md#run-a-merge-train)). +For a stamped train, `train land` compares exact trees: main must be S_(i-1) +before the merge, must not move during the slot's checks, and must be S_i +after it ([stamped trains](ship-policy.md#train-land)); main equal to an +earlier prefix refuses without freezing and names the slot to land first. **Fix.** Stop merging. If a PR of the train merged (verified, or reported MERGED by GitHub without a verified tree, slot 1's own squash included), the From 9e1593ded92e3adab80b07bab7c26e0cf2354a81 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 16:08:11 +0000 Subject: [PATCH 31/31] fix(train-land): count a slot as merged only when GitHub reports its PR MERGED (#3603) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + docs/ship-policy.md | 4 +++ docs/troubleshooting.md | 5 +++- lib/ship-queue-train-verify.ts | 26 +++++++++++------ test/ship-queue-train-land.test.ts | 47 ++++++++++++++++++++++++++++++ 5 files changed, 73 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3a660a9f..e9e4ed32c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - **Stamped merge trains** (`lib/ship-queue-train-stamp.ts`, `lib/ship-queue-train-land.ts`): under `changelog_fragments`, `train build` also builds S_i = stamp(merge(S_(i-1), pin_i)) for every slot, checks each is the merge modulo stamps, keeps every earlier release entry byte for byte and equals the plain prefix outside release paths (`QUEUE_TRAIN_STAMP_MISMATCH`), refuses a symlinked release file before stamping, and gates S_k. `train land --train --pr ` publishes slot i's S_i exactly. It makes a deterministic stamp commit on [pin, main], journals it before it is pushed under the pin's lease with the `evals-no-debounce` label, waits for checks, merges with `--match-head-commit` and confirms main's tree; a rerun adopts its own stamp head, a PR retargeted off the base refuses, and any other main freezes the queue as before. It first verifies the whole record against git (T0 and S_1..S_k must be the gated head's first-parent chain, each S_i's second parent slot i's pin) and uses only that chain, and it merges only when every name in the new `changelog_fragments.required_checks` is present and green on the stamp commit. `verify --train` refuses a stamped train and names `train land`; `train bisect` of a stamped train tests T0 and S_1..S_k. ### Changed +- **A stamped train counts a slot as merged only when GitHub reports its PR MERGED** (`lib/ship-queue-train-verify.ts`, #3603): for a stamped train, `train land` refuses to confirm a PR GitHub reports open even when main's tree is its S_i (`QUEUE_PROOF_MISSING`), and the merged count, the freeze decision and the freeze record read every slot's GitHub state instead of trusting the record's `merged` entries. Plain trains are unchanged. - **The main backstop covers a stamped train's merges by exact tree** (`scripts/train-backstop.ts`, `scripts/train-gate.ts`): a record's S_i prefixes cover main only when its tree is exactly S_i; plain T_i prefixes still cover modulo stamps. - **A train merge keeps a pin's manifest edits** (`lib/ship-queue-train.ts`): a `package.json` or lockfile conflict against the previous prefix merges three-way with the prefix's version (the same `mergeManifest` land uses) instead of taking the prefix's whole file; anything else that conflicts is a train conflict. - **Version Gate reads a stacked child's stamp commit by its own fragments**: a parent's fragment the base has already released is skipped rather than counted again. diff --git a/docs/ship-policy.md b/docs/ship-policy.md index ec51c5feb..69f5d1a6a 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -414,6 +414,10 @@ Slots merge with `train land --train --pr `, never a slot call: 4. It refuses if main moved during the checks, merges with `--match-head-commit`, and confirms main's tree is S_i exactly. Any other main is the freeze path above (`QUEUE_TRAIN_TREE_MISMATCH`, backstop). + A slot counts as merged only when GitHub reports its PR MERGED. Main at + S_i with the PR still open refuses (`QUEUE_PROOF_MISSING`), and the + train's merged count, its freeze decision and the freeze record read + GitHub's state for every slot, never the record's `merged` list alone. `verify --train` refuses a stamped train and names `train land`. The backstop covers main only when its exact tree is some S_i of a green record (plain diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index f6d1b51fa..ce50fdba9 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -4133,7 +4133,10 @@ and whether a slot merged on GitHub cannot be read (so whether the queue must freeze is unknown; it never answers "nothing merged" from an unreadable state), `train land` when the base or the train branch cannot be fetched or the record's T0 and S_1..S_k are not the gated head's first-parent chain -(each S_i's second parent slot i's pin), and `ready --screen` when the +(each S_i's second parent slot i's pin), `train land` when main's tree is the +slot's S_i but GitHub does not report the PR as MERGED (a record whose slot +numbers or merged entries disagree with GitHub), +and `ready --screen` when the scratch merge branch cannot be pushed. Nothing was written unless the `QUEUE_STAMP_REFUSED:` line says `committed=yes`. diff --git a/lib/ship-queue-train-verify.ts b/lib/ship-queue-train-verify.ts index 927656c00..4df842888 100644 --- a/lib/ship-queue-train-verify.ts +++ b/lib/ship-queue-train-verify.ts @@ -113,24 +113,29 @@ function published(env: TrainEnv, t: TrainRecord, i: number, main: string): { sa * Freeze the queue: the record goes to local state and the notes ref before * anything remote, then the backstop gate is dispatched on main's sha. * `states` is the one GitHub read of every unverified slot, made by the - * caller: a slot that read MERGED is merged, verified tree or not, and a slot + * caller (for a stamped train every slot: its record entries never count on + * their own): a slot that read MERGED is merged, verified tree or not, and a slot * that could not be read is left out of `unmerged`, recorded under * `unreadable` and named in the reason and in the NEXT after the repair (the * operator seats it by hand once gh answers), so the rebuild after the repair * never seats a PR that may already be in. */ +/** A record entry that counts as merged without a GitHub read: plain trains only; a stamped train counts only what GitHub reports MERGED (#3603). */ +const recordedMerged = (t: TrainRecord, pr: string): boolean => !t.stamped && t.merged.some(m => m.pr === pr); + async function freezeQueue(env: TrainEnv, t: TrainRecord, reason: string, mainSha: string, states: ReadonlyMap): Promise { const now = nowIso(); - const unverified = t.slots.filter(s => !t.merged.some(m => m.pr === s.pr)); + const unverified = t.slots.filter(s => !recordedMerged(t, s.pr)); const landed = unverified.filter(s => states.get(s.pr) === 'MERGED').map(s => s.pr); + const untreed = landed.filter(pr => !t.merged.some(m => m.pr === pr)); const unreadable = unverified.filter(s => states.get(s.pr) === 'unreadable').map(s => s.pr); const prs = (list: string[]) => list.map(p => '#' + p).join(', '); const f: FreezeRecord = { - schema: 1, kind: 'freeze', repo_key: env.repo.key, train: t.nonce, merged: [...t.merged.map(m => m.pr), ...landed], + schema: 1, kind: 'freeze', repo_key: env.repo.key, train: t.nonce, merged: [...t.slots.filter(s => recordedMerged(t, s.pr)).map(s => s.pr), ...landed], unmerged: unverified.filter(s => !landed.includes(s.pr) && !unreadable.includes(s.pr)).map(s => ({ pr: s.pr, head: s.head })), unreadable: unverified.filter(s => unreadable.includes(s.pr)).map(s => ({ pr: s.pr, head: s.head })), main: { sha: mainSha, tree: git(env.ctx.repoRoot, ['rev-parse', `${mainSha}^{tree}`]).out }, - reason: [reason, landed.length ? `${prs(landed)} merged on GitHub without a verified tree` : '', unreadable.length ? `${prs(unreadable)} unreadable on GitHub, left out of unmerged; seat it in a train by hand once gh pr view answers` : ''].filter(Boolean).join('; '), + reason: [reason, untreed.length ? `${prs(untreed)} merged on GitHub without a verified tree` : '', unreadable.length ? `${prs(unreadable)} unreadable on GitHub, left out of unmerged; seat it in a train by hand once gh pr view answers` : ''].filter(Boolean).join('; '), backstop: null, repair: null, created_at: now, cleared_at: null, cleared_by: null, updated_at: now, }; @@ -173,12 +178,12 @@ function verifiedElsewhere(env: TrainEnv, t: TrainRecord, mainSha: string): stri export async function stopped(env: TrainEnv, t: TrainRecord, code: 'QUEUE_TRAIN_TREE_MISMATCH' | 'QUEUE_TRAIN_MOVED', message: string, mainSha: string, lines: string[], fetched: boolean): Promise { const run = trainFile(t.nonce); // One GitHub read per unverified slot decides both the freeze and its record. - const states = new Map(t.slots.filter(s => !t.merged.some(m => m.pr === s.pr)).map(s => [s.pr, prState(env.ctx, s.pr)] as const)); - const landed = t.merged.length > 0 || [...states.values()].includes('MERGED'); + const states = new Map(t.slots.filter(s => !recordedMerged(t, s.pr)).map(s => [s.pr, prState(env.ctx, s.pr)] as const)); + const landed = t.slots.some(s => recordedMerged(t, s.pr)) || [...states.values()].includes('MERGED'); const unreadable = landed ? [] : [...states].filter(([, state]) => state === 'unreadable').map(([pr]) => `#${pr}`); if (unreadable.length) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `${message}; whether ${unreadable.join(', ')} merged on GitHub could not be read, so whether the queue must freeze is unknown` }, lines, `${Q} verify --pr ${t.slots[0]!.pr} --train ${t.nonce} (once gh pr view answers)`, run); if (!landed) return outFailed({ code, message: `${message}; nothing of train ${t.nonce} merged, so the queue is not frozen` }, lines, `${Q} train build ${prsArgs(t.slots, t.explicit_order)} (a new train on current main)`, run); - const later = fetched && t.slots.every(s => t.merged.some(m => m.pr === s.pr) || states.get(s.pr) === 'MERGED') ? verifiedElsewhere(env, t, mainSha) : null; + const later = fetched && t.slots.every(s => recordedMerged(t, s.pr) || states.get(s.pr) === 'MERGED') ? verifiedElsewhere(env, t, mainSha) : null; if (later) { const held = activeFreeze(env.repo.keys); return outRefused({ code: 'QUEUE_TRAIN_STALE', message: `${message}; every slot of train ${t.nonce} merged on GitHub and main ${mainSha.slice(0, 12)} is ${later}, so this record is stale and ${held ? `freezes nothing (the queue is already frozen by train ${held.train})` : 'the queue is not frozen'}` }, held ? [...lines, ...frozenLines(held)] : lines, `${Q} train prune --stale --train ${t.nonce}`, run); @@ -233,11 +238,14 @@ export async function verifyTrain(ctx: QueueContext, o: { pr: string; train: str export function confirmMerged(env: TrainEnv, t: TrainRecord, pr: string, main: string, index: number, lines: string[]): TrainOut { const run = trainFile(t.nonce); + const state = t.stamped ? prState(env.ctx, pr) : 'MERGED'; + if (state !== 'MERGED') return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `main's tree is S${index} of train ${t.nonce}, but GitHub reports #${pr} as ${state}, not MERGED; only a merge GitHub reports counts` }, lines, `gh pr view ${pr} --json state,headRefOid; rerun once GitHub reports #${pr} MERGED`, run); if (!t.merged.some(m => m.pr === pr)) t.merged.push({ pr, sha: main, tree: git(env.ctx.repoRoot, ['rev-parse', `${main}^{tree}`]).out, at: nowIso() }); - t.state = t.merged.length === t.slots.length ? 'merged' : 'merging'; + const merged = t.slots.filter(s => s.pr === pr || recordedMerged(t, s.pr) || (t.merged.some(m => m.pr === s.pr) && prState(env.ctx, s.pr) === 'MERGED')); + t.state = merged.length === t.slots.length ? 'merged' : 'merging'; effect(t, `verified #${pr} merged as T${index}`); save(env, t); - lines.push(`VERIFY_TRAIN: #${pr} merged; main's tree is T${index} of train ${t.nonce} (${t.merged.length}/${t.slots.length} merged)`); + lines.push(`VERIFY_TRAIN: #${pr} merged; main's tree is T${index} of train ${t.nonce} (${merged.length}/${t.slots.length} merged)`); const freeze = activeFreeze(env.repo.keys); if (freeze && freeze.repair?.train === t.nonce && t.state === 'merged') { freeze.cleared_at = nowIso(); diff --git a/test/ship-queue-train-land.test.ts b/test/ship-queue-train-land.test.ts index dbc240bec..262d82b64 100644 --- a/test/ship-queue-train-land.test.ts +++ b/test/ship-queue-train-land.test.ts @@ -251,3 +251,50 @@ describe('stamped trains: build and land', () => { expect(r.err).toMatch(/was built without stamped prefixes.*\(QUEUE_GATE_MISSING\)/); }, 180_000); }); + +describe('train land counts only merges GitHub reports (#3603)', () => { + const trainPath = (fx: TrainFixture, nonce: string) => path.join(fx.state, 'ship-queue', 'trains', nonce, 'train.json'); + const landFirst = (fx: TrainFixture, nonce: string) => { land(fx, nonce, '1'); expect(land(fx, nonce, '1').status).toBe(EXIT.ok); }; + + test('a record with its slot PRs swapped never confirms the open PR as merged', () => { + const { fx, nonce } = stampedTrain(); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + landFirst(fx, nonce); + const t = trainJson(fx, nonce); + fs.writeFileSync(trainPath(fx, nonce), JSON.stringify({ ...t, slots: [{ ...t.slots[0], pr: '2' }, { ...t.slots[1], pr: '1' }] })); + const r = land(fx, nonce, '2'); + expect(r.status).toBe(EXIT.refused); + expect(r.err).toMatch(/GitHub reports #2 as OPEN, not MERGED.*\(QUEUE_PROOF_MISSING\)/); + expect(r.out).not.toMatch(/^VERIFY_TRAIN: #2 merged/m); + expect(fx.read().prs['2']).not.toMatchObject({ state: 'MERGED' }); + expect(trainJson(fx, nonce).merged.map((m: any) => m.pr)).toEqual(['1']); + }, 300_000); + + test('a forged merged entry for an open slot does not mark the train merged', () => { + const { fx, nonce } = stampedTrain(); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + const t = trainJson(fx, nonce); + fs.writeFileSync(trainPath(fx, nonce), JSON.stringify({ ...t, merged: [{ pr: '2', sha: t.prefixes[0].commit, tree: t.prefixes[0].tree, at: '2026-10-29T00:00:00Z' }] })); + landFirst(fx, nonce); + const rerun = land(fx, nonce, '1'); + expect(rerun.out).toContain('(1/2 merged)'); + expect(trainJson(fx, nonce).state).toBe('merging'); + }, 300_000); + + test('a forged merged entry does not turn a pre-landing mismatch into a freeze', () => { + const { fx, nonce } = stampedTrain(); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + const t = trainJson(fx, nonce); + fs.writeFileSync(trainPath(fx, nonce), JSON.stringify({ ...t, merged: [{ pr: '1', sha: t.prefixes[0].commit, tree: t.prefixes[0].tree, at: '2026-10-29T00:00:00Z' }] })); + fx.repo.git('fetch', '-q', 'origin'); + fx.repo.git('checkout', '-q', '--detach', 'origin/main'); + fs.writeFileSync(path.join(fx.repo.work, 'outside.txt'), 'outside\n'); + fx.repo.git('add', 'outside.txt'); + fx.repo.git('commit', '-q', '-m', 'outside'); + fx.repo.git('push', '-q', 'origin', 'HEAD:refs/heads/main'); + const r = land(fx, nonce, '1'); + expect(r.err).toMatch(/nothing of train \S+ merged, so the queue is not frozen.*\(QUEUE_TRAIN_TREE_MISMATCH\)/); + expect(r.out).not.toMatch(/^QUEUE_TRAIN_FROZEN:/m); + }, 300_000); +}); +