mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 06:24:41 +01:00
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>
This commit is contained in:
1 parent
2a0ccf23e9
commit
23a17b4b7e
11 files changed
+195
-43
No files matched your search
@@ -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 };
|
||||
|
||||
+33
-18
@@ -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 };
|
||||
|
||||
@@ -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<Partial<PrInfo>>(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';
|
||||
|
||||
@@ -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<TrainOut> {
|
||||
@@ -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`]);
|
||||
}
|
||||
@@ -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']);
|
||||
|
||||
@@ -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/<nonce>'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);
|
||||
|
||||
+11
-2
@@ -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() };
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user