mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* feat(ship-queue): require a current /ship receipt before READY, train build and train merge Garry, 2026-10-25: use the gstack ship skill before merging PRs. ready printed SHIP_RECEIPT: none as information and returned ok, so the pool was READY'd without /ship. Now ready, train build (dry run included) and verify --train refuse with QUEUE_RECEIPT_MISSING or QUEUE_RECEIPT_STALE from the PR body alone; verify accepts the receipt written for the pinned head when the current head is its stamp commit. gstack-ship-receipt result is /ship's terminal step (GSTACK_RESULT: skill=ship, run dir with the head and receipt), unattended /ship may update an existing PR's body, and INSTALL_UNVERIFIED with no verdict names the project directory to install from (GSTA-85). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(ship): pin Step 22 in workflow-required-steps and tighten the ship skeleton Gate edit: test/fixtures/workflow-required-steps.json pins the new "## Step 22: Terminal line (every run)" heading (strengthens: a later edit that drops the terminal line turns the required-steps test red). Steps 14, 16, 18, 20 and 22 prose tightened so the carve-guards raise shrinks to 81,600 / 1.6740 (measured skeleton 81,535, union 314,027). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (ready at the stamp commit, screen on a stale merge ref, ship-run.json) - ready accepts the pin's receipt at a slot's stamp commit (plus the slot-2 merge of main), as verify --train already did; a real edit stays STALE - ready --screen refuses QUEUE_SCREEN_STALE_REF when refs/pull/<n>/merge is missing or merges an older head; a finished screen that planned 0 cases is never followed, and train build seats that slot unscreened (GSTA-95 on #3431) - gstack-ship-receipt result writes ship-run.json (a /ship record, not a run v1 manifest), validates --head, and prints status=incomplete on gh failures - bindReceipt takes an optional pr and renders one-line SHIP_RECEIPT lines - tests for every state-matrix cell the review named; the train fixture's merge ref is a real merge commit Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: cycle-2 review fixes (private screen scratch, no bare-merge receipt reuse, 12-hex --head, one receipt scrub) and align the unattended hook text with the GSTACK_RESULT writers (GSTA-91) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: adversarial-review fixes (result refuses an unpushed HEAD, resolves --out and reports unwritable records; ready names a failed merge-ref fetch; unattended /ship never runs gh pr ready) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: close review-cycle gaps (a stamp must change a stamp file, result requires a full --head and fails closed without headRefOid, digits-only --pr) with tests for every cycle-3 branch Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: give the fsck-rejected merge-ref spawns a timeout (spawnsync tripwire) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: detect a hollow screen from the plan it uploads (no paid-plan artifact or nothing planned), correct two troubleshooting facts, and normalise result --pr Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(hollow-screen): read manifest.json by name, list artifacts by name, say when the plan is unreadable; lowercase result --head Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(hollow-screen): expired and unreadable run rows, uppercase --head; docs name every SCREEN: literal Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(train build): say when the repository slug is unreadable instead of counting the screen silently Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: name an ls-remote failure in ready --screen, refuse result --pr with --body, cap echoed receipt errors, tell an unreadable install verdict from a missing one Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: ls-remote refusal, --pr with --body, capped receipt errors, unreadable slug and verdict (both twins); docs name the new literals Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: scrub Unicode line separators from receipt text, report an unreadable --body as incomplete, refuse unsupported verdict schemas in the bash twin 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>
182 lines
12 KiB
TypeScript
Executable File
182 lines
12 KiB
TypeScript
Executable File
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
|
|
/**
|
|
* gstack-ship-queue — the shared merge queue, one subcommand per protocol
|
|
* step (field list 2, W1; docs/ship-policy.md#run-a-shared-merge-queue).
|
|
*
|
|
* gstack-ship-queue ready --pr <n> [--pregate <pregate.json>] [--gate-exit <rc> --gate-log <file>]
|
|
* (refused with QUEUE_RECEIPT_MISSING|STALE unless the PR body carries /ship's receipt for HEAD)
|
|
* gstack-ship-queue stamp --pr <n> --version <X> --expect-gated <sha> --expect-base <sha>
|
|
* [--expect-order <pr,...>] [--allow-repo-commands] [--gate-exit <rc> --gate-log <file>]
|
|
* gstack-ship-queue verify --pr <n> (--gated <sha> | --record <gate-ahead.json>) [--receipt <sha256>]
|
|
* [--expect-head <sha>] [--ref <ref>] [--no-fetch] [--gate-exit <rc> --gate-log <file>]
|
|
* gstack-ship-queue gate-ahead --after <pr> --pr <n> [--predecessor-ref <ref>] [--allow-repo-commands]
|
|
* gstack-ship-queue gate-ahead --record --pr <n> --attempt <id> --exit <rc> --log <file>
|
|
* gstack-ship-queue gate-ahead --list --pr <n>
|
|
* gstack-ship-queue train build|status|verdict|bisect|rebuild|prune ... (merge trains; train --help)
|
|
*
|
|
* Every subcommand takes --base <branch> (default origin/HEAD, else main) and
|
|
* ends with GSTACK_RESULT: skill=ship-queue status=complete|refused run=<path>.
|
|
* gstack-restamp stays the primitive. Nothing here pushes, except under a
|
|
* ship policy with a `train` block: merge trains push train/<nonce> 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
|
|
* (loaded only on the train paths).
|
|
*/
|
|
import { spawnSync } from 'node:child_process';
|
|
import * as path from 'node:path';
|
|
import { EXIT, exitTableHelp, formatResult } from '../lib/headless-artifacts';
|
|
import { versionAtRef } from '../lib/restamp';
|
|
import { RESULT_CODES } from '../lib/result-codes';
|
|
import { resolveBaseBranch } from '../lib/ship-policy';
|
|
import { loadBasePolicy, ready, stamp, verify, type QueueContext, type StepOutput } from '../lib/ship-queue';
|
|
import { attemptFile, listAttempts, prepareGateAhead, recordGateAhead, type QueueFailure } from '../lib/ship-queue-gate';
|
|
|
|
const HELP = `gstack-ship-queue — the shared merge queue's protocol steps (docs/ship-policy.md#run-a-shared-merge-queue)
|
|
|
|
ready --pr <n> [--pregate <file>] [--gate-exit <rc> --gate-log <file>]
|
|
READY #<n> <head> STAMP_VERSION=<X|unstamped>, CI at that head, PREGATE, gate state,
|
|
SHIP_RECEIPT (refused unless the PR body carries /ship's receipt for that head)
|
|
stamp --pr <n> --version <X> --expect-gated <sha> --expect-base <sha> [--expect-order <pr,...>] [--allow-repo-commands]
|
|
[--gate-exit <rc> --gate-log <file>]
|
|
restamp, commit and prove: QUEUE_STAMP: <pr> <X> head= gated= tree= receipt=
|
|
verify --pr <n> --gated <sha> | --record <file> [--receipt <sha256>] [--expect-head <sha>] [--ref <ref>] [--no-fetch]
|
|
[--gate-exit <rc> --gate-log <file>] (full_e2e: --gated needs the lane's passing gate run on that head)
|
|
the coordinator's check from the PR's pushed head: VERIFY: ... and MERGE_CONDITION
|
|
gate-ahead --after <pr> --pr <n> [--predecessor-ref <ref>] [--allow-repo-commands]
|
|
prepare an attempt: merged, stamped worktree, gated sha and the gate command
|
|
gate-ahead --record --pr <n> --attempt <id> --exit <rc> --log <file>
|
|
bind the gate result to that attempt's gated tree; removes its worktree
|
|
gate-ahead --list --pr <n> attempts for a PR, newest last (the current one)
|
|
train build|status|verdict|bisect|rebuild|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 <branch>: base branch (default: origin/HEAD, else main). Nothing here pushes.
|
|
${exitTableHelp()}`;
|
|
|
|
function flag(args: string[], name: string): string | undefined {
|
|
const i = args.indexOf(name);
|
|
return i >= 0 ? args[i + 1] : undefined;
|
|
}
|
|
|
|
function finish(cmd: string, out: StepOutput): number {
|
|
for (const line of out.lines) process.stdout.write(line + '\n');
|
|
if (out.failure) {
|
|
const f = out.failure;
|
|
process.stderr.write(`gstack-ship-queue ${cmd}: ${f.message}; fix: ${f.fix ?? RESULT_CODES[f.code].fix} (${f.code})\n`);
|
|
}
|
|
process.stdout.write(formatResult('ship-queue', out.ok ? 'complete' : 'refused', out.run) + '\n');
|
|
if (out.ok) return EXIT.ok;
|
|
return out.failure && ['QUEUE_VERIFY_MISMATCH', 'QUEUE_STAMP_NOT_STAMP_ONLY'].includes(out.failure.code) ? EXIT.fail : EXIT.refused;
|
|
}
|
|
|
|
const shq = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
|
|
function usage(message: string): number {
|
|
process.stderr.write(`gstack-ship-queue: ${message}\n${HELP}\n`);
|
|
return EXIT.usage;
|
|
}
|
|
|
|
function gateAhead(ctx: QueueContext, args: string[]): number {
|
|
const pr = flag(args, '--pr');
|
|
if (!pr) return usage('gate-ahead needs --pr <n> (records are keyed by PR)');
|
|
if (args.includes('--list')) {
|
|
const all = listAttempts(ctx.repoRoot, pr);
|
|
for (const a of all) process.stdout.write(`ATTEMPT: ${a.attempt} state=${a.state} gated_tree=${a.gated_tree} predecessor=#${a.predecessor_pr}@${a.predecessor_head.slice(0, 12)}${a.result ? ` exit=${a.result.exit}` : ''}${a === all.at(-1) ? ' current' : ''}\n`);
|
|
if (!all.length) process.stdout.write(`ATTEMPT: none for #${pr}\n`);
|
|
return EXIT.ok;
|
|
}
|
|
if (args.includes('--record')) {
|
|
const attempt = flag(args, '--attempt');
|
|
const exit = flag(args, '--exit');
|
|
const log = flag(args, '--log');
|
|
if (!attempt || exit === undefined || !/^-?\d+$/.test(exit) || !log) return usage('gate-ahead --record needs --attempt <id> --exit <rc> --log <file>');
|
|
const r = recordGateAhead({ repoRoot: ctx.repoRoot, pr, attempt, exit: Number(exit), log: path.resolve(log) });
|
|
if ('code' in r) return finish('gate-ahead', { ok: false, lines: [], failure: r, run: attemptFile(ctx.repoRoot, pr, attempt) });
|
|
const cur = r.current && r.current.attempt !== attempt ? `no (superseded by ${r.current.attempt})` : 'yes';
|
|
return finish('gate-ahead', {
|
|
ok: true, run: path.relative(ctx.repoRoot, r.file),
|
|
lines: [
|
|
`GATE_RECORD: pr=${pr} attempt=${attempt} exit=${r.record.result!.exit} gated_tree=${r.record.gated_tree} log_sha256=${r.record.result!.log_sha256} current=${cur}`,
|
|
...(r.repeated ? ['note: identical retry; the stored result is returned'] : []),
|
|
`record: ${r.file}`,
|
|
`next: send the record file with the READY report; the coordinator runs gstack-ship-queue verify --pr ${pr} --record <file> once this PR is stamped`,
|
|
],
|
|
});
|
|
}
|
|
const after = flag(args, '--after');
|
|
if (!after) return usage('gate-ahead needs --after <predecessor-pr>, --record or --list');
|
|
const policy = loadBasePolicy(ctx);
|
|
if ('message' in policy) return finish('gate-ahead', { ok: false, lines: [], failure: policy, run: 'none' });
|
|
const at = versionAtRef(ctx.repoRoot, 'HEAD');
|
|
const r = prepareGateAhead({
|
|
repoRoot: ctx.repoRoot, base: ctx.base, pr, after, predecessorRef: flag(args, '--predecessor-ref'), allowRepoCommands: args.includes('--allow-repo-commands'),
|
|
policy: policy.policy, versionRel: 'rel' in at ? at.rel : 'VERSION',
|
|
});
|
|
if ('code' in r) return finish('gate-ahead', { ok: false, lines: [], failure: r as QueueFailure, run: 'none' });
|
|
const rec = r.record;
|
|
const log = path.join(path.dirname(rec.worktree), 'gate.log');
|
|
const cmd = rec.gate_command ?? '<your gate command>';
|
|
const superseded = listAttempts(ctx.repoRoot, pr).filter(a => a.attempt !== rec.attempt && a.state === 'prepared').map(a => a.attempt);
|
|
return finish('gate-ahead', {
|
|
ok: true, run: path.relative(ctx.repoRoot, r.file),
|
|
lines: [
|
|
`GATE_AHEAD: pr=${pr} attempt=${rec.attempt} gated=${rec.gated_head} tree=${rec.gated_tree} merge_tree=${rec.merge_tree} predecessor=#${rec.predecessor_pr}@${rec.predecessor_head.slice(0, 12)} version=${rec.version}`,
|
|
`worktree: ${rec.worktree}`,
|
|
`record: ${r.file}`,
|
|
`GATE_COMMAND: ${rec.gate_command ?? 'none (the policy sets no gate_command; run the repo gate you would run on a PR head)'}`,
|
|
...(superseded.length ? [`note: this attempt is now current; earlier unrecorded attempts (${superseded.join(', ')}) keep their worktrees until recorded`] : []),
|
|
`next: (cd ${shq(rec.worktree)} && ${cmd}) > ${shq(log)} 2>&1; gstack-ship-queue gate-ahead --record --pr ${pr} --attempt ${rec.attempt} --exit $? --log ${shq(log)}`,
|
|
],
|
|
});
|
|
}
|
|
|
|
async function main(argv: string[]): Promise<number> {
|
|
const [cmd, ...args] = argv;
|
|
if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') { process.stdout.write(HELP + '\n'); return cmd ? EXIT.ok : EXIT.usage; }
|
|
const probe = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', timeout: 15_000 });
|
|
if (probe.status !== 0) return usage('not inside a git repository');
|
|
const repoRoot = probe.stdout.trim();
|
|
const gh = (a: string[]) => { const r = spawnSync('gh', a, { cwd: repoRoot, encoding: 'utf8', timeout: 60_000 }); return { ok: r.status === 0, out: r.stdout ?? '', err: r.stderr ?? (r.error ? String(r.error.message) : '') }; };
|
|
const ctx: QueueContext = { repoRoot, base: resolveBaseBranch(repoRoot, flag(args, '--base')), gh };
|
|
const pr = flag(args, '--pr');
|
|
if (cmd === 'gate-ahead') return gateAhead(ctx, args);
|
|
if (cmd === 'train') {
|
|
const train = await import('../lib/ship-queue-train-cli');
|
|
if (!args[0] || args[0].startsWith('-')) { process.stdout.write(train.TRAIN_HELP + '\n'); return args.includes('--help') || args.includes('-h') ? EXIT.ok : EXIT.usage; }
|
|
return train.trainMain(ctx, args);
|
|
}
|
|
if (!pr || !/^\d+$/.test(pr)) return usage(`${cmd} needs --pr <number>`);
|
|
const gateExit = flag(args, '--gate-exit');
|
|
if (gateExit !== undefined && !/^-?\d+$/.test(gateExit)) return usage('--gate-exit needs an exit code');
|
|
const gate = { gateExit: gateExit === undefined ? undefined : Number(gateExit), gateLog: flag(args, '--gate-log') };
|
|
if (cmd === 'ready') {
|
|
const loaded = loadBasePolicy(ctx);
|
|
if (!('message' in loaded) && loaded.policy.train) return (await import('../lib/ship-queue-train-cli')).readyMain(ctx, args, { pr, pregate: flag(args, '--pregate'), ...gate });
|
|
if (['--screen', '--no-screen', '--broad'].some(f => args.includes(f))) process.stderr.write('gstack-ship-queue ready: --screen/--no-screen/--broad ignored: the base policy declares no train block\n');
|
|
return finish('ready', ready(ctx, { pr, pregate: flag(args, '--pregate'), ...gate }));
|
|
}
|
|
if (cmd === 'stamp') {
|
|
const version = flag(args, '--version');
|
|
const expectGated = flag(args, '--expect-gated');
|
|
const expectBase = flag(args, '--expect-base');
|
|
if (!version || !expectGated || !expectBase) return usage('stamp needs --version <X> --expect-gated <sha> --expect-base <sha>');
|
|
return finish('stamp', stamp(ctx, {
|
|
pr, version, expectGated, expectBase, expectOrder: flag(args, '--expect-order')?.split(',').map(s => s.trim()).filter(Boolean),
|
|
allowRepoCommands: args.includes('--allow-repo-commands'), ...gate,
|
|
failAfter: process.env.GSTACK_SHIP_QUEUE_FAIL_AFTER,
|
|
}));
|
|
}
|
|
if (cmd === 'verify') {
|
|
if (flag(args, '--train')) return (await import('../lib/ship-queue-train-cli')).verifyMain(ctx, args, pr);
|
|
const record = flag(args, '--record');
|
|
return finish('verify', verify(ctx, {
|
|
pr, gated: flag(args, '--gated'), record: record ? path.resolve(record) : undefined, receipt: flag(args, '--receipt'),
|
|
expectHead: flag(args, '--expect-head'), ref: flag(args, '--ref'), noFetch: args.includes('--no-fetch'), ...gate,
|
|
}));
|
|
}
|
|
return usage(`unknown subcommand ${cmd}`);
|
|
}
|
|
|
|
process.exitCode = await main(process.argv.slice(2));
|