mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
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>
This commit is contained in:
1 parent
dc309c793e
commit
ead4a7c273
11 files changed
+321
-19
No files matched your search
@@ -216,7 +216,7 @@ function prInfo(ctx: QueueContext, pr: string): PrInfo | QueueFailure {
|
||||
}
|
||||
|
||||
/** Poll the stamp commit's checks (the same read as the pin's) until they are green, one is red, or the time runs out. */
|
||||
function waitChecks(ctx: QueueContext, sha: string, minutes: number): void {
|
||||
export function waitChecks(ctx: QueueContext, sha: string, minutes: number): void {
|
||||
const deadline = Date.now() + minutes * 60_000;
|
||||
for (let ci = ciAtHead(ctx, sha); !ci.green && !ci.line.startsWith('red') && Date.now() < deadline; ci = ciAtHead(ctx, sha)) {
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 30_000);
|
||||
|
||||
@@ -19,6 +19,8 @@ import { trainForget } from './ship-queue-train-forget';
|
||||
import { redactCause } from './ship-queue-train-record';
|
||||
|
||||
export const TRAIN_HELP = ` train build --prs <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
|
||||
@@ -27,6 +29,9 @@ export const TRAIN_HELP = ` train build --prs <n>@<sha>[,...] [--order <n>,...]
|
||||
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)
|
||||
@@ -105,6 +110,7 @@ export async function trainMain(ctx: QueueContext, args: string[]): Promise<numb
|
||||
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 }));
|
||||
@@ -123,6 +129,13 @@ export async function trainMain(ctx: QueueContext, args: string[]): Promise<numb
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* `train land --train <nonce> --pr <n>` (docs/ship-policy.md#train-land, GSTA-154 PR B): the merge step for
|
||||
* one slot of a stamped train (changelog_fragments). The train built S_1..S_k and gated S_k; slot i
|
||||
* publishes S_i exactly. With main's tree equal to S_(i-1)'s (slot 1: the train base), land commits S_i's
|
||||
* tree with parents [pin_i, main], labels the PR evals-no-debounce (a tool-made head gets no later push,
|
||||
* so the quiet window protects nothing), pushes under --force-with-lease=<branch>:<pin>, waits for the
|
||||
* PR's checks on that commit (CI runs live at the slot until GSTA-156's reuse is on main), merges with
|
||||
* --match-head-commit and confirms main's tree is S_i. Any other main is the freeze path
|
||||
* (QUEUE_TRAIN_TREE_MISMATCH), as for verify. Journaled beside the train record; a rerun resumes.
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { ciAtHead, type QueueContext } from './ship-queue';
|
||||
import { git, writeJsonAtomic } from './ship-queue-gate';
|
||||
import { waitChecks } from './ship-queue-land';
|
||||
import { bindReceipt } from './ship-receipt';
|
||||
import { checkRun, prInfo } from './ship-queue-train-github';
|
||||
import { activeFreeze, attemptsOf, trainFile } from './ship-queue-train-record';
|
||||
import { fetchBase, fetchTrainBranch, findTrain, frozenLines, nextFor, outFailed, outOk, outPending, outRefused, trainEnv, type TrainOut } from './ship-queue-train';
|
||||
import { confirmMerged, stopped } from './ship-queue-train-verify';
|
||||
|
||||
const Q = 'gstack-ship-queue';
|
||||
interface SlotJournal { schema_version: 1; pr: string; pin: string; stamp_commit: string; base: string }
|
||||
|
||||
export async function trainLand(ctx: QueueContext, o: { train: string; pr: string; noWait?: boolean; timeoutMin?: number }): Promise<TrainOut> {
|
||||
const fetchErr = fetchBase(ctx);
|
||||
const env = trainEnv(ctx);
|
||||
if ('code' in env) return outRefused(env, [], 'none', 'none');
|
||||
if (fetchErr) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: fetchErr }, [], 'none', 'none');
|
||||
const t = findTrain(env, o.train);
|
||||
if (!t) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `no train record ${o.train} here or in refs/notes/gstack-trains` }, [], `${Q} train status`, 'none');
|
||||
const run = trainFile(t.nonce);
|
||||
const i = t.slots.findIndex(s => s.pr === o.pr);
|
||||
if (i < 0) return outRefused({ code: 'QUEUE_GATE_MISSING', message: `train ${t.nonce} does not include #${o.pr}` }, [], nextFor(t), run);
|
||||
if (!t.stamped) return outRefused({ code: 'QUEUE_GATE_MISSING', message: `train ${t.nonce} was built without stamped prefixes; merge it with the slot stamp and ${Q} verify` }, [], `${Q} verify --pr ${o.pr} --train ${t.nonce}`, run);
|
||||
const gate = attemptsOf(t).filter(a => a.purpose !== 'bisect').at(-1);
|
||||
if (gate?.outcome !== 'GREEN' || !['green', 'merging', 'merged'].includes(t.state)) return outRefused({ code: 'QUEUE_GATE_MISSING', message: `train ${t.nonce} has no green record (state ${t.state}, last gate ${gate?.outcome ?? 'none'})` }, [], nextFor(t), run);
|
||||
const check = checkRun(ctx, t.head, env.train.full_gate_check);
|
||||
if (check?.conclusion !== 'success') return outRefused({ code: 'QUEUE_GATE_MISSING', message: `no successful ${env.train.full_gate_check} check run on the train head ${t.head.slice(0, 12)}` }, [], `${Q} train verdict --train ${t.nonce}`, run);
|
||||
if (!fetchTrainBranch(env, t)) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `the train's prefix commits are not here and ${t.branch} could not be fetched` }, [], `${Q} train status --train ${t.nonce}`, run);
|
||||
const freeze = activeFreeze(env.repo.keys);
|
||||
if (freeze && freeze.repair?.train !== t.nonce) return outRefused({ code: 'QUEUE_TRAIN_FROZEN', message: `the queue is frozen (${freeze.reason}); only the admitted repair train merges` }, frozenLines(freeze), `${Q} train build --repair --prs <repair-pr>@<sha>`, run);
|
||||
|
||||
const root = ctx.repoRoot;
|
||||
const s = t.stamped[i]!;
|
||||
const pred = i === 0 ? { index: 0, tree: t.prefixes[0]!.tree } : t.stamped[i - 1]!;
|
||||
const pin = t.slots[i]!.head;
|
||||
const mainNow = () => { fetchBase(ctx); const sha = git(root, ['rev-parse', `${env.remote}/${ctx.base}`]).out; return { sha, tree: git(root, ['rev-parse', `${sha}^{tree}`]).out }; };
|
||||
const lines = [`TRAIN_LAND: ${t.nonce} slot ${i + 1}/${t.slots.length} #${o.pr} pin ${pin} -> S${s.index} v${s.version} tree ${s.tree}`];
|
||||
let main = mainNow();
|
||||
if (main.tree === s.tree) return confirmMerged(env, t, o.pr, main.sha, s.index, [...lines, `main: ${main.sha} is S${s.index} exactly`]);
|
||||
if (main.tree !== pred.tree) return stopped(env, t, 'QUEUE_TRAIN_TREE_MISMATCH', `main ${main.sha.slice(0, 12)} is neither S${pred.index} nor S${s.index} exactly`, main.sha, lines, true);
|
||||
|
||||
const file = path.join(path.dirname(run), `land-${o.pr}.json`);
|
||||
let j: SlotJournal | null = null;
|
||||
try { j = JSON.parse(fs.readFileSync(file, 'utf8')) as SlotJournal; } catch { j = null; }
|
||||
const info = prInfo(ctx, o.pr);
|
||||
if ('error' in info) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: info.error }, lines, `${Q} train land --train ${t.nonce} --pr ${o.pr}`, run);
|
||||
if (info.isCrossRepository) return outRefused({ code: 'QUEUE_TRAIN_UNTRUSTED', message: `#${o.pr} is a fork head; train land pushes only crew branches` }, lines, 'none', run);
|
||||
if (!j || j.base !== main.sha) {
|
||||
if (info.headRefOid !== pin && info.headRefOid !== j?.stamp_commit) return stopped(env, t, 'QUEUE_TRAIN_MOVED', `#${o.pr}'s head is ${info.headRefOid.slice(0, 12)}, not its pin ${pin.slice(0, 12)}`, main.sha, lines, true);
|
||||
const shipped = bindReceipt(info.body, o.pr, [pin]);
|
||||
lines.push(shipped.line);
|
||||
if (!shipped.ok) return outRefused({ code: shipped.code, message: shipped.message }, lines, `#${o.pr}'s lane runs /ship at its pin and READYs again`, run);
|
||||
const commit = git(root, ['commit-tree', s.tree, '-p', pin, '-p', main.sha, '-m', `v${s.version} stamp: train ${t.nonce} S${s.index} (#${o.pr})`]).out;
|
||||
const label = ctx.gh(['pr', 'edit', o.pr, '--add-label', 'evals-no-debounce']);
|
||||
lines.push(`LABEL: evals-no-debounce ${label.ok ? 'added' : `not added (${(label.err ?? label.out).trim().slice(0, 80)}); the 10-minute quiet window applies`}`);
|
||||
const lease = j?.stamp_commit && info.headRefOid === j.stamp_commit ? j.stamp_commit : pin;
|
||||
const push = git(root, ['push', '-q', `--force-with-lease=refs/heads/${info.headRefName}:${lease}`, env.remote, `${commit}:refs/heads/${info.headRefName}`]);
|
||||
if (!push.ok) return stopped(env, t, 'QUEUE_TRAIN_MOVED', `#${o.pr}'s branch moved off ${lease.slice(0, 12)} before the stamp push; train land never overwrites it`, main.sha, lines, true);
|
||||
j = { schema_version: 1, pr: o.pr, pin, stamp_commit: commit, base: main.sha };
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
writeJsonAtomic(file, j);
|
||||
lines.push(`PUSHED: ${commit} (tree S${s.index}, parents [pin, main ${main.sha.slice(0, 12)}]) to ${info.headRefName}`);
|
||||
const next = `gstack-pr-wait ${o.pr} --until green && ${Q} train land --train ${t.nonce} --pr ${o.pr}`;
|
||||
if (o.noWait) return outPending({ code: 'QUEUE_NOT_READY', message: `checks run on the stamp commit ${commit.slice(0, 12)}` }, lines, next, run);
|
||||
waitChecks(ctx, commit, o.timeoutMin ?? 60);
|
||||
}
|
||||
const head = prInfo(ctx, o.pr);
|
||||
if ('error' in head || head.headRefOid !== j.stamp_commit) return stopped(env, t, 'QUEUE_TRAIN_MOVED', `#${o.pr}'s head is not the stamp commit ${j.stamp_commit.slice(0, 12)}`, main.sha, lines, true);
|
||||
const ci = ciAtHead(ctx, j.stamp_commit);
|
||||
lines.push(`CI: ${ci.line}`);
|
||||
if (!ci.green) return outPending({ code: 'QUEUE_NOT_READY', message: `checks on the stamp commit ${j.stamp_commit.slice(0, 12)} are not green` }, lines, `gstack-pr-wait ${o.pr} --until green && ${Q} train land --train ${t.nonce} --pr ${o.pr}`, run);
|
||||
main = mainNow();
|
||||
if (main.sha !== j.base) return stopped(env, t, 'QUEUE_TRAIN_TREE_MISMATCH', `main moved to ${main.sha.slice(0, 12)} during the slot's checks (stamped on ${j.base.slice(0, 12)})`, main.sha, lines, true);
|
||||
const m = ctx.gh(['pr', 'merge', o.pr, '--squash', '--match-head-commit', j.stamp_commit]);
|
||||
if (!m.ok) return outFailed({ code: 'QUEUE_NOT_READY', message: `gh pr merge refused: ${(m.err ?? m.out).trim().slice(0, 200)}` }, lines, `${Q} train land --train ${t.nonce} --pr ${o.pr}`, run);
|
||||
main = mainNow();
|
||||
if (main.tree !== s.tree) return stopped(env, t, 'QUEUE_TRAIN_TREE_MISMATCH', `after the merge main ${main.sha.slice(0, 12)} is not S${s.index} exactly`, main.sha, lines, true);
|
||||
return confirmMerged(env, t, o.pr, main.sha, s.index, [...lines, `MERGED: #${o.pr} as ${main.sha}; main's tree is S${s.index} exactly`]);
|
||||
}
|
||||
@@ -34,12 +34,16 @@ export interface ScreenChoice {
|
||||
}
|
||||
export interface TrainSlot { pr: string; head: string; ready_at: string; screen: ScreenChoice }
|
||||
export interface TrainPrefix { index: number; commit: string; tree: string; pr: string | null }
|
||||
/** A stamped prefix S_i (changelog_fragments trains, lib/ship-queue-train-stamp.ts): what slot i publishes, exactly. */
|
||||
export interface StampedPrefix { index: number; commit: string; tree: string; pr: string; version: string; date: string }
|
||||
export interface FreeRun { run: number | null; url: string | null; head_sha: string | null; conclusion: string | null; verdict: 'green' | 'red' | 'missing' | 'stale' | 'cancelled' | 'pending' }
|
||||
export interface TrainRecord {
|
||||
schema: 1; kind: 'train'; nonce: string; key: string; repo_key: string; repo: string; base: { ref: string; sha: string }; policy: string;
|
||||
slots: TrainSlot[]; held: string[]; prefixes: TrainPrefix[]; head: string; tree: string; branch: string;
|
||||
urgent: boolean; repair: boolean; explicit_order?: boolean; state: TrainState; free: FreeRun | null; attempts: string[];
|
||||
merged: Array<{ pr: string; sha: string; tree: string; at: string }>; superseded_by: string | null;
|
||||
/** S_1..S_k when the base policy declares changelog_fragments; head and tree are then S_k's. Absent on plain trains. */
|
||||
stamped?: StampedPrefix[];
|
||||
effects: Array<{ at: string; what: string }>; error: string | null; created_at: string; updated_at: string;
|
||||
}
|
||||
export type AttemptPurpose = 'gate' | 'rebuild' | 'infra-redispatch' | 'bisect';
|
||||
|
||||
@@ -17,10 +17,10 @@ import { restamp, versionAtRef, versionFiles } from './restamp';
|
||||
import { matchesAny, type ShipPolicy } from './ship-policy';
|
||||
import { git, type QueueFailure } from './ship-queue-gate';
|
||||
import { mergeCommit } from './ship-queue-train';
|
||||
import type { TrainPrefix, TrainSlot } from './ship-queue-train-record';
|
||||
import type { StampedPrefix, TrainPrefix, TrainSlot } from './ship-queue-train-record';
|
||||
import { buildReceipt } from './tree-receipt';
|
||||
|
||||
export interface StampedPrefix { index: number; commit: string; tree: string; pr: string; version: string; date: string }
|
||||
export type { StampedPrefix };
|
||||
export interface StampChainOptions {
|
||||
repoRoot: string; base: string; policy: ShipPolicy; policyLabel: string; baseSha: string;
|
||||
slots: TrainSlot[]; plain: TrainPrefix[]; allowRepoCommands: boolean; now: Date;
|
||||
@@ -86,7 +86,7 @@ export function stampChain(o: StampChainOptions): { stamped: StampedPrefix[]; li
|
||||
git(o.repoRoot, ['worktree', 'remove', '--force', wt]);
|
||||
fs.rmSync(wt, { recursive: true, force: true });
|
||||
}
|
||||
const c = git(o.repoRoot, ['commit-tree', tree, '-p', pred, '-p', slot.head, '-m', `train S${i + 1}: #${slot.pr} v${version}`], { ...process.env, ...IDENTITY });
|
||||
const c = git(o.repoRoot, ['commit-tree', tree, '-p', pred, '-p', slot.head, '-m', `train S${i + 1}: merge #${slot.pr} at ${slot.head.slice(0, 12)} v${version}`], { ...process.env, ...IDENTITY });
|
||||
if (!c.ok) return { code: 'QUEUE_TRAIN_STAMP_MISMATCH', message: `commit-tree failed for S${i + 1}: ${c.err.slice(0, 120)}` };
|
||||
const plain = o.plain[i + 1];
|
||||
const why = plain ? stampMismatch(o, m.commit, c.out, plain.tree) : `no plain prefix T${i + 1}`;
|
||||
|
||||
@@ -170,7 +170,7 @@ function verifiedElsewhere(env: TrainEnv, t: TrainRecord, mainSha: string): stri
|
||||
* plain refusal (nothing was published). A GitHub state that cannot be read
|
||||
* is neither: the verify refuses and asks to be rerun, never "nothing merged".
|
||||
*/
|
||||
async function stopped(env: TrainEnv, t: TrainRecord, code: 'QUEUE_TRAIN_TREE_MISMATCH' | 'QUEUE_TRAIN_MOVED', message: string, mainSha: string, lines: string[], fetched: boolean): Promise<TrainOut> {
|
||||
export async function stopped(env: TrainEnv, t: TrainRecord, code: 'QUEUE_TRAIN_TREE_MISMATCH' | 'QUEUE_TRAIN_MOVED', message: string, mainSha: string, lines: string[], fetched: boolean): Promise<TrainOut> {
|
||||
const run = trainFile(t.nonce);
|
||||
// One GitHub read per unverified slot decides both the freeze and its record.
|
||||
const states = new Map(t.slots.filter(s => !t.merged.some(m => m.pr === s.pr)).map(s => [s.pr, prState(env.ctx, s.pr)] as const));
|
||||
@@ -197,6 +197,7 @@ export async function verifyTrain(ctx: QueueContext, o: { pr: string; train: str
|
||||
const run = trainFile(t.nonce);
|
||||
const i = t.slots.findIndex(s => s.pr === o.pr);
|
||||
if (i < 0) return outRefused({ code: 'QUEUE_GATE_MISSING', message: `train ${t.nonce} does not include #${o.pr}` }, [], nextFor(t), run);
|
||||
if (t.stamped) return outRefused({ code: 'QUEUE_GATE_MISSING', message: `train ${t.nonce} has stamped prefixes; its slots publish S_i exactly through train land, never a hand stamp checked modulo stamps` }, [], `${Q} train land --train ${t.nonce} --pr ${o.pr}`, run);
|
||||
const gate = attemptsOf(t).filter(a => a.purpose !== 'bisect').at(-1);
|
||||
if (gate?.outcome !== 'GREEN' || !['green', 'merging', 'merged'].includes(t.state)) {
|
||||
return outRefused({ code: 'QUEUE_GATE_MISSING', message: `train ${t.nonce} has no green record (state ${t.state}, last gate ${gate?.outcome ?? 'none'})` }, [], nextFor(t), run);
|
||||
@@ -230,7 +231,7 @@ export async function verifyTrain(ctx: QueueContext, o: { pr: string; train: str
|
||||
`merge #${o.pr} with expected head ${head.slice(0, 12)} (gstack-restamp stamps at merge), then ${Q} verify --pr ${o.pr} --train ${t.nonce}`, run);
|
||||
}
|
||||
|
||||
function confirmMerged(env: TrainEnv, t: TrainRecord, pr: string, main: string, index: number, lines: string[]): TrainOut {
|
||||
export function confirmMerged(env: TrainEnv, t: TrainRecord, pr: string, main: string, index: number, lines: string[]): TrainOut {
|
||||
const run = trainFile(t.nonce);
|
||||
if (!t.merged.some(m => m.pr === pr)) t.merged.push({ pr, sha: main, tree: git(env.ctx.repoRoot, ['rev-parse', `${main}^{tree}`]).out, at: nowIso() });
|
||||
t.state = t.merged.length === t.slots.length ? 'merged' : 'merging';
|
||||
|
||||
@@ -26,6 +26,7 @@ import { bindShippedReceipt } from './ship-receipt-depth';
|
||||
import { git, sha256, withIndex, type QueueFailure } from './ship-queue-gate';
|
||||
import { recordVerdict, screenVerdict, verdictLines } from './ship-queue-screen';
|
||||
import { stackTestLine, stackTests } from './ship-queue-stack-tests';
|
||||
import { stampChain } from './ship-queue-train-stamp';
|
||||
import { asRun, execFor, newestBranchRun, prInfo, remoteBranchSha, repoSlug, runView, checkRun } from './ship-queue-train-github';
|
||||
import {
|
||||
TERMINAL_STATES, activeFreeze, attemptsOf, listTrains, loadBisect, loadReady, loadTrain, nowIso, persist, rebuildFromNotes, repoIdentity, trainFile,
|
||||
@@ -321,6 +322,8 @@ export interface BuildOptions {
|
||||
pins?: string; order?: string; resume?: string; dryRun: boolean; urgent: boolean; repair: boolean; freeTimeoutSec: number; pollMs: number;
|
||||
/** `--dry-run --stack-tests`: run each PR's new test files on its integrated prefix (lib/ship-queue-stack-tests.ts); the command's words when `--stack-tests-command` names one. */
|
||||
stackTests?: string[] | true;
|
||||
/** Stamping S_i runs the base policy's restamp_command or release_tool (changelog_fragments trains). */
|
||||
allowRepoCommands?: boolean;
|
||||
}
|
||||
|
||||
/** `--order <pr>,<pr>,...`: every pinned PR exactly once (a leading # allowed). */
|
||||
@@ -412,13 +415,17 @@ async function newTrain(env: TrainEnv, o: BuildOptions): Promise<{ t: TrainRecor
|
||||
if (due && !o.dryRun) return outPending({ code: 'QUEUE_TRAIN_WAITING', message: `${order.train.length} of ${env.train.max_size} PRs; the oldest READY dispatches at ${due}` }, lines, `${Q} train build ${prsArgs(order.train, !!explicit)} (after ${due}, or with more PRs, or --urgent for one)`, 'none');
|
||||
const live = listTrains(env.repo.keys).find(t => t.key === key && !TERMINAL_STATES.includes(t.state) && t.state !== 'red' && t.state !== 'unattributed');
|
||||
if (live) return { t: Object.assign(live, { held: order.held.map(s => s.pr) }), lines };
|
||||
const head = chain.prefixes.at(-1)!;
|
||||
const policy = env.policy.policy;
|
||||
const stamped = policy.changelog_fragments ? stampChain({ repoRoot: env.ctx.repoRoot, base: env.ctx.base, policy, policyLabel: env.policy.source.label, baseSha, slots: order.train, plain: chain.prefixes, allowRepoCommands: !!o.allowRepoCommands, now: new Date(nowIso()) }) : null;
|
||||
if (stamped && 'code' in stamped) return outFailed(stamped, lines, `${Q} train build ${prsArgs(order.train, !!explicit)} --allow-repo-commands (after the named slot is fixed or dropped)`, 'none');
|
||||
if (stamped) lines.push(...stamped.lines);
|
||||
const head = stamped ? stamped.stamped.at(-1)! : chain.prefixes.at(-1)!;
|
||||
const nonce = makeNonce('gate', head.commit, baseSha, randomBytes(3).toString('hex'), { purpose: 'train' });
|
||||
const now = nowIso();
|
||||
return { lines, t: {
|
||||
schema: 1, kind: 'train', nonce, key, repo_key: env.repo.key, repo: env.repo.url, base: { ref: env.ctx.base, sha: baseSha }, policy: env.policy.source.label,
|
||||
slots: order.train, held: order.held.map(s => s.pr), prefixes: chain.prefixes, head: head.commit, tree: head.tree, branch: `train/${nonce}`,
|
||||
urgent: o.urgent, repair: o.repair, ...(explicit ? { explicit_order: true } : {}), state: 'built', free: null, attempts: [], merged: [], superseded_by: null, effects: [], error: null,
|
||||
urgent: o.urgent, repair: o.repair, ...(explicit ? { explicit_order: true } : {}), ...(stamped ? { stamped: stamped.stamped } : {}), state: 'built', free: null, attempts: [], merged: [], superseded_by: null, effects: [], error: null,
|
||||
created_at: now, updated_at: now,
|
||||
} };
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user