Files
gstack/lib/ship-queue-train-cli.ts
Garry Tanandcapy-ai[bot] 3df7484bc7 v1.91.339.0 ship-queue: stamped merge-train prefixes and train land (GSTA-154 PR B) (#3595)
* 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>

* 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>

* 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>

* test: timeout on the land gh shim's git spawn

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* 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>

* 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>

* 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>

* 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(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>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* ship: write the Steps 12-16 range with an ASCII hyphen so the skeleton fits maxSkeletonBytes

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-30 19:23:01 +00:00

174 lines
12 KiB
TypeScript

/**
* Argument parsing and output for the merge-train surface of
* bin/gstack-ship-queue: `train build|status|verdict|bisect|rebuild|prune`,
* `ready --screen|--no-screen [--broad]` and `verify --train`. Every command
* prints its lines, the coded error line on stderr, then `NEXT: <command>`
* and the canonical `GSTACK_RESULT: skill=ship-queue status=<s> run=<record>`.
* Loaded by the bin only when one of those paths runs.
*/
import { EXIT, formatResult } from './headless-artifacts';
import { RESULT_CODES } from './result-codes';
import type { QueueContext } from './ship-queue';
import { buildTrain, pruneTrains, trainStatus, type TrainOut } from './ship-queue-train';
import { trainReady, verifyScreen } from './ship-queue-train-ready';
import { trainBisect, trainRebuild, trainVerdict } from './ship-queue-train-verdict';
import { verifyTrain } from './ship-queue-train-verify';
import { trainWatch } from './ship-queue-train-watch';
import { verifyPostMerge } from './ship-queue-post-merge';
import { trainForget } from './ship-queue-train-forget';
import { redactCause } from './ship-queue-train-record';
export const TRAIN_HELP = ` train build --prs <n>@<sha>[,...] [--order <n>,...] [--urgent | --repair] [--dry-run [--stack-tests [--stack-tests-command "<cmd>"]]] [--free-timeout <s>]
[--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 <nonce> resume a train (push, free tests, dispatch) from its record
merge pinned unstamped heads in slot order onto main, free tests on
train/<nonce>, then one full gate through eval-bg's nonce
train status [--train <nonce>] never spends: rebuild records from refs/notes/gstack-trains, reconcile
unconfirmed dispatches by nonce
train verdict --train <nonce> read the gate run and its record: GREEN, RED, INCOMPLETE or PENDING
train land --train <nonce> --pr <n> [--no-wait] [--timeout <min>]
(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 <nonce> --spend-cap <usd> [--dry-run]
red cases on every prefix T0..Tk, resumable and capped
train rebuild --train <nonce> [--infra] the one rebuild an all-green bisect earns (or the one INFRA re-dispatch)
train watch --train <nonce> [--timeout <s>]
resumable follow: runs the step the record asks for (build, status), waits for the
pending run, reads the verdict, prints NEXT; rerun it after a restart (no daemon)
train forget --train <nonce> | --receipt <pr>@<sha> [--dry-run]
remove a settled train record (attempts, bisect) or a READY receipt from
refs/notes/gstack-trains under an exact lease, then delete the local files
train prune [--train <nonce>] [--dry-run] [--stale]
delete train/<nonce> branches of merged, superseded or dropped trains; --stale also
settles merging or green trains whose every PR is merged or closed on GitHub, and
deletes screen/<pr>-* branches of merged or closed PRs
ready --pr <n> --screen [--broad] | --no-screen [--dry-run] [--dispatch-ci <wf.yml[,…]> [--timeout <s>]]
(train policy) READY plus the screen choice, recorded as a receipt; a head whose
screen run is RED is refused (QUEUE_SCREEN_RED). --dispatch-ci runs the named
workflows on the branch first (a conflicting PR has no pull_request CI), waits for
them and clears the matching requires-remote pregate lanes with the run URLs
verify --pr <n> --train <nonce> [--no-fetch]
(train policy) the merge check against a green train record
verify --pr <n> --screen (train policy) the PR head's screen verdict read from its run: SCREEN_VERDICT and
FLAKY lines, recorded in the READY receipt
verify --post-merge <sha> [--timeout <s>] after a merge: wait for every check run at the merged commit to settle;
POST_MERGE: green|red|pending plus one CHECK: line per workflow (resumable)
Merge trains need a "train" block in the base revision's ship policy (docs/ship-policy.md#run-a-merge-train).`;
function flag(args: string[], name: string): string | undefined {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
}
export function printTrain(cmd: string, out: TrainOut): number {
for (const line of out.lines) process.stdout.write(line + '\n');
if (out.failure) {
const f = out.failure;
process.stderr.write(`gstack-ship-queue ${cmd}: ${f.message}; fix: ${f.fix ?? RESULT_CODES[f.code].fix} (${f.code})\n`);
}
process.stdout.write(`NEXT: ${out.next}\n`);
process.stdout.write(formatResult('ship-queue', out.status, out.run) + '\n');
return out.exit;
}
function usage(message: string): number {
process.stderr.write(`gstack-ship-queue: ${message}\n${TRAIN_HELP}\n`);
return EXIT.usage;
}
async function guarded(cmd: string, fn: () => Promise<TrainOut> | TrainOut): Promise<number> {
try {
return printTrain(cmd, await fn());
} catch (error) {
const cause = redactCause(error instanceof Error ? error.message : String(error));
process.stderr.write(`gstack-ship-queue ${cmd}: unexpected failure: ${cause}\n`);
process.stdout.write(`NEXT: gstack-ship-queue train status\n`);
process.stdout.write(formatResult('ship-queue', 'interrupted', 'none') + '\n');
return EXIT.fail;
}
}
const positive = (s: string | undefined, fallback: number) => (s !== undefined && /^\d+(\.\d+)?$/.test(s) && Number(s) > 0 ? Number(s) : s === undefined ? fallback : NaN);
export async function trainMain(ctx: QueueContext, args: string[]): Promise<number> {
const [sub, ...rest] = args;
const nonce = flag(rest, '--train');
const dryRun = rest.includes('--dry-run');
if (sub === 'build') {
if (!nonce && !flag(rest, '--prs')) return usage('train build needs --prs <n>@<sha>[,...] or --train <nonce>');
const freeTimeoutSec = positive(flag(rest, '--free-timeout'), 2700);
if (Number.isNaN(freeTimeoutSec)) return usage('--free-timeout needs seconds');
if (rest.includes('--urgent') && rest.includes('--repair')) return usage('--urgent and --repair are separate one-PR trains; pass one');
if (rest.includes('--order') && (nonce || !flag(rest, '--order'))) return usage('--order <n>,... orders a new train from --prs; a resumed train keeps its recorded order');
const stackCommand = flag(rest, '--stack-tests-command');
if ((rest.includes('--stack-tests') || stackCommand !== undefined) && !dryRun) return usage('--stack-tests runs each PR\'s new test files on its integrated prefix; it needs --dry-run');
const stackWords = stackCommand?.split(/\s+/).filter(Boolean);
if (stackCommand !== undefined && !stackWords?.length) return usage('--stack-tests-command needs a command');
return guarded('train build', () => 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 }));
if (sub === 'prune') return guarded('train prune', () => pruneTrains(ctx, { dryRun, train: nonce, stale: rest.includes('--stale') }));
if (sub === 'forget') {
const receipt = flag(rest, '--receipt');
if (!nonce && !receipt) return usage('train forget needs --train <nonce> or --receipt <pr>@<sha>');
return guarded('train forget', () => trainForget(ctx, { train: nonce, receipt, dryRun }));
}
if (!nonce) return usage(`train ${sub ?? '<subcommand>'} needs --train <nonce>`);
if (sub === 'watch') {
const timeoutSec = positive(flag(rest, '--timeout'), 7200);
if (Number.isNaN(timeoutSec)) return usage('--timeout needs seconds');
const freeTimeoutSec = positive(flag(rest, '--free-timeout'), 2700);
if (Number.isNaN(freeTimeoutSec)) return usage('--free-timeout needs seconds');
return guarded('train watch', () => 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 <number>');
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);
if (Number.isNaN(cap)) return usage('train bisect needs --spend-cap <usd>');
return guarded('train bisect', () => trainBisect(ctx, { train: nonce, spendCap: cap, dryRun }));
}
return usage(`unknown train subcommand ${sub}`);
}
export async function readyMain(ctx: QueueContext, args: string[], base: { pr: string; pregate?: string; gateExit?: number; gateLog?: string }): Promise<number> {
const screen = args.includes('--screen');
const noScreen = args.includes('--no-screen');
if (screen && noScreen) return usage('pass one of --screen or --no-screen');
if (args.includes('--broad') && !screen) return usage('--broad selects the screen policy; it needs --screen');
const dispatchCi = flag(args, '--dispatch-ci');
const workflows = dispatchCi === undefined ? [] : dispatchCi.split(',').map(s => s.trim()).filter(Boolean);
if (dispatchCi !== undefined && (!workflows.length || workflows.some(w => !/^[\w.-]+\.ya?ml$/.test(w)))) return usage('--dispatch-ci names workflow files under .github/workflows, comma-separated (free-tests.yml,windows-free-tests.yml)');
const timeoutSec = positive(flag(args, '--timeout'), 2700);
if (!Number.isFinite(timeoutSec)) return usage('--timeout needs a positive number of seconds');
return guarded('ready', () => trainReady(ctx, { ...base, screen: screen ? 'screen' : noScreen ? 'no-screen' : undefined, broad: args.includes('--broad'), dryRun: args.includes('--dry-run'),
dispatchCi: workflows.length ? { workflows, timeoutSec, pollMs: Number(process.env.GSTACK_SHIP_QUEUE_POLL_MS) || 30_000 } : undefined }));
}
export async function postMergeMain(ctx: QueueContext, args: string[]): Promise<number> {
const sha = flag(args, '--post-merge');
if (!sha || !/^[0-9a-f]{40}$/.test(sha)) return usage('verify --post-merge needs the merged commit\'s full 40-hex SHA');
const timeoutSec = positive(flag(args, '--timeout'), 2700);
if (!Number.isFinite(timeoutSec)) return usage('--timeout needs a positive number of seconds');
return guarded('verify', () => verifyPostMerge(ctx, { sha, timeoutSec, pollMs: Number(process.env.GSTACK_SHIP_QUEUE_POLL_MS) || 30_000 }));
}
export async function verifyMain(ctx: QueueContext, args: string[], pr: string): Promise<number> {
if (args.includes('--screen')) return guarded('verify', () => verifyScreen(ctx, { pr }));
return guarded('verify', () => verifyTrain(ctx, { pr, train: flag(args, '--train')!, noFetch: args.includes('--no-fetch') }));
}