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();