Merge origin/main (v1.91.278.0) into eval-select-review-floor-followups; stamp v1.91.279.0

FILES: CHANGELOG.md VERSION agents-digest/gstack-AGENTS.md package.json
MERGE_CONDITION: sha=64684de144e5c5b8934408514b44434559487b18
NEW_VERSION=1.91.279.0

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-27 17:02:49 +00:00
commit c7f6daa7a4
30 files changed
+654 -85

No files matched your search

+9 -5
View File
@@ -193,24 +193,28 @@ export function fetchChecks(env: GhEnv, repo: string, pr: number): Map<string, C
* Required check names for the base branch, from rulesets and classic branch
* protection. Reading them up front means a required check that has not
* started yet counts as pending instead of green by omission. A 404 means no
* protection of that kind, which is normal; any other failure is logged and
* that source contributes nothing.
* protection of that kind, which is normal; any other failure is logged, that
* source contributes nothing, and `known` is false: the set may be short, so a
* caller that would relax the gate on it (`--exclude`) must not.
*/
export function requiredChecks(env: GhEnv, repo: string, base: string): Set<string> {
export function requiredChecks(env: GhEnv, repo: string, base: string): { names: Set<string>; known: boolean } {
const sources: Array<[string, string]> = [
[`repos/${repo}/rules/branches/${base}`, '.[] | select(.type == "required_status_checks") | .parameters.required_status_checks[].context'],
[`repos/${repo}/branches/${base}/protection/required_status_checks`, '.contexts[]?, .checks[]?.context'],
];
const names = new Set<string>();
let known = true;
for (const [path, jq] of sources) {
try {
for (const name of ghLines(env, ['api', path, '--jq', jq])) names.add(name);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!/HTTP 404/.test(message)) env.log(`could not read required checks from ${path}: ${message}`);
if (/HTTP 404/.test(message)) continue;
known = false;
env.log(`could not read required checks from ${path}: ${message}`);
}
}
return names;
return { names, known };
}
/** Files the PR changes, from `gh pr diff --name-only`. */
+52 -12
View File
@@ -46,12 +46,22 @@ export interface PrWaitEnv {
pidAlive(pid: number): boolean;
}
export interface PrWaitOptions { target?: string; repo?: string; until: Until; interval: number; timeout: number }
export interface PrWaitOptions { target?: string; repo?: string; until: Until; interval: number; timeout: number; excludes: string[] }
export interface BotLists { gating: string[]; advisory: string[] }
interface State { reported: string[]; summaries: Record<string, string>; base_oid: string | null; merge_state: string | null }
export const PR_WAIT_USAGE = `gstack-pr-wait <pr> [--repo owner/name] [--until green|ready|any-change] [--interval SECONDS] [--timeout MINUTES]
/**
* The base-overlap event, one shape for the watcher and for `gstack-ship-queue
* ready` / `train build`, which read the same fact from git (handoff
* GSTA-119): the base moved and its new commits touch files this PR changes.
*/
export function baseOverlapLine(base: string, baseOid: string, touched: readonly string[]): string {
const more = touched.length > 5 ? ` and ${touched.length - 5} more` : '';
return `${base} moved to ${baseOid.slice(0, 7)} and touches files in this PR: ${touched.slice(0, 5).join(', ')}${more}`;
}
export const PR_WAIT_USAGE = `gstack-pr-wait <pr> [--repo owner/name] [--until green|ready|any-change] [--interval SECONDS] [--timeout MINUTES] [--exclude CHECK,...]
Watches one pull request and prints one line per real event on stdout (prefixed
#<pr>), so a monitor wakes the agent once per event. Progress and recoverable
@@ -69,6 +79,10 @@ comes from the URL or the current checkout.
--timeout minutes before a watch that met nothing ends as PR_WAIT_BLOCKED,
naming what is still pending (default 60; 0 waits without limit)
--exclude check names (comma-separated, repeatable) left out of the verdict,
as gstack-ci-gate --exclude does: a named check neither holds green
while pending nor blocks when it fails. A check the base branch
requires cannot be excluded; the name is logged as refused and gates
Events: a check failed (name and link); all checks finished on the
head (one summary line); a comment, review, thread or thread reply from someone
@@ -101,7 +115,7 @@ lock, which the next watcher for the PR takes over once the pid is gone.
Docs: docs/troubleshooting.md#gstack-pr-wait`;
export function parsePrWaitArgs(argv: string[]): PrWaitOptions | { help: true } | { error: string } {
const opts: PrWaitOptions = { until: 'ready', interval: 20, timeout: DEFAULT_TIMEOUT_MINUTES };
const opts: PrWaitOptions = { until: 'ready', interval: 20, timeout: DEFAULT_TIMEOUT_MINUTES, excludes: [] };
for (let i = 0; i < argv.length; i++) {
const flag = argv[i];
if (flag === '--help' || flag === '-h') return { help: true };
@@ -124,6 +138,12 @@ export function parsePrWaitArgs(argv: string[]): PrWaitOptions | { help: true }
opts.timeout = Number(value);
continue;
}
if (flag === '--exclude') {
const names = (argv[++i] ?? '').split(',').map(s => s.trim()).filter(Boolean);
if (!names.length) return { error: '--exclude needs one or more check names, comma-separated' };
for (const name of names) if (!opts.excludes.includes(name)) opts.excludes.push(name);
continue;
}
if (flag.startsWith('-') && !/^-?\d+$/.test(flag)) return { error: `unknown argument ${flag}` };
if (opts.target !== undefined) return { error: `unexpected argument ${flag}` };
opts.target = flag;
@@ -185,12 +205,14 @@ export class Watcher {
/** First run for this PR: record what exists and report nothing. */
baseline: boolean;
private required: Set<string> | null = null;
/** False when a required-check source could not be read: the set may be short, so no --exclude name is excluded. */
private requiredKnown = true;
private prevSnapshot: string | null = null;
private prFiles = new Map<string, Set<string>>();
private pending: string[] = [];
private readonly gh: GhEnv;
constructor(readonly env: PrWaitEnv, readonly repo: string, readonly pr: number, readonly until: Until, readonly interval: number, readonly bots: BotLists, readonly timeoutMinutes = DEFAULT_TIMEOUT_MINUTES) {
constructor(readonly env: PrWaitEnv, readonly repo: string, readonly pr: number, readonly until: Until, readonly interval: number, readonly bots: BotLists, readonly timeoutMinutes = DEFAULT_TIMEOUT_MINUTES, readonly excludes: readonly string[] = []) {
const stem = path.join(stateDir(env.vars), `${repo.replace('/', '__')}__${pr}`);
this.stateFile = `${stem}.json`;
this.lockFile = `${stem}.lock`;
@@ -295,11 +317,22 @@ export class Watcher {
return count === null ? '' : `; ${count} commit${count === '1' ? '' : 's'} behind ${base}`;
}
private evaluateChecks(checks: Map<string, Check>, head: string) {
/**
* `--exclude` names that are not required checks: the ones the verdict leaves out, as gstack-ci-gate does. While the
* required set is unknown (a source could not be read) nothing is excluded, as gstack-ci-gate refuses its overrides.
*/
private excluded(): Set<string> {
return new Set(this.requiredKnown ? this.excludes.filter(name => !this.required?.has(name)) : []);
}
private evaluateChecks(all: Map<string, Check>, head: string) {
const required = this.required ?? new Set<string>();
// Every check on the head gates, as gstack-ci-gate does, so land-and-deploy's two gates never
// disagree; a required check gates even before it appears. An advisory bot's review is never
// waited for, but its check is a check like any other.
// waited for, but its check is a check like any other. A --exclude name that is not required
// is left out of the verdict; a required one cannot be excluded and gates.
const excluded = this.excluded();
const checks = new Map([...all].filter(([name]) => !excluded.has(name)));
const watched = checks;
const gate = new Set([...required, ...watched.keys()]);
const bucketOf = (name: string) => checks.get(name)?.bucket ?? 'pending';
@@ -392,10 +425,7 @@ export class Watcher {
const oldBase = this.state.base_oid;
if (pr.baseOid && oldBase && pr.baseOid !== oldBase && !this.baseline) {
const touched = this.baseOverlap(head, oldBase, pr.baseOid);
if (touched.length > 0) {
const more = touched.length > 5 ? ` and ${touched.length - 5} more` : '';
this.emit(`base:${pr.baseOid}`, `${base} moved to ${pr.baseOid.slice(0, 7)} and touches files in this PR: ${touched.slice(0, 5).join(', ')}${more}`);
}
if (touched.length > 0) this.emit(`base:${pr.baseOid}`, baseOverlapLine(base, pr.baseOid, touched));
}
if (pr.baseOid) this.state.base_oid = pr.baseOid;
const mergeState = pr.mergeStateStatus;
@@ -418,9 +448,19 @@ export class Watcher {
if (pr.state === 'CLOSED') return this.finish(PR_WAIT_EXIT.blocked, [`closed without merging: ${pr.url}`]);
const base = pr.baseRefName;
if (this.required === null) {
this.required = requiredChecks(this.gh, this.repo, base);
const read = requiredChecks(this.gh, this.repo, base);
this.required = read.names;
this.requiredKnown = read.known;
const gate = [...this.required].sort().join(', ') || 'none set';
this.log(`watching ${this.repo}#${this.pr} until ${this.until}; required checks: ${gate}; every check on the head gates`);
if (this.excludes.length && !this.requiredKnown) {
this.log(`exclude refused for ${this.excludes.join(', ')}: the required checks could not be read, so every check gates`);
} else if (this.excludes.length) {
const refused = this.excludes.filter(name => this.required!.has(name));
const dropped = this.excludes.filter(name => !this.required!.has(name));
if (dropped.length) this.log(`excluded from the verdict: ${dropped.join(', ')}`);
if (refused.length) this.log(`exclude refused for required check${refused.length === 1 ? '' : 's'} ${refused.join(', ')}: a required check gates`);
}
}
const checks = fetchChecks(this.gh, this.repo, this.pr);
const head = pr.headRefOid;
@@ -530,7 +570,7 @@ export function prWaitMain(argv: string[], env: PrWaitEnv): number {
if ('error' in parsed) return refuse(env, parsed.error);
const target = resolveTarget(env, parsed);
if ('error' in target) return refuse(env, target.error);
const watcher = new Watcher(env, target.repo, target.pr, parsed.until, parsed.interval, botLists(env.vars), parsed.timeout);
const watcher = new Watcher(env, target.repo, target.pr, parsed.until, parsed.interval, botLists(env.vars), parsed.timeout, parsed.excludes);
// Exit 1 always carries a PR_WAIT_BLOCKED line, so a local fault (an unwritable state root, a full disk) is a refusal, never a verdict.
try {
const lock = watcher.lock();
+62 -7
View File
@@ -128,12 +128,33 @@ function queueOrderCheck(o: RestampOptions, baseSha: string): void {
actual[pr] = merged ? (inBase ? 'merged' : 'merged-not-in-base') : String(info.state ?? 'unknown').toLowerCase();
}
const stale = [...ahead.filter(pr => actual[pr] !== 'merged'), ...behind.filter(pr => actual[pr]?.startsWith('merged'))];
if (stale.length) {
throw new Refusal('QUEUE_STALE', `queue order moved: ${stale.map(pr => `#${pr}=${actual[pr]}`).join(', ')}`, {
expected: { order: o.expectOrder, base: o.expectBase ?? null }, actual: { prs: actual, base: baseSha },
recompute: `gstack-restamp --next --base ${o.base} --expect-base ${baseSha}${mine ? ` --pr ${mine}` : ''} --expect-order ${o.expectOrder.join(',')}`,
});
}
if (!stale.length) return;
// The recompute command is the corrected order, never the stale list again: this PR first (a list that left it out read
// every entry as a predecessor), PRs merged into the local base dropped, an unmerged predecessor kept ahead because it
// still has to merge first. A predecessor GitHub calls merged that the local base does not carry stays ahead too: the
// recompute pins --expect-base to that local base, and dropping the PR would let it allocate the version the
// predecessor already took (pre-landing review, pass 1); the lane fetches the base and the entry reads merged.
const corrected = correctedOrder(o.expectOrder, mine, actual);
const waitingOn = ahead.filter(pr => actual[pr] !== 'merged' && actual[pr] !== 'merged-not-in-base');
const unfetched = ahead.filter(pr => actual[pr] === 'merged-not-in-base');
const why = mine && idx < 0 ? `; --pr ${mine} is not in --expect-order, so every listed PR read as a predecessor: list this PR first, then the remaining order`
: [waitingOn.length ? `${waitingOn.map(pr => `#${pr}`).join(', ')} must merge before this PR stamps` : '',
unfetched.length ? `${unfetched.map(pr => `#${pr}`).join(', ')} merged but the local ${o.base} does not carry the merge yet: git fetch origin ${o.base}, then rerun` : '']
.filter(Boolean).map(s => `; ${s}`).join('');
throw new Refusal('QUEUE_STALE', `queue order moved: ${stale.map(pr => `#${pr}=${actual[pr]}`).join(', ')}${why}`, {
expected: { order: o.expectOrder, base: o.expectBase ?? null }, actual: { prs: actual, base: baseSha },
recompute: `gstack-restamp --next --base ${o.base} --expect-base ${baseSha.slice(0, 12)}${mine ? ` --pr ${mine}` : ''} --expect-order ${corrected.join(',')}`,
...(unfetched.length ? { fix: `git fetch origin ${o.base}` } : {}),
});
}
/**
* The --expect-order to run next: this PR first when the list left it out, every PR merged into the local base dropped,
* the rest (open PRs and merges the local base does not carry yet) in their order.
*/
export function correctedOrder(order: readonly string[], mine: string | null, actual: Record<string, string>): string[] {
const rest = order.filter(pr => actual[pr] !== 'merged');
return mine && !order.includes(mine) ? [mine, ...rest] : rest;
}
interface Plan {
@@ -148,7 +169,7 @@ function planVersions(o: RestampOptions, loaded: LoadedPolicy, policyLabel: stri
if (o.expectBase && !baseSha.out.startsWith(o.expectBase)) {
throw new Refusal('QUEUE_STALE', `base moved: expected ${o.expectBase}, ${baseRef} is ${baseSha.out}`, {
expected: { base: o.expectBase }, actual: { base: baseSha.out },
recompute: `gstack-restamp --next --base ${o.base} --expect-base ${baseSha.out.slice(0, 12)}${o.expectOrder?.length ? ` --expect-order ${o.expectOrder.join(',')}` : ''}`,
recompute: `gstack-restamp --next --base ${o.base} --expect-base ${baseSha.out.slice(0, 12)}${o.pr ? ` --pr ${o.pr}` : ''}${o.expectOrder?.length ? ` --expect-order ${correctedOrder(o.expectOrder, o.pr ?? null, {}).join(',')}` : ''}`,
});
}
queueOrderCheck(o, baseSha.out);
@@ -313,6 +334,40 @@ export function tentativeMentions(added: Map<string, Set<string>>, forkVersion:
return [...found];
}
/** The versions one line mentions in a stamp form (the built-in forms and the policy's `stamp_patterns` templates). */
export function stampMentions(text: string, templates: readonly string[] = []): Set<string> {
const found = new Set<string>();
for (const re of [...BUILT_IN_MENTIONS, ...templates.map(templateMention)]) for (const m of text.matchAll(re)) found.add(m.groups!.v!);
return found;
}
/**
* A removed/added line pair that a restamp alone explains: every version the
* removed line mentions in a stamp form and the added line no longer does
* moves to `newV`, and the rewritten line is the added line byte for byte.
* The moved versions are the ones the stamp_paths stage moves, not only the
* old version `oldV` (the gated head's): a tentative `*_KEY_VERSION = '<next
* minor>'` never equals the base version at the slot and is a stamp all the
* same (#3512). The stage's own bound applies: a tentative mention is newer
* than the version the branch was written against, so a moved version older
* than `oldV` (a released key moved by hand) is not a stamp, and neither is
* one of another width (`tentativeMentions`: a 3-digit dependency pin in a
* 4-digit repo is never a candidate, so moving it is a hand edit). A line that
* moves nothing, or changes anything beyond those mentions, is not one either.
*/
export function stampOnlyLine(removed: string, added: string, oldV: string, newV: string, templates: readonly string[] = []): boolean {
if (removed === added) return false;
const floor = parseVersion(oldV);
const width = versionWidth(oldV);
const after = stampMentions(added, templates);
const moved = [...stampMentions(removed, templates)].filter(v => v !== newV && !after.has(v));
if (!moved.length || !floor) return false;
if (moved.some(v => { const p = parseVersion(v); return !p || versionWidth(v) !== width || cmpVersion(p, floor) < 0; })) return false;
let text = removed;
for (const v of moved) text = rewriteStamps(text, v, newV, templates).text;
return text === added;
}
/** The version at the branch's original fork point: the first parent of its oldest own commit (base when it has none). */
export function forkVersion(repoRoot: string, baseRef: string, fallback: string): string {
const first = git(repoRoot, ['rev-list', '--reverse', '--first-parent', `${baseRef}..HEAD`]).out.split('\n')[0]?.trim();
+13 -1
View File
@@ -28,7 +28,7 @@ import { dispatchCi } from './ship-queue-dispatch-ci';
import { recordVerdict, screenVerdict, verdictLines, type ScreenOutcome } from './ship-queue-screen';
import { execFor, repoSlug } from './ship-queue-train-github';
import { loadReady, nowIso, persist, readyPath, rebuildFromNotes, type ReadyReceipt, type ScreenChoice } from './ship-queue-train-record';
import { fetchBase, mergeCommit, outOk, outPending, outRefused, trainEnv, type TrainEnv, type TrainOut } from './ship-queue-train';
import { baseOverlap, fetchBase, mergeCommit, outOk, outPending, outRefused, trainEnv, type TrainEnv, type TrainOut } from './ship-queue-train';
const Q = 'gstack-ship-queue';
@@ -181,6 +181,18 @@ export async function trainReady(ctx: QueueContext, o: ReadyOptions): Promise<Tr
if (kind.kind === 'invalid') return outRefused({ code: 'QUEUE_NOT_READY', message: `the Version Gate refuses ${head.slice(0, 12)}: ${kind.reason}` }, base.lines, `fix the head, push, then ${Q} ready --pr ${o.pr} --screen|--no-screen`, 'none');
const sel = await screenSelection(env, head, baseSha);
const lines = [...base.lines, ...sel.lines];
// gstack-pr-wait's base-overlap event, read from git at READY (handoff GSTA-119): the lane learns here that the base
// moved onto its files, and a merge the train would stop at is refused before the pin is pooled. Under --screen the
// scratch merge below reports the same conflict as QUEUE_SCREEN_STALE_REF. A comparison git could not make is printed as
// unknown and refuses nothing: the screen or the train makes the merge for real.
const overlap = baseOverlap(ctx.repoRoot, ctx.base, baseSha, head, env.policy.policy);
if (overlap.line) lines.push(overlap.line);
if (overlap.conflicts && o.screen !== 'screen') {
// The fix follows the crew template (captain/templates/CREW.md rule 14): a lane merges the base into a READY PR only on
// the controller's word, so the refusal is reported and the merge waits for that word.
return outRefused({ code: 'QUEUE_TRAIN_CONFLICT', message: `#${o.pr} conflicts with ${ctx.base}@${baseSha.slice(0, 12)} beyond the CHANGELOG and the stamp files (${overlap.conflicts.join(', ')}); the train would stop at this PR` },
lines, `report the conflict to the controller; on its word merge ${ctx.base} into the pin (never rebase), resolve, push, run /ship, then ${Q} ready --pr ${o.pr} --screen|--no-screen`, 'none');
}
if (!loadReady(env.repo.keys, o.pr, head)) rebuildFromNotes(ctx.repoRoot, env.remote);
const prior = loadReady(env.repo.keys, o.pr, head);
const slug = repoSlug(ctx);
+46 -4
View File
@@ -18,6 +18,7 @@ import { randomBytes } from 'node:crypto';
import { DISPATCH_INPUTS, awaitNonceRun, dispatchWorkflow, listDispatchRuns, makeNonce, runForNonce, type WorkflowDispatch } from '../scripts/eval-bg';
import { changelogDamage, mergeChangelogs, parseChangelog, type ChangelogSection } from './changelog-check';
import type { ResultStatus } from './headless-artifacts';
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';
@@ -194,6 +195,39 @@ export function mergeStep(repoRoot: string, pred: string, head: string, policy:
return 'error' in out ? { conflicts: [`(${out.error})`] } : { tree: out.tree };
}
export interface BaseOverlap { touched: string[]; conflicts: string[] | null; unknown: boolean; line: string | null }
/**
* What gstack-pr-wait's base-overlap event tells a lane, read from git at
* READY and at train build (handoff GSTA-119): the files the base changed
* since this head forked that the head also changes and, when `verdict` is
* asked for, whether the train's own merge of the head onto the base is
* clean. A base that moved onto nothing the PR touches is one line of fact; a
* conflict is what QUEUE_TRAIN_CONFLICT would otherwise discover at the
* scratch merge. A git read that fails is `unknown`, said so, never "no
* overlap". Train build asks for no verdict: its own chain merges each pin
* onto its prefix, and a merge onto the bare base would contradict it for T2+.
*/
export function baseOverlap(repoRoot: string, base: string, baseSha: string, head: string, policy: ShipPolicy, verdict = true): BaseOverlap {
const fork = git(repoRoot, ['merge-base', baseSha, head]);
const none = { touched: [] as string[], conflicts: null, unknown: false };
if (fork.ok && fork.out === baseSha) return { ...none, line: null };
const theirs = fork.ok && fork.out ? git(repoRoot, ['diff', '--name-only', fork.out, baseSha]) : fork;
const ours = fork.ok && fork.out ? git(repoRoot, ['diff', '--name-only', fork.out, head]) : fork;
if (!fork.ok || !fork.out || !theirs.ok || !ours.ok) {
const why = [fork, theirs, ours].find(r => !r.ok)?.out.split('\n')[0] || 'git read failed';
return { ...none, unknown: true, line: `BASE_OVERLAP: could not compare ${base}@${baseSha.slice(0, 12)} with ${head.slice(0, 12)} (${why}); conflicts unknown` };
}
const changed = new Set(theirs.out.split('\n').filter(Boolean));
const touched = ours.out.split('\n').filter(f => f && changed.has(f)).sort();
if (!touched.length) return { ...none, line: `BASE: ${base} moved to ${baseSha.slice(0, 7)} since this PR forked; its new commits touch no file this PR changes` };
if (!verdict) return { touched, conflicts: null, unknown: false, line: `BASE_OVERLAP: ${baseOverlapLine(base, baseSha, touched)}` };
const m = mergeStep(repoRoot, baseSha, head, policy);
const conflicts = 'conflicts' in m ? m.conflicts : null;
const said = conflicts ? `the train's merge onto ${base} conflicts beyond the CHANGELOG and the stamp files (${conflicts.join(', ')})` : `the train's merge onto ${base} is clean`;
return { touched, conflicts, unknown: false, line: `BASE_OVERLAP: ${baseOverlapLine(base, baseSha, touched)}; ${said}` };
}
const TRAIN_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' };
@@ -353,12 +387,15 @@ async function newTrain(env: TrainEnv, o: BuildOptions): Promise<{ t: TrainRecor
`ORDER: ${order.train.map((s, i) => `${i + 1}:#${s.pr}(${s.screen.choice === 'screen' ? 'screened' : 'unscreened'})`).join(' ')}${explicit ? ' (explicit --order)' : ''}`,
...(order.held.length ? [`HELD: ${order.held.map(s => '#' + s.pr).join(',')} (${order.split ? 'unscreened PRs form their own train' : `over the train size ${env.train.max_size}`})`] : []),
];
const due = fillDue(order.train, env.train, nowIso(), o.urgent || o.repair);
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 baseSha = git(env.ctx.repoRoot, ['rev-parse', `${env.remote}/${env.ctx.base}`]).out;
const key = sha256(JSON.stringify({ base: baseSha, slots: order.train.map(s => `${s.pr}@${s.head}`) }));
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 };
// The scratch merges run before the fill rule (#3485): a QUEUE_TRAIN_WAITING result is also a clean dry-run merge of
// every seated pin, and a pin the base moved onto is named (files only; the chain below is the merge verdict) before the
// train is refused for it.
for (const s of order.train) {
const overlap = baseOverlap(env.ctx.repoRoot, env.ctx.base, baseSha, s.head, env.policy.policy, false);
if (overlap.touched.length || overlap.unknown) lines.push(`#${s.pr} ${overlap.line}`);
}
const chain = buildChain(env.ctx.repoRoot, baseSha, order.train, env.policy.policy);
if ('stoppedAt' in chain) {
const without = order.train.filter(s => s.pr !== chain.stoppedAt);
@@ -366,6 +403,11 @@ async function newTrain(env: TrainEnv, o: BuildOptions): Promise<{ t: TrainRecor
`QUEUE_TRAIN_CONFLICT: train ${key.slice(0, 12)} stopped at #${chain.stoppedAt} (conflicts: ${chain.conflicts.join(', ')}) after merging ${chain.merged.map(p => '#' + p).join(',') || '(none)'}; nothing dispatched, no branch pushed; fix: ${Q} train build ${prsArgs(without, !!explicit)}`],
without.length ? `${Q} train build ${prsArgs(without, !!explicit)}` : 'none', 'none');
}
lines.push(`MERGE_CHECK: T1..T${order.train.length} merge clean onto ${env.ctx.base}@${baseSha.slice(0, 12)} (dry run in object storage; nothing pushed)`);
const due = fillDue(order.train, env.train, nowIso(), o.urgent || o.repair);
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 nonce = makeNonce('gate', head.commit, baseSha, randomBytes(3).toString('hex'), { purpose: 'train' });
const now = nowIso();
+14 -4
View File
@@ -34,7 +34,9 @@ const ESCAPE = /[.*+?^${}()|[\]\\]/g;
* is not a `pass` claim), compared with the receipt. A leaf key shared by
* several counters (`pass` under every gate lane, `passed` under gate and ci)
* names any of them: the claim matches when its number equals one of those
* counters, and a mismatch names the first of them.
* counters, and a mismatch names the first of them. A number that is the value
* of a `key=` or `key:` pair is that key's claim alone, never the start of a
* `<n> <name>` claim about the next key (#3513).
*/
export function checkReceiptProse(receipt: Record<string, unknown>, body: string): ProseCheck {
const prose = body.replace(BLOCK_RE, '');
@@ -43,12 +45,20 @@ export function checkReceiptProse(receipt: Record<string, unknown>, body: string
const leaf = field.split('.').at(-1)!;
byLeaf.set(leaf, [...(byLeaf.get(leaf) ?? []), { field, value }]);
}
const pattern = (leaf: string) => leaf.replace(ESCAPE, '\\$&').replaceAll('_', '[ _-]');
// The number-then-name branch must not start on a `key=value` or `key: value` pair's value: in `passes=2 blocking_found=1`
// and `passes: 2 blocking_found: 1` the 2 is passes' claim, not a "2 blocking_found" one. The offsets of every counter
// key's value are collected first, whatever the whitespace around the separator, and a claim starting there is skipped.
const values = new Set<number>();
for (const leaf of byLeaf.keys()) for (const m of prose.matchAll(new RegExp(`(?<![\\w-])${pattern(leaf)}\\s*[=:]\\s*(?=\\d)`, 'gi'))) values.add(m.index + m[0].length);
const mismatches: ProseClaim[] = [];
let checked = 0;
for (const [leaf, counters] of byLeaf) {
const name = leaf.replace(ESCAPE, '\\$&').replaceAll('_', '[ _-]');
const re = new RegExp(`(?:(?<![\\w-])${name}\\s*[=:]\\s*(\\d+)\\b|\\b(\\d+)\\s+${name}(?![\\w-]))`, 'gi');
for (const m of prose.matchAll(re)) {
const name = pattern(leaf);
const re = new RegExp(`(?:(?<![\\w-])${name}\\s*[=:]\\s*(\\d+)\\b|(?<![=:\\w.-])(\\d+)\\s+${name}(?![\\w-]))`, 'gi');
for (let m = re.exec(prose); m; m = re.exec(prose)) {
// A skipped value hands the scan back right after its digits, so the key that follows still makes its own pair claim.
if (m[2] !== undefined && values.has(m.index)) { re.lastIndex = m.index + m[2].length; continue; }
checked++;
const n = Number(m[1] ?? m[2]);
if (!counters.some(c => c.value === n)) mismatches.push({ field: counters[0]!.field, prose: n, receipt: counters[0]!.value });
+18 -7
View File
@@ -11,6 +11,7 @@ import { approvalSummary } from './approval-citations';
import { renderIssueLines } from './issue-links';
import { matchesAny } from './ship-policy';
import { stampFileKind, versionStampOnly } from './pregate/stamp-only';
import { stampOnlyLine } from './restamp';
export const RECEIPT_FENCE = 'gstack-ship-receipt';
const KEY_ORDER: Array<keyof ShipReceipt | 'docs'> = [
@@ -165,22 +166,32 @@ export interface CarryPlanInput {
export interface CarryRecord { from: string; reason: string; product_patch_id: string; changed_paths: string[]; merge_commits: number; range_diff: string; covers: string[]; reruns: string[] }
export type CarryPlan = { ok: true; carry: CarryRecord } | { ok: false; code: 'RECEIPT_CARRY_PRODUCT_CHANGED' | 'RECEIPT_CARRY_REFUSED'; message: string };
/** The file's text at the reviewed head and at HEAD, with the release version each head carries (from `VERSION`, else `package.json`). */
export interface CarryChange { before: string | null; after: string | null; oldVersion?: string | null; newVersion?: string | null }
/**
* The file's text at the reviewed head and at HEAD, with the release version each head carries (from `VERSION`, else
* `package.json`) and the base policy's `stamp_patterns` templates.
*/
export interface CarryChange { before: string | null; after: string | null; oldVersion?: string | null; newVersion?: string | null; templates?: readonly string[] }
/**
* A policy-named stamp path's change is a stamp when the new text reads as the
* old text once every new version in it is the old one: the rule the tree receipt
* applies to the same `stamp_paths`, `stamp_files` and `release_outputs` before
* the train accepts a stamp commit. No version change, or a file missing on
* either side, is not a stamp.
* old text once every new version in it is the old one, or when every changed
* line is a stamp-form rewrite to the new version (`stampOnlyLine`: the old
* version or a newer tentative `*_KEY_VERSION` the slot stamp moved, #3512): the rule
* the tree receipt applies to the same `stamp_paths`, `stamp_files` and
* `release_outputs` before the train accepts a stamp commit. No version
* change, or a file missing on either side, is not a stamp.
*/
export function stampOnlyText(change: CarryChange): boolean {
const { before, after, oldVersion, newVersion } = change;
if (before === null || after === null || !oldVersion || !newVersion || oldVersion === newVersion) return false;
// Digit-bounded: replacing 1.2.3 inside 11.2.3 or 1.2.34 would read a different version's stamp as this one's.
const escaped = newVersion.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return after.replace(new RegExp(`(?<![0-9])${escaped}(?![0-9])`, 'g'), oldVersion) === before;
if (after.replace(new RegExp(`(?<![0-9])${escaped}(?![0-9])`, 'g'), oldVersion) === before) return true;
const was = before.split('\n');
const now = after.split('\n');
if (was.length !== now.length) return false;
const changed = was.map((line, i) => i).filter(i => was[i] !== now[i]);
return changed.length > 0 && changed.every(i => stampOnlyLine(was[i]!, now[i]!, oldVersion, newVersion, change.templates ?? []));
}
/**
+7 -4
View File
@@ -15,7 +15,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import { normalizeChangelog } from './changelog-check';
import { compareIdentity, executionIdentity, normalizeLockfile, readBundle, type EvidenceBundle, type ExecutionIdentity } from './evidence-bundle';
import { parseUnifiedDiff, rewriteStamps, versionAtRef, type FileDiff, type GateAheadRecord } from './restamp';
import { parseUnifiedDiff, stampOnlyLine, versionAtRef, type FileDiff, type GateAheadRecord } from './restamp';
import type { ResultCodeName } from './result-codes';
import { matchesAny, type ShipPolicy } from './ship-policy';
@@ -49,15 +49,18 @@ function blob(cwd: string, treeish: string, rel: string): string | null {
return r.status === 0 ? r.stdout : null;
}
/** Every removed/added pair differs only by the exact stamp rewrites (old -> new). */
/**
* Every removed/added pair differs only by stamp rewrites to the new version:
* the old version, or a tentative one (newer than it) the stamp_paths stage
* moved (#3512), each in a stamp form (`stampOnlyLine`).
*/
function stampOnlyHunks(hunks: FileDiff['hunks'], oldV: string, newV: string, templates: readonly string[]): { why: string; line: number } | null {
for (const h of hunks) {
if (h.removed.length !== h.added.length) return { why: `${h.removed.length} line(s) removed, ${h.added.length} added`, line: h.at };
for (let i = 0; i < h.removed.length; i++) {
const r = h.removed[i]!;
const a = h.added[i]!;
const rewritten = rewriteStamps(r, oldV, newV, templates);
if (rewritten.count === 0 || rewritten.text !== a) return { why: `${JSON.stringify(r.trim().slice(0, 60))} -> ${JSON.stringify(a.trim().slice(0, 60))}`, line: h.at + i };
if (!stampOnlyLine(r, a, oldV, newV, templates)) return { why: `${JSON.stringify(r.trim().slice(0, 60))} -> ${JSON.stringify(a.trim().slice(0, 60))}`, line: h.at + i };
}
}
return null;