mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
v1.91.192.0 fix(ship-queue): merge-train hardening — repo key, CHANGELOG damage, 1 MiB fail-open, merged-in verify, --order (GSTA-67) (#3374)
* fix(ship-queue): merge-train hardening — repo key, CHANGELOG damage, 1 MiB fail-open, merged-in verify, --order (GSTA-67) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(ship-queue): register the #3345 CHANGELOG fixtures; placeholder credential URLs (GSTA-67) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.192.0 stamp: fix(ship-queue): merge-train hardening (GSTA-67) (#3374) 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>
This commit is contained in:
1 parent
e66c43c572
commit
910ee700e5
26 files changed
+827
-74
No files matched your search
@@ -1,5 +1,30 @@
|
||||
# Changelog
|
||||
|
||||
## [1.91.192.0] - 2026-10-25
|
||||
|
||||
**Merge trains stop refusing correct heads and stop passing damaged CHANGELOGs.** This week's trains hit five defects in the ship-queue tooling. #3331's READY was invisible to the coordinator because its lane's origin was `https://git.capy.ai/...`. #3345's pin replaced the released `## [1.91.179.0]` heading, and three things then failed. The Version Gate passed the pin. `mergeChangelogs` copied 179's body into `[Unreleased]`. `verify --train` refused the correct hand-resolved head at 593e61c. Separately, the train slotted the /cso docs PR (#3337) ahead of the catalog PR it describes (#3343). Each defect now has a regression test built from the real case.
|
||||
|
||||
### Fixed
|
||||
- **`lib/version-gate-train.ts`**: the unstamped-head check read CHANGELOG.md through `spawnSync`'s default 1 MiB buffer. gstack's CHANGELOG is 1.67 MB, so both reads failed and the check compared two empty strings and passed. That is how #3345's 1eb7092 got through: `classifyTrainHead(base a59b5e4, head 1eb7092)` returned `unstamped`. The buffer is now 64 MiB, a CHANGELOG that can't be read is refused, and a dropped released heading is named in the error ("CHANGELOG.md drops released heading 1.91.179.0"). `gstack-ship-queue ready` runs the same check and refuses with `QUEUE_NOT_READY`.
|
||||
- **`lib/changelog-check.ts`**: `mergeChangelogs(ours, theirs, base?)` returns null, meaning resolve by hand, when either side damaged released history. Damage means one of two things: a released heading the merge base carries is missing from that side (dropped or renamed), or a section only that side carries holds a paragraph of a released entry the other side has. The train merge, the gate-ahead recompute and `gstack-restamp --after` all pass the merge base. A damaged PR is now `QUEUE_TRAIN_CONFLICT` on CHANGELOG.md with the reason. Before, the damage went into T<i>.
|
||||
- **`lib/ship-queue-train-verify.ts`**: `verify --train` refused #3345's correct stamped head because T1's CHANGELOG was the damaged mechanical merge. The refusal printed the diff against the pin (main's files), not the diff against T1. A merged-in head is now accepted when it contains main and equals T<i> modulo stamps, with its CHANGELOG judged against main: main's header and sections must be unchanged, with only lines from the pin's own CHANGELOG diff above them. The refusal now names the real difference.
|
||||
- **`lib/ship-queue-train-record.ts`**: READY receipts, train records and freezes are keyed on origin normalized to `https://github.com/<owner>/<repo>`, whatever URL form the lane uses (github.com, git.capy.ai, scp, ssh, a token URL, with or without `.git`). The canonical key equals the github.com key existing records already use. Readers also accept the keys older versions wrote for the common URL forms, so #3331's `1fb5dd69740a8cf8` receipt is found. The stored `repo` URL never carries credentials.
|
||||
- **`lib/restamp.ts`**: its git reads use a 64 MiB buffer too. Before, `gstack-restamp --after` read each CHANGELOG conflict stage through the 1 MiB default.
|
||||
|
||||
### Added
|
||||
- **`gstack-ship-queue train build --order <pr>,...`**: an explicit slot order naming every pinned PR once. The default order (screened first, then READY order) is unchanged, and the train's later `NEXT:` rebuild lines keep the explicit order.
|
||||
|
||||
### Tests
|
||||
- `test/changelog-merge-damage.test.ts`: the GSTA-64 repro, plus #3345's real CHANGELOGs (pin 1eb7092, base a59b5e4, main 4a98e40, train T1 6755ab8, stamped de23f5d, under `test/fixtures/merge-train-3345/`). Negative controls: a correct hand resolve, a shared tentative stamp, and two `[Unreleased]` blocks are not damage. An invented line, an edited main section, a changed header and a duplicate heading are still refused.
|
||||
- `test/compare-pr-version.test.ts`: on a CHANGELOG over 1 MiB, #3345's edit is refused naming 1.91.179.0, an additive entry passes, and a corrupt CHANGELOG object is refused. All three failed before the fix.
|
||||
- `test/ship-queue-train.test.ts`: a git.capy.ai lane writes the owner/repo key, and a legacy-key receipt is admitted. A damaged PR is a CHANGELOG conflict, and ready refuses it. `--order` sets and records the order. A hand-resolved merged-in head verifies, while an invented CHANGELOG line, a head without main and a stray file are refused. `test/ship-queue-train-logic.test.ts`: URL normalization, #3331's real keys, `orderSlots` and `parseOrder`.
|
||||
|
||||
### Gate integrity
|
||||
- Version Gate unstamped check: **stronger**. It refuses a head it can't read and names dropped headings. Additive heads still pass at full size.
|
||||
- `mergeChangelogs` and the train CHANGELOG merge: **stronger**. Damage is now a conflict.
|
||||
- `verify --train` merged-in path: **narrower in one place, stronger elsewhere**. The head must now contain main. A CHANGELOG that differs from T<i> is accepted only as main's CHANGELOG plus lines the pin itself added. Every other file must still equal T<i> modulo stamps, and the paid gate never reads CHANGELOG text.
|
||||
- READY receipt lookup: **unchanged strength**. The head and CI binding are the same; only the repository key is normalized.
|
||||
|
||||
## [1.91.191.0] - 2026-10-25
|
||||
|
||||
**`gstack-version-bump write --changelog` moves the branch's `[Unreleased]` entry under the new version, and /ship Step 12 stops treating a provisional queue pick as a verified slot.** Stamping by hand meant writing VERSION with `gstack-version-bump write` and then renaming the `## [Unreleased]` heading yourself, because only `gstack-restamp` re-headed the CHANGELOG. Separately, when `gstack-next-version` could not read some open PRs' VERSION it returned `complete:false` and a provisional pick, and Step 12 printed the warning and then wrote that version as if the queue had been checked (left over from #3258, noted in GSTA-45).
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.91.191.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.91.192.0 — regenerate/re-copy after upgrading gstack
|
||||
|
||||
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
|
||||
for agent hosts without a full skill install. The full skills add workflows,
|
||||
|
||||
+13
-6
@@ -337,7 +337,9 @@ gstack-ship-queue train build --prs 41@<sha>,42@<sha>,43@<sha>
|
||||
|
||||
It prints `ORDER:` (the slot order to announce: screened PRs first in READY
|
||||
order, then unscreened; when `max_size` or more unscreened PRs wait they form
|
||||
their own train and `HELD:` lists them), one `PREFIX: T<i> <commit>
|
||||
their own train and `HELD:` lists them; `--order <pr>,<pr>,...` naming every
|
||||
pinned PR once replaces that rule, for a PR that must merge before another,
|
||||
and the train's later `NEXT:` rebuilds keep it), one `PREFIX: T<i> <commit>
|
||||
tree=<tree>` per prefix and one `SLOT:` per PR. `--dry-run` adds the
|
||||
`ESTIMATE:` line and writes, pushes and dispatches nothing. Otherwise it
|
||||
writes `train.json`, pushes `train/<nonce>`, dispatches `free_workflow` there
|
||||
@@ -355,7 +357,11 @@ merging #<a>,#<b>; nothing dispatched, no branch pushed; fix: …` line names
|
||||
the build without that PR) and `QUEUE_TRAIN_FREE_RED` (free tests red,
|
||||
missing, cancelled or on another SHA). In a merge, version files take the
|
||||
prefix's side and the CHANGELOG merges by section, since the stamp at merge
|
||||
rewrites both.
|
||||
rewrites both; a PR whose CHANGELOG drops or renames a released heading (or
|
||||
carries a released entry's text in a section of its own) is a CHANGELOG
|
||||
conflict, never merged with the damage. READY receipts and train records are
|
||||
keyed on origin's GitHub owner/repo, so lanes whose origin uses another URL
|
||||
form (git.capy.ai, ssh, a token URL) share the coordinator's queue.
|
||||
|
||||
**3. Coordinator: status and verdict.**
|
||||
|
||||
@@ -406,13 +412,13 @@ EVAL_POLICY records it. Bisect results never turn a train green.
|
||||
```bash
|
||||
git fetch origin main
|
||||
git merge --no-ff --no-commit origin/main # expect a CHANGELOG.md conflict only
|
||||
bun --no-env-file --config=/dev/null -e "import { mergeChangelogs } from './lib/changelog-check'; import { spawnSync } from 'node:child_process'; import { writeFileSync } from 'node:fs'; const s = (n) => spawnSync('git', ['show', ':' + n + ':CHANGELOG.md'], { encoding: 'utf8', maxBuffer: 1 << 26, timeout: 30000 }).stdout; const m = mergeChangelogs(s(2), s(3)); if (!m) { console.error('CHANGELOG needs a hand merge'); process.exit(1); } writeFileSync('CHANGELOG.md', m);"
|
||||
bun --no-env-file --config=/dev/null -e "import { mergeChangelogs } from './lib/changelog-check'; import { spawnSync } from 'node:child_process'; import { writeFileSync } from 'node:fs'; const s = (n) => spawnSync('git', ['show', ':' + n + ':CHANGELOG.md'], { encoding: 'utf8', maxBuffer: 1 << 26, timeout: 30000 }).stdout; const m = mergeChangelogs(s(2), s(3), s(1)); if (!m) { console.error('CHANGELOG needs a hand merge'); process.exit(1); } writeFileSync('CHANGELOG.md', m);"
|
||||
git add CHANGELOG.md && git commit --no-edit
|
||||
gstack-restamp --next --base main --pr 42 --expect-base <main sha> --expect-order <order> --allow-repo-commands
|
||||
git add CHANGELOG.md VERSION package.json <release outputs> && git commit -m "v<X> stamp" && git push
|
||||
```
|
||||
|
||||
A conflict in any file other than CHANGELOG.md means the merge isn't the train's prefix. Stop and report it. `verify` accepts the resulting head because its tree equals T<i> modulo stamps; the `head:` line says "T<i> with main merged in".
|
||||
A conflict in any file other than CHANGELOG.md means the merge isn't the train's prefix. Stop and report it. When the helper says the CHANGELOG needs a hand merge, take main's CHANGELOG unchanged and put your own entry above it. `verify` accepts the resulting head because it contains main and its tree equals T<i> modulo stamps, with its CHANGELOG judged as main's plus lines your pin added, however the conflict was resolved; the `head:` line says "T<i> with main merged in".
|
||||
|
||||
**5. Coordinator: verify, merge, verify again.** For each slot in order:
|
||||
|
||||
@@ -423,8 +429,9 @@ gstack-ship-queue verify --pr 41 --train <nonce> # VERIFY_TRAIN: #41 merged; m
|
||||
```
|
||||
|
||||
Before a merge, main's tree must equal the prefix before the PR modulo stamps
|
||||
and the PR must be at its pin (stamps aside) or at T<i> modulo stamps (its pin
|
||||
with main merged in); after it, main's tree
|
||||
and the PR must be at its pin (stamps aside) or contain main and be T<i>
|
||||
modulo stamps (its pin with main merged in; its CHANGELOG must be main's with
|
||||
only lines from the pin's own CHANGELOG diff above it); after it, main's tree
|
||||
must equal the prefix after it. Anything else is `QUEUE_TRAIN_TREE_MISMATCH`
|
||||
(or `QUEUE_TRAIN_MOVED`). When nothing of the train has merged, that is a plain
|
||||
refusal: build a new train on current main. When earlier slots already
|
||||
|
||||
+75
-2
@@ -115,6 +115,76 @@ export function normalizeChangelog(text: string): string {
|
||||
return [parsed.header.join('\n').replace(/\n+$/, ''), ...sections].join('\n\u0000');
|
||||
}
|
||||
|
||||
/**
|
||||
* A merged-in head's CHANGELOG, however its conflict was resolved: why it is
|
||||
* not main's CHANGELOG with the PR's own entry above it, or null. Main's
|
||||
* header and every main section must follow unchanged and in order, and the
|
||||
* sections above them may hold only lines the PR itself added (`authored`,
|
||||
* the + lines of its pin's CHANGELOG diff), so a hand resolve can drop
|
||||
* damage but never add text the train gate did not carry.
|
||||
*/
|
||||
export function ownEntryAboveMain(head: string, main: string, authored: ReadonlySet<string>): string | null {
|
||||
const h = parseChangelog(head);
|
||||
const m = parseChangelog(main);
|
||||
const trim = (lines: string[]) => { const out = [...lines]; while (out.length && !out[out.length - 1]!.trim()) out.pop(); return out.join('\n'); };
|
||||
if (trim(h.header) !== trim(m.header)) return 'the header differs from main';
|
||||
const own = h.sections.length - m.sections.length;
|
||||
if (own < 0) return `it has ${h.sections.length} sections, main has ${m.sections.length}`;
|
||||
const moved = m.sections.findIndex((s, i) => trim(s.lines) !== trim(h.sections[own + i]!.lines));
|
||||
if (moved >= 0) return `main's ${m.sections[moved]!.heading.trim()} is not carried unchanged below the PR's entry`;
|
||||
const mainKeys = new Set(m.sections.map(s => s.version ?? 'Unreleased'));
|
||||
const clash = h.sections.slice(0, own).find(s => mainKeys.has(s.version ?? 'Unreleased'));
|
||||
if (clash) return `${clash.heading.trim()} is headed twice`;
|
||||
const added = h.sections.slice(0, own).flatMap(s => s.lines.slice(1)).find(l => l.trim() && !authored.has(l));
|
||||
return added === undefined ? null : `the PR's entry holds a line its pin never added: ${JSON.stringify(added.slice(0, 80))}`;
|
||||
}
|
||||
|
||||
const MIN_BLOCK_CHARS = 12;
|
||||
|
||||
/** Paragraph blocks of a section's entry text (heading lines dropped), long enough to identify one entry. */
|
||||
function entryBlocks(s: ChangelogSection): string[] {
|
||||
const blocks: string[] = [];
|
||||
let run: string[] = [];
|
||||
const flush = () => { const text = run.join('\n').trim(); if (text.length >= MIN_BLOCK_CHARS) blocks.push(text); run = []; };
|
||||
for (const line of s.lines.slice(1)) {
|
||||
if (!line.trim() || line.startsWith('#')) flush();
|
||||
else run.push(line);
|
||||
}
|
||||
flush();
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why one side of a CHANGELOG merge damaged released history, or null. A
|
||||
* side damages it when it lacks a released heading the merge base carries
|
||||
* (dropped, or renamed), or when a section only that side carries holds a
|
||||
* paragraph of a released entry the other side carries (a released heading
|
||||
* replaced, so its body reads as part of the section above it; #3345).
|
||||
*/
|
||||
export function changelogDamage(ours: string, theirs: string, base?: string): string | null {
|
||||
const sides = [{ name: 'ours', parsed: parseChangelog(ours) }, { name: 'theirs', parsed: parseChangelog(theirs) }];
|
||||
const key = (s: ChangelogSection) => (s.unreleased ? 'Unreleased' : s.version!);
|
||||
if (base !== undefined) {
|
||||
const released = parseChangelog(base).sections.filter(s => !s.unreleased).map(key);
|
||||
for (const side of sides) {
|
||||
const has = new Set(side.parsed.sections.map(key));
|
||||
const missing = released.find(v => !has.has(v));
|
||||
if (missing) return `${side.name} drops released heading ${missing} that the merge base carries`;
|
||||
}
|
||||
}
|
||||
for (const [i, side] of sides.entries()) {
|
||||
const other = sides[1 - i]!.parsed.sections;
|
||||
const otherKeys = new Set(other.map(key));
|
||||
const releasedText = new Map<string, string>();
|
||||
for (const s of other.filter(s => !s.unreleased)) for (const b of entryBlocks(s)) releasedText.set(b, s.version!);
|
||||
for (const s of side.parsed.sections.filter(s => !otherKeys.has(key(s)))) {
|
||||
const hit = entryBlocks(s).find(b => releasedText.has(b));
|
||||
if (hit) return `${side.name}'s ${key(s)} section holds released ${releasedText.get(hit)} text (${JSON.stringify(hit.slice(0, 60))}); a released heading was dropped or renamed`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate-ahead merge of two CHANGELOGs that conflict textually but not
|
||||
* semantically: the predecessor's header and sections, with the branch's own
|
||||
@@ -122,9 +192,12 @@ export function normalizeChangelog(text: string): string {
|
||||
* branch's top section carries the same version heading as a predecessor
|
||||
* section with a different body (both took the same tentative stamp), the
|
||||
* branch's entry is kept under `[Unreleased]` so the restamp re-heads it.
|
||||
* Both sides carrying `[Unreleased]` is a real conflict and returns null.
|
||||
* Both sides carrying `[Unreleased]` is a real conflict and returns null, and
|
||||
* so is damage to released history (`changelogDamage`): null means a hand
|
||||
* resolve, never a merge that carries the damage through.
|
||||
*/
|
||||
export function mergeChangelogs(ours: string, theirs: string): string | null {
|
||||
export function mergeChangelogs(ours: string, theirs: string, base?: string): string | null {
|
||||
if (changelogDamage(ours, theirs, base)) return null;
|
||||
const a = parseChangelog(ours);
|
||||
const b = parseChangelog(theirs);
|
||||
const key = (s: ChangelogSection) => (s.unreleased ? 'Unreleased' : s.version!);
|
||||
|
||||
+4
-2
@@ -68,7 +68,7 @@ export interface RestampResult {
|
||||
|
||||
function sha256(buf: Buffer | string): string { return createHash('sha256').update(buf).digest('hex'); }
|
||||
function git(cwd: string, args: string[], env?: NodeJS.ProcessEnv): { ok: boolean; out: string; err: string; status: number | null } {
|
||||
const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 60_000, env: env ?? process.env });
|
||||
const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 60_000, env: env ?? process.env, maxBuffer: 64 * 1024 * 1024 });
|
||||
return { ok: r.status === 0, out: (r.stdout ?? '').replace(/\n$/, ''), err: (r.stderr ?? '').trim(), status: r.status };
|
||||
}
|
||||
class Refusal extends Error { constructor(public code: ResultCodeName, message: string, public extra: Partial<RestampResult> = {}) { super(message); } }
|
||||
@@ -578,7 +578,9 @@ function gateAhead(o: RestampOptions, plan: Plan, stages: StageRecord[], common:
|
||||
if (resolvable) {
|
||||
for (const c of conflicts) {
|
||||
if (c === plan.policy.changelog) {
|
||||
const merged = mergeChangelogs(git(dir, ['show', `:2:${c}`]).out + '\n', git(dir, ['show', `:3:${c}`]).out + '\n');
|
||||
const stage = (n: number) => git(dir, ['show', `:${n}:${c}`]);
|
||||
const [base, ours, theirs] = [stage(1), stage(2), stage(3)];
|
||||
const merged = ours.ok && theirs.ok ? mergeChangelogs(ours.out + '\n', theirs.out + '\n', base.ok ? base.out + '\n' : undefined) : null;
|
||||
if (merged === null) { unresolved = [c]; break; }
|
||||
fs.writeFileSync(path.join(dir, c), merged);
|
||||
} else git(dir, ['checkout', '--theirs', '--', c]);
|
||||
|
||||
@@ -99,7 +99,8 @@ export function recomputeMergeTree(repoRoot: string, branchHead: string, predece
|
||||
if (!ours || !theirs) return { error: `${file} conflicts beyond a content merge (added or deleted on one side)` };
|
||||
if (file === policy.changelog) {
|
||||
const text = (sha: string) => git(repoRoot, ['cat-file', 'blob', sha]).out + '\n';
|
||||
const merged = mergeChangelogs(text(ours), text(theirs));
|
||||
const base = entry.get(1);
|
||||
const merged = mergeChangelogs(text(ours), text(theirs), base ? text(base) : undefined);
|
||||
if (merged === null) return { error: `${file} conflicts beyond a heading move` };
|
||||
const blob = git(repoRoot, ['hash-object', '-w', '--stdin'], undefined, merged);
|
||||
if (!blob.ok) return { error: `cannot write the merged ${file}: ${blob.err}` };
|
||||
|
||||
@@ -15,7 +15,9 @@ import { trainBisect, trainRebuild, trainVerdict } from './ship-queue-train-verd
|
||||
import { verifyTrain } from './ship-queue-train-verify';
|
||||
import { redactCause } from './ship-queue-train-record';
|
||||
|
||||
export const TRAIN_HELP = ` train build --prs <n>@<sha>[,...] [--urgent | --repair] [--dry-run] [--free-timeout <s>]
|
||||
export const TRAIN_HELP = ` train build --prs <n>@<sha>[,...] [--order <n>,...] [--urgent | --repair] [--dry-run] [--free-timeout <s>]
|
||||
--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
|
||||
@@ -77,8 +79,9 @@ export async function trainMain(ctx: QueueContext, args: string[]): Promise<numb
|
||||
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');
|
||||
return guarded('train build', () => buildTrain(ctx, {
|
||||
pins: flag(rest, '--prs'), resume: nonce, dryRun, urgent: rest.includes('--urgent'), repair: rest.includes('--repair'), freeTimeoutSec,
|
||||
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,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { randomBytes } from 'node:crypto';
|
||||
import { DISPATCH_INPUTS, awaitNonceRun, dedupeKey, dispatchWorkflow, findDuplicateRun, listDispatchRuns, makeNonce, type WorkflowDispatch } from '../scripts/eval-bg';
|
||||
import { EXIT } from './headless-artifacts';
|
||||
import { ready, type QueueContext } from './ship-queue';
|
||||
import { classifyTrainHead } from './version-gate-train';
|
||||
import { git } from './ship-queue-gate';
|
||||
import { execFor, repoSlug } from './ship-queue-train-github';
|
||||
import { loadReady, nowIso, persist, readyPath, type ReadyReceipt, type ScreenChoice } from './ship-queue-train-record';
|
||||
@@ -100,6 +101,8 @@ export async function trainReady(ctx: QueueContext, o: ReadyOptions): Promise<Tr
|
||||
}
|
||||
const head = git(ctx.repoRoot, ['rev-parse', 'HEAD']).out;
|
||||
const baseSha = git(ctx.repoRoot, ['rev-parse', `${env.remote}/${ctx.base}`]).out;
|
||||
const kind = classifyTrainHead({ repoRoot: ctx.repoRoot, baseRef: ctx.base, head });
|
||||
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];
|
||||
const rec = sel.recommend ?? 'no-screen';
|
||||
@@ -119,7 +122,7 @@ export async function trainReady(ctx: QueueContext, o: ReadyOptions): Promise<Tr
|
||||
}
|
||||
lines.splice(1, 0, `screen: ${choice.choice}${choice.policy ? ` policy=${choice.policy} nonce=${choice.nonce} run=${choice.run ?? '-'}` : ''}`);
|
||||
const now = nowIso();
|
||||
const readyAt = loadReady(env.repo.key, o.pr, head)?.ready_at ?? now;
|
||||
const readyAt = loadReady(env.repo.keys, o.pr, head)?.ready_at ?? now;
|
||||
const receipt: ReadyReceipt = { schema: 1, kind: 'ready', repo_key: env.repo.key, pr: o.pr, head, base_sha: baseSha, ready_at: readyAt, screen: choice, updated_at: now };
|
||||
if (o.dryRun) return outOk([...lines, 'DRY-RUN: no READY receipt written, nothing dispatched'], `${Q} ready --pr ${o.pr} --${o.screen}${o.broad ? ' --broad' : ''}`, 'none');
|
||||
const p = persist(ctx.repoRoot, [receipt], { remote: env.remote });
|
||||
|
||||
@@ -31,7 +31,7 @@ export interface FreeRun { run: number | null; url: string | null; head_sha: str
|
||||
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; state: TrainState; free: FreeRun | null; attempts: 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;
|
||||
effects: Array<{ at: string; what: string }>; error: string | null; created_at: string; updated_at: string;
|
||||
}
|
||||
@@ -87,11 +87,34 @@ const bisectPath = (nonce: string) => path.join(trainDir(nonce), 'bisect.json');
|
||||
const freezePath = (repoKey: string) => path.join(queueRoot(), 'freeze', `${repoKey}.json`);
|
||||
export const readyPath = (repoKey: string, pr: string, head: string) => path.join(queueRoot(), 'ready', repoKey, `${pr}-${head}.json`);
|
||||
|
||||
/** The repository's identity: origin's URL, keyed so records of different repositories never mix. */
|
||||
export function repoIdentity(repoRoot: string): { key: string; url: string } {
|
||||
const url = git(repoRoot, ['config', '--get', 'remote.origin.url']).out.trim() || repoRoot;
|
||||
const norm = url.replace(/\.git$/, '').replace(/^git@github\.com:/, 'https://github.com/').replace(/\/+$/, '');
|
||||
return { key: sha256(norm).slice(0, 16), url: norm };
|
||||
/** Hosts that serve GitHub repositories at /<owner>/<repo> (git.capy.ai is Capy's GitHub proxy, set through url.insteadOf). */
|
||||
const GITHUB_HOSTS = ['github.com', 'www.github.com', 'ssh.github.com', 'git.capy.ai'];
|
||||
|
||||
/** Before 1.91.187.0 the key hashed origin's URL with only .git, git@github.com: and trailing slashes normalized. */
|
||||
const legacyNorm = (url: string) => url.replace(/\.git$/, '').replace(/^git@github\.com:/, 'https://github.com/').replace(/\/+$/, '');
|
||||
|
||||
/** A remote URL as https://github.com/<owner>/<repo> when it names a GitHub repository in any form (scp, ssh, http(s), credentials, proxy host); otherwise the legacy form. */
|
||||
export function normalizeRemoteUrl(url: string): string {
|
||||
const m = /^(?:[a-z+]+:\/\/)?(?:[^@/]+@)?([^/:]+)(?::\d+)?[:/]+([^/]+)\/([^/]+?)(?:\.git)?\/*$/i.exec(url.trim());
|
||||
if (m && GITHUB_HOSTS.includes(m[1]!.toLowerCase())) return `https://github.com/${m[2]!.toLowerCase()}/${m[3]!.toLowerCase()}`;
|
||||
return legacyNorm(url.trim().replace(/^([a-z+]+:\/\/)[^@/]+@/i, '$1'));
|
||||
}
|
||||
|
||||
/**
|
||||
* The repository's identity, keyed so records of different repositories
|
||||
* never mix: a hash of origin's URL normalized to owner/repo, so lanes whose
|
||||
* origin names the same GitHub repository in another form share one key.
|
||||
* `keys` adds the keys earlier versions wrote for this URL and for the
|
||||
* repository's common URL forms; readers accept all of them, writers use `key`.
|
||||
*/
|
||||
export function repoIdentity(repoRoot: string): { key: string; url: string; keys: string[] } {
|
||||
const raw = git(repoRoot, ['config', '--get', 'remote.origin.url']).out.trim() || repoRoot;
|
||||
const url = normalizeRemoteUrl(raw);
|
||||
const key = sha256(url).slice(0, 16);
|
||||
const gh = /^https:\/\/github\.com\/(.+)$/.exec(url)?.[1];
|
||||
const forms = gh ? GITHUB_HOSTS.flatMap(h => ['https', 'http', 'ssh://git@'].map(s => `${s.includes('://') ? s : `${s}://`}${h}/${gh}`)) : [];
|
||||
const keys = [...new Set([key, ...[raw, ...forms].map(u => sha256(legacyNorm(u)).slice(0, 16))])];
|
||||
return { key, url, keys };
|
||||
}
|
||||
|
||||
function readJson<T>(file: string, kind: string): T | null {
|
||||
@@ -101,19 +124,25 @@ function readJson<T>(file: string, kind: string): T | null {
|
||||
export const loadTrain = (nonce: string) => (/^[A-Za-z0-9._-]{1,128}$/.test(nonce) ? readJson<TrainRecord>(trainFile(nonce), 'train') : null);
|
||||
export const loadAttempt = (nonce: string, id: string) => (/^[A-Za-z0-9._-]{1,64}$/.test(id) ? readJson<AttemptRecord>(attemptPath(nonce, id), 'train-attempt') : null);
|
||||
export const loadBisect = (nonce: string) => readJson<BisectRecord>(bisectPath(nonce), 'train-bisect');
|
||||
export const loadFreeze = (repoKey: string) => readJson<FreezeRecord>(freezePath(repoKey), 'freeze');
|
||||
export const loadReady = (repoKey: string, pr: string, head: string) => readJson<ReadyReceipt>(readyPath(repoKey, pr, head), 'ready');
|
||||
export const activeFreeze = (repoKey: string): FreezeRecord | null => { const f = loadFreeze(repoKey); return f && !f.cleared_at ? f : null; };
|
||||
/** A reader's repo keys: the current key first, then the legacy keys (repoIdentity().keys). */
|
||||
type RepoKeys = string | readonly string[];
|
||||
const keyList = (k: RepoKeys): readonly string[] => (typeof k === 'string' ? [k] : k);
|
||||
const firstOf = <T>(k: RepoKeys, read: (key: string) => T | null): T | null => { for (const key of keyList(k)) { const r = read(key); if (r) return r; } return null; };
|
||||
|
||||
export const loadFreeze = (repoKeys: RepoKeys) => firstOf(repoKeys, key => readJson<FreezeRecord>(freezePath(key), 'freeze'));
|
||||
export const loadReady = (repoKeys: RepoKeys, pr: string, head: string) => firstOf(repoKeys, key => readJson<ReadyReceipt>(readyPath(key, pr, head), 'ready'));
|
||||
export const activeFreeze = (repoKeys: RepoKeys): FreezeRecord | null => firstOf(repoKeys, key => { const f = readJson<FreezeRecord>(freezePath(key), 'freeze'); return f && !f.cleared_at ? f : null; });
|
||||
|
||||
export function attemptsOf(train: TrainRecord): AttemptRecord[] {
|
||||
return train.attempts.map(id => loadAttempt(train.nonce, id)).filter((a): a is AttemptRecord => !!a);
|
||||
}
|
||||
|
||||
/** Every train of this repository, newest first. */
|
||||
export function listTrains(repoKey: string): TrainRecord[] {
|
||||
export function listTrains(repoKeys: RepoKeys): TrainRecord[] {
|
||||
const dir = path.join(queueRoot(), 'trains');
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
return fs.readdirSync(dir).map(n => loadTrain(n)).filter((t): t is TrainRecord => !!t && t.repo_key === repoKey)
|
||||
const keys = keyList(repoKeys);
|
||||
return fs.readdirSync(dir).map(n => loadTrain(n)).filter((t): t is TrainRecord => !!t && keys.includes(t.repo_key))
|
||||
.sort((a, b) => b.created_at.localeCompare(a.created_at) || b.nonce.localeCompare(a.nonce));
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
type AttemptRecord, type BisectRecord, type CasePanel, type GateOutcome, type PrefixResult, type TrainRecord,
|
||||
} from './ship-queue-train-record';
|
||||
import {
|
||||
dispatchAttempt, effect, findTrain, gateDispatch, nextFor, outFailed, outOk, outPending, outRefused, pinList, save, trainEnv,
|
||||
dispatchAttempt, effect, findTrain, gateDispatch, nextFor, outFailed, outOk, outPending, outRefused, prsArgs, save, trainEnv,
|
||||
type TrainEnv, type TrainOut,
|
||||
} from './ship-queue-train';
|
||||
|
||||
@@ -309,7 +309,7 @@ export async function trainRebuild(ctx: QueueContext, o: { train: string; infra:
|
||||
const run = trainFile(t.nonce);
|
||||
const gates = gateAttempts(t);
|
||||
const last = gates.at(-1);
|
||||
const repair = `route ${last?.red_cases.join(',') || 'the red cases'} to a repair lane (GStack Minder); the train is rebuilt only once the repair merges: ${Q} train build --prs ${pinList(t.slots)}`;
|
||||
const repair = `route ${last?.red_cases.join(',') || 'the red cases'} to a repair lane (GStack Minder); the train is rebuilt only once the repair merges: ${Q} train build ${prsArgs(t.slots, t.explicit_order)}`;
|
||||
const refuse = (reason: string) => outRefused({ code: 'QUEUE_TRAIN_REBUILD_REFUSED', message: `rebuild refused: ${reason}` }, gates.map(attemptLine), repair, run);
|
||||
if (o.infra) {
|
||||
if (!last || last.outcome !== 'INCOMPLETE' || !last.infra_eligible) return refuse(`no INFRA/INCOMPLETE-only record (the last gate is ${last?.outcome ?? 'missing'})`);
|
||||
@@ -321,7 +321,7 @@ export async function trainRebuild(ctx: QueueContext, o: { train: string; infra:
|
||||
if (b.outcome.kind !== 'unattributed' || !b.outcome.all_green) return refuse(`the bisect was not all green (${Object.entries(b.outcome.patterns).map(([c, s]) => `${c}=${s}`).join(' ')})`);
|
||||
}
|
||||
const tip = remoteBranchSha(args => git(ctx.repoRoot, args), env.remote, t.branch);
|
||||
if (tip !== t.head) return outRefused({ code: 'QUEUE_TRAIN_MOVED', message: `${t.branch} is at ${tip?.slice(0, 12) ?? 'nothing'}, not the recorded head ${t.head.slice(0, 12)}` }, [], `${Q} train build --prs ${pinList(t.slots)}`, run);
|
||||
if (tip !== t.head) return outRefused({ code: 'QUEUE_TRAIN_MOVED', message: `${t.branch} is at ${tip?.slice(0, 12) ?? 'nothing'}, not the recorded head ${t.head.slice(0, 12)}` }, [], `${Q} train build ${prsArgs(t.slots, t.explicit_order)}`, run);
|
||||
const slug = repoSlug(ctx);
|
||||
if (!slug) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: 'gh repo view could not name the repository' }, [], `${Q} train rebuild --train ${t.nonce}${o.infra ? ' --infra' : ''}`, run);
|
||||
const a = await dispatchAttempt(env, slug, t, o.infra ? 'infra-redispatch' : 'rebuild', gateDispatch(env, t, `${t.nonce}.${gates.length + 1}`));
|
||||
|
||||
@@ -20,8 +20,9 @@ import { git } from './ship-queue-gate';
|
||||
import { checkRun, execFor, repoSlug } from './ship-queue-train-github';
|
||||
import { activeFreeze, attemptsOf, nowIso, persist, trainFile, type FreezeRecord, type TrainRecord } from './ship-queue-train-record';
|
||||
import {
|
||||
effect, fetchBase, fetchTrainBranch, findTrain, frozenLines, nextFor, outFailed, outOk, outRefused, pinList, save, trainEnv, type TrainEnv, type TrainOut,
|
||||
effect, fetchBase, fetchTrainBranch, findTrain, frozenLines, nextFor, outFailed, outOk, outRefused, pinList, prsArgs, save, trainEnv, type TrainEnv, type TrainOut,
|
||||
} from './ship-queue-train';
|
||||
import { ownEntryAboveMain } from './changelog-check';
|
||||
import { buildReceipt } from './tree-receipt';
|
||||
|
||||
const Q = 'gstack-ship-queue';
|
||||
@@ -32,6 +33,37 @@ function sameModuloStamps(env: TrainEnv, gated: string, current: string): { same
|
||||
return { same: r.tree === 'same-modulo-stamps', detail: r.tree === 'same-modulo-stamps' ? `same modulo stamps (${r.files.length} stamp file(s))` : `differs: ${r.residual.slice(0, 8).join(', ')}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* A slot head with main merged in (from slot 2 on the lane merges main into
|
||||
* its pin so GitHub can merge it): it must contain main, and its tree must be
|
||||
* the slot's prefix T<i> modulo stamps. The CHANGELOG is judged against main
|
||||
* instead of T<i>'s mechanical merge, so a hand resolve is accepted when it
|
||||
* is main's CHANGELOG plus the PR's own entry (ownEntryAboveMain); any other
|
||||
* difference is refused.
|
||||
*/
|
||||
function mergedIn(env: TrainEnv, t: TrainRecord, i: number, head: string, main: string): { same: boolean; detail: string } {
|
||||
const after = t.prefixes[i + 1]!;
|
||||
const root = env.ctx.repoRoot;
|
||||
if (!git(root, ['merge-base', '--is-ancestor', main, head]).ok) return { same: false, detail: `does not contain main ${main.slice(0, 12)}` };
|
||||
const r = buildReceipt({ repoRoot: root, gated: after.commit, current: head, policy: env.policy.policy, policyLabel: env.policy.source.label });
|
||||
if ('error' in r) return { same: false, detail: r.error };
|
||||
const stamps = `${r.files.filter(f => f.verdict === 'stamp').length} stamp file(s)`;
|
||||
if (r.tree === 'same-modulo-stamps') return { same: true, detail: `T${after.index} with main merged in, same modulo stamps (${stamps})` };
|
||||
const cl = env.policy.policy.changelog;
|
||||
const beyond = r.residual.filter(f => f !== cl);
|
||||
if (beyond.length || !cl) return { same: false, detail: `differs from T${after.index}: ${r.residual.slice(0, 8).join(', ')}` };
|
||||
const pin = t.slots[i]!.head;
|
||||
const forkPoint = git(root, ['merge-base', pin, t.prefixes[0]!.commit]).out;
|
||||
const diff = git(root, ['diff', '--unified=0', '--no-color', '--no-ext-diff', forkPoint, pin, '--', cl]);
|
||||
const show = (rev: string) => git(root, ['show', `${rev}:${cl}`]);
|
||||
const [headCl, mainCl] = [show(head), show(main)];
|
||||
if (!forkPoint || !diff.ok || !headCl.ok || !mainCl.ok) return { same: false, detail: `cannot read ${cl} at the pin, the head or main` };
|
||||
const authored = new Set(diff.out.split('\n').filter(l => l.startsWith('+') && !l.startsWith('+++')).map(l => l.slice(1)));
|
||||
const why = ownEntryAboveMain(headCl.out, mainCl.out, authored);
|
||||
if (why) return { same: false, detail: `differs from T${after.index} in ${cl}, which is not main's plus #${t.slots[i]!.pr}'s own entry: ${why}` };
|
||||
return { same: true, detail: `T${after.index} with main merged in, same modulo stamps (${stamps}) except ${cl}, resolved as main's ${cl} plus #${t.slots[i]!.pr}'s own entry` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Freeze the queue: the record goes to local state and the notes ref before
|
||||
* anything remote, then the backstop gate is dispatched on main's sha.
|
||||
@@ -61,8 +93,8 @@ async function freezeQueue(env: TrainEnv, t: TrainRecord, reason: string, mainSh
|
||||
/** Interrupted publication: freeze when earlier slots merged; otherwise a plain refusal (nothing was published). */
|
||||
async function stopped(env: TrainEnv, t: TrainRecord, code: 'QUEUE_TRAIN_TREE_MISMATCH' | 'QUEUE_TRAIN_MOVED', message: string, mainSha: string, lines: string[]): Promise<TrainOut> {
|
||||
const run = trainFile(t.nonce);
|
||||
if (!t.merged.length) return outFailed({ code, message: `${message}; nothing of train ${t.nonce} merged, so the queue is not frozen` }, lines, `${Q} train build --prs ${pinList(t.slots)} (a new train on current main)`, run);
|
||||
const f = activeFreeze(env.repo.key) ?? await freezeQueue(env, t, message, mainSha);
|
||||
if (!t.merged.length) return outFailed({ code, message: `${message}; nothing of train ${t.nonce} merged, so the queue is not frozen` }, lines, `${Q} train build ${prsArgs(t.slots, t.explicit_order)} (a new train on current main)`, run);
|
||||
const f = activeFreeze(env.repo.keys) ?? await freezeQueue(env, t, message, mainSha);
|
||||
return outFailed({ code, message: `${message}; the queue is frozen` }, [...lines, ...frozenLines(f)], `${Q} train build --repair --prs <repair-pr>@<sha>`, run);
|
||||
}
|
||||
|
||||
@@ -84,7 +116,7 @@ export async function verifyTrain(ctx: QueueContext, o: { pr: string; train: str
|
||||
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 main = git(ctx.repoRoot, ['rev-parse', `${env.remote}/${ctx.base}`]).out;
|
||||
const freeze = activeFreeze(env.repo.key);
|
||||
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 after = t.prefixes[i + 1]!;
|
||||
const before = t.prefixes[i]!;
|
||||
@@ -98,9 +130,8 @@ export async function verifyTrain(ctx: QueueContext, o: { pr: string; train: str
|
||||
const headRef = o.noFetch ? '' : (git(ctx.repoRoot, ['fetch', '--no-tags', '-q', env.remote, `+refs/pull/${o.pr}/head:refs/gstack/queue/pr-${o.pr}`]).ok ? `refs/gstack/queue/pr-${o.pr}` : '');
|
||||
const head = headRef ? git(ctx.repoRoot, ['rev-parse', headRef]).out : t.slots[i]!.head;
|
||||
const pinned = head === t.slots[i]!.head ? { same: true, detail: 'the pinned head' } : sameModuloStamps(env, t.slots[i]!.head, head);
|
||||
const asPrefix = pinned.same ? null : sameModuloStamps(env, after.commit, head);
|
||||
const stampOnly = asPrefix?.same ? { same: true, detail: `T${after.index} with main merged in, ${asPrefix.detail}` } : pinned;
|
||||
if (!stampOnly.same) return stopped(env, t, 'QUEUE_TRAIN_MOVED', `#${o.pr} moved past its pinned head ${t.slots[i]!.head.slice(0, 12)} and is not T${after.index} modulo stamps (${head.slice(0, 12)}: ${pinned.detail})`, main, lines);
|
||||
const stampOnly = pinned.same ? pinned : mergedIn(env, t, i, head, main);
|
||||
if (!stampOnly.same) return stopped(env, t, 'QUEUE_TRAIN_MOVED', `#${o.pr} moved past its pinned head ${t.slots[i]!.head.slice(0, 12)} and is not T${after.index} modulo stamps (${head.slice(0, 12)}: ${stampOnly.detail}; against the pin it ${pinned.detail})`, main, lines);
|
||||
return outOk([...lines, `main: ${main} = T${before.index} (${pre.detail})`, `head: ${head} (${stampOnly.detail})`,
|
||||
`VERIFY_TRAIN: #${o.pr} may merge as slot ${i + 1} of train ${t.nonce} (green run ${gate.run?.id ?? '-'}, ${env.train.full_gate_check} success)`, `MERGE_CONDITION: sha=${head}`],
|
||||
`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);
|
||||
@@ -113,7 +144,7 @@ function confirmMerged(env: TrainEnv, t: TrainRecord, pr: string, main: string,
|
||||
effect(t, `verified #${pr} merged as T${index}`);
|
||||
save(env, t);
|
||||
lines.push(`VERIFY_TRAIN: #${pr} merged; main's tree is T${index} of train ${t.nonce} (${t.merged.length}/${t.slots.length} merged)`);
|
||||
const freeze = activeFreeze(env.repo.key);
|
||||
const freeze = activeFreeze(env.repo.keys);
|
||||
if (freeze && freeze.repair?.train === t.nonce && t.state === 'merged') {
|
||||
freeze.cleared_at = nowIso();
|
||||
freeze.cleared_by = `repair #${pr} via train ${t.nonce}; main ${main.slice(0, 12)} carries a green record`;
|
||||
|
||||
+53
-25
@@ -16,7 +16,7 @@
|
||||
*/
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { DISPATCH_INPUTS, awaitNonceRun, dispatchWorkflow, listDispatchRuns, makeNonce, runForNonce, type WorkflowDispatch } from '../scripts/eval-bg';
|
||||
import { mergeChangelogs, parseChangelog, type ChangelogSection } from './changelog-check';
|
||||
import { changelogDamage, mergeChangelogs, parseChangelog, type ChangelogSection } from './changelog-check';
|
||||
import type { ResultStatus } from './headless-artifacts';
|
||||
import { versionAtRef, versionFiles } from './restamp';
|
||||
import type { LoadedPolicy, ShipPolicy, TrainPolicy } from './ship-policy';
|
||||
@@ -34,7 +34,7 @@ export const outRefused = (failure: QueueFailure, lines: string[], next: string,
|
||||
export const outFailed = (failure: QueueFailure, lines: string[], next: string, run: string): TrainOut => ({ exit: 1, status: 'refused', lines, next, failure, run });
|
||||
export const outPending = (failure: QueueFailure, lines: string[], next: string, run: string): TrainOut => ({ exit: 3, status: 'incomplete', lines, next, failure, run });
|
||||
|
||||
export interface TrainEnv { ctx: QueueContext; policy: LoadedPolicy; train: TrainPolicy; repo: { key: string; url: string }; remote: string }
|
||||
export interface TrainEnv { ctx: QueueContext; policy: LoadedPolicy; train: TrainPolicy; repo: { key: string; url: string; keys: string[] }; remote: string }
|
||||
|
||||
const HEX = /^[0-9a-f]{40,64}$/;
|
||||
const Q = 'gstack-ship-queue';
|
||||
@@ -92,9 +92,20 @@ export function parsePins(spec: string | undefined): Array<{ pr: string; sha: st
|
||||
}
|
||||
|
||||
export const pinList = (slots: Array<{ pr: string; head: string }>) => slots.map(s => `${s.pr}@${s.head}`).join(',');
|
||||
/** `--prs <pins>` for a rebuild of these slots, plus `--order` when the train was built with an explicit order. */
|
||||
export const prsArgs = (slots: Array<{ pr: string; head: string }>, explicit?: boolean) => `--prs ${pinList(slots)}${explicit ? ` --order ${slots.map(s => s.pr).join(',')}` : ''}`;
|
||||
|
||||
/** Slot order: screened PRs first, then unscreened, each in READY order; enough unscreened PRs form their own train. */
|
||||
export function orderSlots(slots: TrainSlot[], max: number): { train: TrainSlot[]; held: TrainSlot[]; split: boolean } {
|
||||
/**
|
||||
* Slot order: screened PRs first, then unscreened, each in READY order;
|
||||
* enough unscreened PRs form their own train. An explicit order (`--order`,
|
||||
* every admitted PR once) replaces that rule: slots follow it, the first
|
||||
* `max` ride and the rest are held.
|
||||
*/
|
||||
export function orderSlots(slots: TrainSlot[], max: number, explicit?: readonly string[]): { train: TrainSlot[]; held: TrainSlot[]; split: boolean } {
|
||||
if (explicit) {
|
||||
const all = explicit.map(pr => slots.find(s => s.pr === pr)!);
|
||||
return { train: all.slice(0, max), held: all.slice(max), split: false };
|
||||
}
|
||||
const byReady = (a: TrainSlot, b: TrainSlot) => a.ready_at.localeCompare(b.ready_at) || Number(a.pr) - Number(b.pr);
|
||||
const screened = slots.filter(s => s.screen.choice === 'screen').sort(byReady);
|
||||
const unscreened = slots.filter(s => s.screen.choice !== 'screen').sort(byReady);
|
||||
@@ -130,7 +141,9 @@ function unionChangelog(ours: string, theirs: string): string {
|
||||
* One train merge, in object storage: the PR head onto the previous prefix.
|
||||
* Version files take the prefix's side; the CHANGELOG merges by section (the
|
||||
* gate-ahead rule, else a union of sections, since each heading is restamped
|
||||
* at merge). Any other conflict is reported with every conflicting path.
|
||||
* at merge), unless a side damaged released history (changelogDamage), which
|
||||
* is a conflict the lane resolves. Any other conflict is reported with every
|
||||
* conflicting path.
|
||||
*/
|
||||
export function mergeStep(repoRoot: string, pred: string, head: string, policy: ShipPolicy): { tree: string } | { conflicts: string[] } {
|
||||
const r = git(repoRoot, ['merge-tree', '--write-tree', head, pred]);
|
||||
@@ -157,6 +170,9 @@ export function mergeStep(repoRoot: string, pred: string, head: string, policy:
|
||||
if (!ours || !theirs) conflicts.push(file);
|
||||
else if (file === policy.changelog) {
|
||||
const text = (sha: string) => git(repoRoot, ['cat-file', 'blob', sha]).out + '\n';
|
||||
const base = entry.get(1);
|
||||
const damage = changelogDamage(text(ours), text(theirs), base ? text(base) : undefined);
|
||||
if (damage) { conflicts.push(`${file} (${damage})`); continue; }
|
||||
const merged = mergeChangelogs(text(ours), text(theirs)) ?? unionChangelog(text(ours), text(theirs));
|
||||
const blob = git(repoRoot, ['hash-object', '-w', '--stdin'], undefined, merged);
|
||||
if (blob.ok) resolved.push({ path: file, blob: blob.out }); else conflicts.push(file);
|
||||
@@ -207,12 +223,12 @@ export function nextFor(t: TrainRecord): string {
|
||||
case 'attributed': {
|
||||
const drop = loadBisect(n)?.outcome?.prs ?? [];
|
||||
const keep = t.slots.filter(s => !drop.includes(s.pr));
|
||||
return keep.length ? `${Q} train build --prs ${pinList(keep)}` : 'none (every PR was dropped)';
|
||||
return keep.length ? `${Q} train build ${prsArgs(keep, t.explicit_order)}` : 'none (every PR was dropped)';
|
||||
}
|
||||
case 'red-on-main': return `fix forward on main first, then ${Q} train build --prs ${pinList(t.slots)}`;
|
||||
case 'red-on-main': return `fix forward on main first, then ${Q} train build ${prsArgs(t.slots, t.explicit_order)}`;
|
||||
case 'unattributed': return loadBisect(n)?.outcome?.all_green && !attemptsOf(t).some(a => a.purpose === 'rebuild')
|
||||
? `${Q} train rebuild --train ${n}`
|
||||
: `route the red cases to a repair lane (GStack Minder); once the repair merges: ${Q} train build --prs ${pinList(t.slots)}`;
|
||||
: `route the red cases to a repair lane (GStack Minder); once the repair merges: ${Q} train build ${prsArgs(t.slots, t.explicit_order)}`;
|
||||
default: return `${Q} train prune --train ${n}`;
|
||||
}
|
||||
}
|
||||
@@ -249,7 +265,17 @@ export function gateDispatch(env: TrainEnv, train: TrainRecord, nonce: string):
|
||||
return { workflow: env.train.gate_workflow, inputs: { [DISPATCH_INPUTS.evalsAll]: 'true', [DISPATCH_INPUTS.expectedSha]: train.head, [DISPATCH_INPUTS.nonce]: nonce } };
|
||||
}
|
||||
|
||||
export interface BuildOptions { pins?: string; resume?: string; dryRun: boolean; urgent: boolean; repair: boolean; freeTimeoutSec: number; pollMs: number }
|
||||
export interface BuildOptions { pins?: string; order?: string; resume?: string; dryRun: boolean; urgent: boolean; repair: boolean; freeTimeoutSec: number; pollMs: number }
|
||||
|
||||
/** `--order <pr>,<pr>,...`: every pinned PR exactly once (a leading # allowed). */
|
||||
export function parseOrder(spec: string | undefined, pins: Array<{ pr: string }>): string[] | undefined | { error: string } {
|
||||
if (spec === undefined) return undefined;
|
||||
const order = spec.split(',').map(s => s.trim().replace(/^#/, '')).filter(Boolean);
|
||||
const want = pins.map(p => p.pr).sort((a, b) => Number(a) - Number(b));
|
||||
const got = [...order].sort((a, b) => Number(a) - Number(b));
|
||||
if (order.some(pr => !/^\d+$/.test(pr)) || got.join(',') !== want.join(',')) return { error: `--order must list every PR in --prs exactly once (--prs has ${want.map(p => '#' + p).join(',')}; got ${spec})` };
|
||||
return order;
|
||||
}
|
||||
|
||||
/** The slots a new train would carry: trusted, unmoved, READY PRs, in slot order. */
|
||||
function admitSlots(env: TrainEnv, pins: Array<{ pr: string; sha: string }>): { slots: TrainSlot[]; lines: string[] } | TrainOut {
|
||||
@@ -267,7 +293,7 @@ function admitSlots(env: TrainEnv, pins: Array<{ pr: string; sha: string }>): {
|
||||
if (!info.headRefOid.startsWith(p.sha) || fetched !== info.headRefOid) {
|
||||
return outRefused({ code: 'QUEUE_TRAIN_MOVED', message: `PR #${p.pr} moved: pinned ${p.sha.slice(0, 12)}, head is now ${info.headRefOid.slice(0, 12)}` }, [], `ask #${p.pr}'s lane for READY on ${info.headRefOid.slice(0, 12)}, then ${Q} train build --prs <pins>`, 'none');
|
||||
}
|
||||
const ready = loadReady(env.repo.key, p.pr, info.headRefOid);
|
||||
const ready = loadReady(env.repo.keys, p.pr, info.headRefOid);
|
||||
if (!ready) return outRefused({ code: 'QUEUE_NOT_READY', message: `no READY receipt for #${p.pr} at ${info.headRefOid.slice(0, 12)} (local state or ${'refs/notes/gstack-trains'})` }, [], `the lane runs ${Q} ready --pr ${p.pr} --screen|--no-screen`, 'none');
|
||||
slots.push({ pr: p.pr, head: info.headRefOid, ready_at: ready.ready_at, screen: ready.screen });
|
||||
}
|
||||
@@ -280,28 +306,30 @@ async function newTrain(env: TrainEnv, o: BuildOptions): Promise<{ t: TrainRecor
|
||||
if (typeof pins === 'string') return outRefused({ code: 'QUEUE_PROOF_MISSING', message: pins }, [], 'none', 'none');
|
||||
if ((o.urgent || o.repair) && pins.length !== 1) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `${o.repair ? '--repair' : '--urgent'} admits exactly one PR` }, [], 'none', 'none');
|
||||
rebuildFromNotes(env.ctx.repoRoot, env.remote);
|
||||
const freeze = activeFreeze(env.repo.key);
|
||||
const freeze = activeFreeze(env.repo.keys);
|
||||
if (freeze && !o.repair) return outRefused({ code: 'QUEUE_TRAIN_FROZEN', message: `the queue is frozen since ${freeze.created_at}: train ${freeze.train} stopped after merging ${freeze.merged.map(p => '#' + p).join(',')} (${freeze.reason})` },
|
||||
frozenLines(freeze), `${Q} train build --repair --prs <repair-pr>@<sha>`, 'none');
|
||||
const explicit = parseOrder(o.order, pins);
|
||||
if (explicit && 'error' in explicit) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: explicit.error }, [], 'none', 'none');
|
||||
const admitted = admitSlots(env, pins);
|
||||
if ('exit' in admitted) return admitted;
|
||||
const order = orderSlots(admitted.slots, o.repair ? 1 : env.train.max_size);
|
||||
const order = orderSlots(admitted.slots, o.repair ? 1 : env.train.max_size, explicit);
|
||||
const lines = [
|
||||
`ORDER: ${order.train.map((s, i) => `${i + 1}:#${s.pr}(${s.screen.choice === 'screen' ? 'screened' : 'unscreened'})`).join(' ')}`,
|
||||
`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 --prs ${pinList(order.train)} (after ${due}, or with more PRs, or --urgent for one)`, 'none');
|
||||
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.key).find(t => t.key === key && !TERMINAL_STATES.includes(t.state) && t.state !== 'red' && t.state !== 'unattributed');
|
||||
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 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);
|
||||
return outFailed({ code: 'QUEUE_TRAIN_CONFLICT', message: `#${chain.stoppedAt} conflicts with the prefix before it` }, [...lines,
|
||||
`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 --prs ${pinList(without)}`],
|
||||
without.length ? `${Q} train build --prs ${pinList(without)}` : 'none', 'none');
|
||||
`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');
|
||||
}
|
||||
const head = chain.prefixes.at(-1)!;
|
||||
const nonce = makeNonce('gate', head.commit, baseSha, randomBytes(3).toString('hex'), { purpose: 'train' });
|
||||
@@ -309,7 +337,7 @@ async function newTrain(env: TrainEnv, o: BuildOptions): Promise<{ t: TrainRecor
|
||||
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, state: 'built', free: null, attempts: [], merged: [], superseded_by: null, effects: [], error: null,
|
||||
urgent: o.urgent, repair: o.repair, ...(explicit ? { explicit_order: true } : {}), state: 'built', free: null, attempts: [], merged: [], superseded_by: null, effects: [], error: null,
|
||||
created_at: now, updated_at: now,
|
||||
} };
|
||||
}
|
||||
@@ -342,7 +370,7 @@ async function freePhase(env: TrainEnv, slug: string, t: TrainRecord, o: BuildOp
|
||||
t.state = 'free-red';
|
||||
effect(t, `${env.train.free_workflow} dispatch refused by gh`);
|
||||
save(env, t);
|
||||
return outFailed({ code: 'QUEUE_TRAIN_FREE_RED', message: `gh could not dispatch ${env.train.free_workflow} on ${t.branch}` }, describe(t), `${Q} train build --prs ${pinList(t.slots)}`, run);
|
||||
return outFailed({ code: 'QUEUE_TRAIN_FREE_RED', message: `gh could not dispatch ${env.train.free_workflow} on ${t.branch}` }, describe(t), `${Q} train build ${prsArgs(t.slots, t.explicit_order)}`, run);
|
||||
}
|
||||
t.state = 'free-pending';
|
||||
effect(t, `${env.train.free_workflow} dispatched on ${t.branch}`);
|
||||
@@ -422,12 +450,12 @@ export async function buildTrain(ctx: QueueContext, o: BuildOptions): Promise<Tr
|
||||
if (!('kind' in t)) return t;
|
||||
if (o.dryRun) {
|
||||
return outOk([...lines, ...describe(t), `ESTIMATE: one ${env.train.gate_workflow} full gate (evals_all) on T${t.prefixes.length - 1} plus ${env.train.free_workflow}; dollar figure pending the T0 cost replay (lib/gate-cost.ts)`,
|
||||
'DRY-RUN: nothing written, pushed or dispatched'], `${Q} train build --prs ${pinList(t.slots)}`, 'none');
|
||||
'DRY-RUN: nothing written, pushed or dispatched'], `${Q} train build ${prsArgs(t.slots, t.explicit_order)}`, 'none');
|
||||
}
|
||||
if (t.state === 'built' && !t.attempts.length && !loadTrain(t.nonce)) {
|
||||
effect(t, 'train.json written');
|
||||
save(env, t);
|
||||
for (const old of listTrains(env.repo.key)) {
|
||||
for (const old of listTrains(env.repo.keys)) {
|
||||
if (old.nonce === t.nonce || TERMINAL_STATES.includes(old.state) || old.state === 'green' || old.state === 'merging') continue;
|
||||
if (!old.slots.some(s => t.slots.some((n: TrainSlot) => n.pr === s.pr))) continue;
|
||||
old.state = 'superseded';
|
||||
@@ -435,7 +463,7 @@ export async function buildTrain(ctx: QueueContext, o: BuildOptions): Promise<Tr
|
||||
save(env, old);
|
||||
}
|
||||
if (o.repair) {
|
||||
const f = activeFreeze(env.repo.key)!;
|
||||
const f = activeFreeze(env.repo.keys)!;
|
||||
f.repair = { pr: t.slots[0]!.pr, head: t.slots[0]!.head, train: t.nonce };
|
||||
f.updated_at = nowIso();
|
||||
persist(env.ctx.repoRoot, [f], { remote: env.remote });
|
||||
@@ -454,7 +482,7 @@ export function trainStatus(ctx: QueueContext, o: { train?: string }): TrainOut
|
||||
if ('code' in env) return outRefused(env, [], 'none', 'none');
|
||||
const restored = rebuildFromNotes(ctx.repoRoot, env.remote);
|
||||
const lines = [`NOTES: read ${restored.read} record(s), restored ${restored.restored} (${restored.detail})`];
|
||||
const trains = o.train ? [loadTrain(o.train)].filter((t): t is TrainRecord => !!t) : listTrains(env.repo.key).filter(t => !TERMINAL_STATES.includes(t.state));
|
||||
const trains = o.train ? [loadTrain(o.train)].filter((t): t is TrainRecord => !!t) : listTrains(env.repo.keys).filter(t => !TERMINAL_STATES.includes(t.state));
|
||||
if (o.train && !trains.length) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `no train record ${o.train} here or in refs/notes/gstack-trains` }, lines, `${Q} train status`, 'none');
|
||||
const slug = trains.length ? repoSlug(ctx) : null;
|
||||
const exec = execFor(ctx);
|
||||
@@ -482,7 +510,7 @@ export function trainStatus(ctx: QueueContext, o: { train?: string }): TrainOut
|
||||
lines.push(`NEXT(${t.nonce}): ${nextFor(t)}`);
|
||||
}
|
||||
if (!trains.length) lines.push('TRAIN: none live');
|
||||
const freeze = activeFreeze(env.repo.key);
|
||||
const freeze = activeFreeze(env.repo.keys);
|
||||
if (freeze) lines.push(...frozenLines(freeze));
|
||||
const single = trains.length === 1 ? trains[0]! : null;
|
||||
const next = freeze && !freeze.repair ? `${Q} train build --repair --prs <repair-pr>@<sha>` : single ? nextFor(single) : trains.length ? 'see each NEXT(<nonce>) line' : 'none';
|
||||
@@ -496,7 +524,7 @@ export function pruneTrains(ctx: QueueContext, o: { dryRun: boolean; train?: str
|
||||
rebuildFromNotes(ctx.repoRoot, env.remote);
|
||||
const lines: string[] = [];
|
||||
const rgit = (args: string[]) => git(ctx.repoRoot, args);
|
||||
for (const t of listTrains(env.repo.key).filter(x => !o.train || x.nonce === o.train)) {
|
||||
for (const t of listTrains(env.repo.keys).filter(x => !o.train || x.nonce === o.train)) {
|
||||
if (!TERMINAL_STATES.includes(t.state)) { lines.push(`KEEP: ${t.branch} (${t.state})`); continue; }
|
||||
if (!remoteBranchSha(rgit, env.remote, t.branch)) { lines.push(`GONE: ${t.branch} (${t.state})`); continue; }
|
||||
if (o.dryRun) { lines.push(`WOULD-DELETE: ${t.branch} (${t.state})`); continue; }
|
||||
|
||||
@@ -18,7 +18,7 @@ export type TrainHead =
|
||||
| { kind: 'invalid'; reason: string };
|
||||
|
||||
function git(repoRoot: string, args: string[]): string | null {
|
||||
const r = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8', timeout: 30_000 });
|
||||
const r = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 64 * 1024 * 1024 });
|
||||
return r.status === 0 ? r.stdout : null;
|
||||
}
|
||||
|
||||
@@ -50,11 +50,18 @@ export function classifyTrainHead(o: { repoRoot: string; baseRef: string; head:
|
||||
}
|
||||
|
||||
const file = typeof policy.changelog === 'string' ? policy.changelog : 'CHANGELOG.md';
|
||||
const before = parseChangelog(show(mergeBase, file) ?? '');
|
||||
const after = parseChangelog(show(o.head, file) ?? '');
|
||||
const exists = (rev: string) => git(o.repoRoot, ['cat-file', '-e', `${rev}:${file}`]) !== null;
|
||||
const read = (rev: string) => (exists(rev) ? show(rev, file) : '');
|
||||
const [beforeText, afterText] = [read(mergeBase), read(o.head)];
|
||||
if (beforeText === null || afterText === null) return { kind: 'invalid', reason: `cannot read ${file} at ${beforeText === null ? `the merge base ${mergeBase.slice(0, 12)}` : o.head}` };
|
||||
const before = parseChangelog(beforeText);
|
||||
const after = parseChangelog(afterText);
|
||||
const unreleased = after.sections.filter(s => s.unreleased);
|
||||
if (unreleased.length > 1) return { kind: 'invalid', reason: `${file} has ${unreleased.length} [Unreleased] sections` };
|
||||
if (unreleased.length === 1 && after.sections[0] !== unreleased[0]) return { kind: 'invalid', reason: `${file}: [Unreleased] must be the top section` };
|
||||
const kept = new Set(after.sections.filter(s => !s.unreleased).map(s => s.version));
|
||||
const dropped = before.sections.filter(s => !s.unreleased && !kept.has(s.version)).map(s => s.version);
|
||||
if (dropped.length) return { kind: 'invalid', reason: `${file} drops released heading ${dropped.slice(0, 3).join(', ')}${dropped.length > 3 ? ` (+${dropped.length - 3})` : ''}; an unstamped head may only add its [Unreleased] section above the latest release` };
|
||||
const released = (c: typeof after) => [c.header.join('\n'), ...c.sections.filter(s => !s.unreleased).map(s => s.lines.join('\n'))];
|
||||
const was = released(before);
|
||||
const now = released(after);
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.91.191",
|
||||
"version": "1.91.192",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* CHANGELOG merges that would carry damage to released history (GSTA-67,
|
||||
* from #3345 on 2026-10-22). #3345's pin 1eb7092 replaced main's
|
||||
* `## [1.91.179.0]` heading with `## [Unreleased]` instead of inserting
|
||||
* above it, and mergeChangelogs returned non-null output with 179's body
|
||||
* duplicated into [Unreleased]; the train's T1 6755ab8 carried it. The
|
||||
* fixtures under test/fixtures/merge-train-3345/ are the real CHANGELOGs at
|
||||
* those commits, cut below 1.91.177.0. A null merge means a hand resolve.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { changelogDamage, mergeChangelogs, ownEntryAboveMain, parseChangelog } from '../lib/changelog-check';
|
||||
|
||||
const fixture = (name: string) => fs.readFileSync(path.join(import.meta.dir, 'fixtures', 'merge-train-3345', name), 'utf8');
|
||||
const base = fixture('base-a59b5e4.md');
|
||||
const pin = fixture('pin-1eb7092.md');
|
||||
const main = fixture('main-4a98e40.md');
|
||||
const keys = (text: string) => parseChangelog(text).sections.map(s => s.version ?? 'Unreleased');
|
||||
|
||||
describe('mergeChangelogs refuses damage to released history', () => {
|
||||
test('GSTA-64 repro: a released heading replaced by [Unreleased] is null, not a merge with the 1.0.1 body duplicated', () => {
|
||||
const ours = '# Changelog\n\n## [Unreleased]\n\nNew entry.\n\nOld 1.0.1 body.\n\n## [1.0.0] - d\n\nOld 1.0.0 body.\n';
|
||||
const theirs = '# Changelog\n\n## [1.0.2] - d\n\nMain body.\n\n## [1.0.1] - d\n\nOld 1.0.1 body.\n\n## [1.0.0] - d\n\nOld 1.0.0 body.\n';
|
||||
expect(changelogDamage(ours, theirs)).toContain("ours's Unreleased section holds released 1.0.1 text");
|
||||
expect(mergeChangelogs(ours, theirs)).toBeNull();
|
||||
const baseText = '# Changelog\n\n## [1.0.1] - d\n\nOld 1.0.1 body.\n\n## [1.0.0] - d\n\nOld 1.0.0 body.\n';
|
||||
expect(changelogDamage(ours, theirs, baseText)).toBe('ours drops released heading 1.0.1 that the merge base carries');
|
||||
});
|
||||
|
||||
test('#3345: pin 1eb7092 against main 4a98e40 is null with or without the merge base; the train T1 it produced duplicated 179', () => {
|
||||
expect(changelogDamage(pin, main, base)).toBe('ours drops released heading 1.91.179.0 that the merge base carries');
|
||||
expect(changelogDamage(pin, main)).toMatch(/^ours's Unreleased section holds released 1\.91\.179\.0 text/);
|
||||
expect(mergeChangelogs(pin, main, base)).toBeNull();
|
||||
expect(mergeChangelogs(pin, main)).toBeNull();
|
||||
const t1 = fixture('train-T1-6755ab8.md');
|
||||
expect(keys(t1)).toEqual(['Unreleased', '1.91.181.0', '1.91.180.0', '1.91.179.0', '1.91.178.0', '1.91.177.0']);
|
||||
expect(changelogDamage(t1, main)).toMatch(/^ours's Unreleased section holds released 1\.91\.179\.0 text/);
|
||||
});
|
||||
|
||||
test('either side, and a renamed heading, count as damage', () => {
|
||||
expect(changelogDamage(main, pin, base)).toBe('theirs drops released heading 1.91.179.0 that the merge base carries');
|
||||
const renamed = main.replace('## [1.91.178.0]', '## [1.91.178.9]');
|
||||
expect(changelogDamage(renamed, main, base)).toBe('ours drops released heading 1.91.178.0 that the merge base carries');
|
||||
expect(changelogDamage(renamed, main)).toMatch(/^ours's 1\.91\.178\.9 section holds released 1\.91\.178\.0 text/);
|
||||
});
|
||||
|
||||
test('negative controls: the real hand resolve, an entry above a newer main, a shared tentative stamp and two [Unreleased] blocks are not damage', () => {
|
||||
const fixed = pin.replace(/\n\n\*\*docsync-fault-interface/, '\n\n## [1.91.179.0] - 2026-10-22\n\n**docsync-fault-interface');
|
||||
expect(keys(fixed)).toEqual(['Unreleased', '1.91.179.0', '1.91.178.0', '1.91.177.0']);
|
||||
expect(changelogDamage(fixed, main, base)).toBeNull();
|
||||
const merged = mergeChangelogs(fixed, main, base)!;
|
||||
expect(keys(merged)).toEqual(['Unreleased', '1.91.181.0', '1.91.180.0', '1.91.179.0', '1.91.178.0', '1.91.177.0']);
|
||||
expect(merged.split('**docsync-fault-interface fits its budgets').length).toBe(2);
|
||||
const b = '## [1.2.3.0] - d\n\n- base release entry\n';
|
||||
expect(mergeChangelogs(`# C\n\n## [1.2.4.0] - d\n\n- mine, a new entry\n\n${b}`, `# C\n\n## [1.2.4.0] - e\n\n- theirs, a new entry\n\n${b}`, `# C\n\n${b}`)).not.toBeNull();
|
||||
expect(changelogDamage(`# C\n\n## [Unreleased]\n\n- #2 change, its entry\n\n${b}`, `# C\n\n## [Unreleased]\n\n- #1 change, its entry\n\n${b}`, `# C\n\n${b}`)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ownEntryAboveMain: a merged-in head's CHANGELOG however it was resolved", () => {
|
||||
const dir = path.join(import.meta.dir, 'fixtures', 'merge-train-3345');
|
||||
const diff = spawnSync('git', ['diff', '--no-index', '--unified=0', '--no-color', path.join(dir, 'base-a59b5e4.md'), path.join(dir, 'pin-1eb7092.md')], { encoding: 'utf8', timeout: 30_000 }).stdout;
|
||||
const authored = new Set(diff.split('\n').filter(l => l.startsWith('+') && !l.startsWith('+++')).map(l => l.slice(1)));
|
||||
const stamped = fixture('stamped-de23f5d.md');
|
||||
|
||||
test("#3345's stamped head de23f5d is main 4a98e40's CHANGELOG plus its own entry; the damaged train T1 is not", () => {
|
||||
expect(authored.has('### Fixed')).toBe(true);
|
||||
expect(authored.has('## [1.91.179.0] - 2026-10-22')).toBe(false);
|
||||
expect(ownEntryAboveMain(stamped, main, authored)).toBeNull();
|
||||
expect(ownEntryAboveMain(fixture('train-T1-6755ab8.md'), main, authored)).toMatch(/^the PR's entry holds a line its pin never added: "\*\*docsync-fault-interface/);
|
||||
});
|
||||
|
||||
test('negative controls: an invented line, a dropped or edited main section, a changed header and a duplicate heading are refused', () => {
|
||||
expect(ownEntryAboveMain(stamped.replace('### Fixed\n', '### Fixed\n- added after the gate\n'), main, authored)).toBe('the PR\'s entry holds a line its pin never added: "- added after the gate"');
|
||||
expect(ownEntryAboveMain(stamped.replace(/## \[1\.91\.180\.0\][^\n]*\n/, ''), main, authored)).toBe("main's ## [1.91.181.0] - 2026-10-23 is not carried unchanged below the PR's entry");
|
||||
expect(ownEntryAboveMain(stamped.replace('## [1.91.178.0]', '## [1.91.178.0]\n\n- smuggled'), main, authored)).toBe("main's ## [1.91.178.0] - 2026-10-22 is not carried unchanged below the PR's entry");
|
||||
expect(ownEntryAboveMain(stamped.replace('# Changelog', '# Change log'), main, authored)).toBe('the header differs from main');
|
||||
expect(ownEntryAboveMain(stamped.replace('## [1.91.182.0]', '## [1.91.181.0]'), main, authored)).toMatch(/is headed twice$/);
|
||||
});
|
||||
});
|
||||
@@ -185,6 +185,52 @@ describe('version gate: unstamped heads under a train policy', () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe('a CHANGELOG over 1 MiB (GSTA-67: #3345 head 1eb7092 passed because the 1.67 MB CHANGELOG overflowed the read)', () => {
|
||||
const excerpt = (name: string) => fs.readFileSync(path.join(ROOT, 'test', 'fixtures', 'merge-train-3345', name), 'utf8');
|
||||
const baseTop = excerpt('base-a59b5e4.md');
|
||||
const padding = Array.from({ length: 2400 }, (_, i) => `## [1.0.${2400 - i}.0] - 2026-01-01\n\nOlder release ${i}: ${'x'.repeat(440)}\n`).join('\n');
|
||||
const big = `${baseTop}\n${padding}`;
|
||||
const bigFixture = () => {
|
||||
const r = fixture();
|
||||
write(r, 'CHANGELOG.md', big);
|
||||
commit(r, 'main with a full-size CHANGELOG');
|
||||
r.git('push', '-q', 'origin', 'main');
|
||||
r.git('fetch', '-q', 'origin');
|
||||
branch(r);
|
||||
expect(Buffer.byteLength(big)).toBeGreaterThan(1.05 * 1024 * 1024);
|
||||
return r;
|
||||
};
|
||||
|
||||
test("#3345's change (the 1.91.179.0 heading replaced by [Unreleased]) is refused, naming the dropped heading", () => {
|
||||
const r = bigFixture();
|
||||
write(r, 'CHANGELOG.md', big.replace(baseTop, excerpt('pin-1eb7092.md')));
|
||||
const res = run(r, commit(r, 'the 1eb7092 CHANGELOG edit'));
|
||||
expect(res.code).toBe(1);
|
||||
expect(res.out).toContain('::error::PR #3320: CHANGELOG.md drops released heading 1.91.179.0; an unstamped head may only add its [Unreleased] section above the latest release.');
|
||||
});
|
||||
|
||||
test('control: an additive [Unreleased] entry above the latest release still passes at full size', () => {
|
||||
const r = bigFixture();
|
||||
write(r, 'CHANGELOG.md', big.replace('## [1.91.179.0]', '## [Unreleased]\n\n- the PR entry\n\n## [1.91.179.0]'));
|
||||
const res = run(r, commit(r, 'additive entry'));
|
||||
expect(res.code).toBe(0);
|
||||
expect(res.out).toContain('is unstamped under the base');
|
||||
});
|
||||
|
||||
test('a CHANGELOG that cannot be read is refused, never passed', () => {
|
||||
const r = bigFixture();
|
||||
write(r, 'CHANGELOG.md', big.replace('## [1.91.179.0]', '## [Unreleased]\n\n- the PR entry\n\n## [1.91.179.0]'));
|
||||
const head = commit(r, 'additive entry, then a damaged object');
|
||||
const blob = r.git('rev-parse', `${head}:CHANGELOG.md`);
|
||||
const loose = path.join(r.work, '.git', 'objects', blob.slice(0, 2), blob.slice(2));
|
||||
fs.chmodSync(loose, 0o644);
|
||||
fs.writeFileSync(loose, fs.readFileSync(loose).subarray(0, 2000));
|
||||
const res = run(r, head);
|
||||
expect(res.code).toBe(1);
|
||||
expect(res.out).toContain(`::error::PR #3320: cannot read CHANGELOG.md at ${head}.`);
|
||||
});
|
||||
});
|
||||
|
||||
test('the workflow runs the gate from the merge commit with the base ref and head sha', () => {
|
||||
const workflow = Bun.YAML.parse(fs.readFileSync(path.join(ROOT, '.github', 'workflows', 'version-gate.yml'), 'utf8')) as any;
|
||||
const steps = workflow.jobs.check.steps;
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
# Changelog
|
||||
|
||||
## [1.91.179.0] - 2026-10-22
|
||||
|
||||
**docsync-fault-interface fits its budgets under load again.** On 2026-10-18 it timed out under load on a Ubicloud full run. Its nested 36-case suite spent most of its time starting processes: each fault actor spawn loaded about 36 ms of imports (an empty bun takes 5.5 ms), inspect repeated Git reads, and the whole nested run sat under one 120 s outer test. With real Git on 4 cores, inner cases now take a 0.70 s median (max 1.25 s) unloaded, against 1.07 s before. Under 8 busy loops the max is 3.44 s, inside bun's default 5 s; before, cases reached 4.45 s. The worst outer test went from about 100 s of its 120 s cap to 33 s. No timeout, budget, retry, assertion or case changed.
|
||||
|
||||
### Fixed
|
||||
- **`test/helpers/docsync-fault-actor.ts`**: imports only light helper modules, hashes with `Bun.CryptoHasher` instead of loading `node:crypto`, and loads the redaction engine only on the snapshot-failure path. inspect makes 8 Git calls instead of 11, and the snapshot base check reuses the `--base` it just passed. Each actor spawn now costs 15–19 ms.
|
||||
- **`test/helpers/docsync-repo.ts`, `test/helpers/docsync-interface.ts`** (new): the fixture's Git and file helpers and `docsNativeInterface` moved out of the fixture and observer modules, so the actor stops loading the eval store and the QA observer. `docsync-fixture.ts` and `docsync-observer.ts` re-export them, so no importer changed. The fixture writes its Git identity with one append to `.git/config` instead of three `git config` calls.
|
||||
- **`bin/gstack-docs-candidate`**: snapshot lists `ls-files` once instead of twice and hashes with `Bun.CryptoHasher`. A snapshot spawn went from 68 ms to 46 ms, and the record it writes is byte-identical.
|
||||
- **`test/docsync-fault-interface.test.ts`**: the nested callback run is split into three outer tests by test-name pattern (10, 12 and 14 cases). Each part asserts its exact pass count, its exact filtered-out count and `0 fail`, so all 36 cases still run and the patterns are disjoint.
|
||||
|
||||
## [1.91.178.0] - 2026-10-22
|
||||
|
||||
**A /qa run no longer gets a hint that another report root could change its verdict.** Train gate 39646086155 failed qa-functional-webhook-fix once, and gate 39166935531 failed qa-functional-cli-fix the same way on 2026-10-18. Both times the agent ran `gstack-qa-evidence materialize`, read a fail verdict that ended "this verdict is final for this report root", and its very next call wrote a stray placeholder file in the fixture's parent directory (`qa-reports-placeholder-unused`, `qa-Q3vLqZ-placeholder`) before writing `report.md`. Each agent then reported the stray file itself. The out-of-fixture write detector was right both times and is unchanged.
|
||||
|
||||
### Fixed
|
||||
- **`lib/qa-evidence.ts`**: materialize's `next` instruction now ends "this verdict is final. Next, write the Markdown report beside evidence.json." It names the next write and where it goes, instead of qualifying the verdict by report root.
|
||||
- **`test/helpers/qa-functional-eval.ts`**: the functional fixture's declared interface says that every Write/Edit targets an owned path inside the working directory, and that a write anywhere else, including the parent directory, fails acceptance. That was already the acceptance rule; the prompt now states it.
|
||||
|
||||
### Gate integrity
|
||||
- No detector, threshold, trial count or case kind changed. `test/qa-evidence.test.ts` pins the new `next` wording and checks that it no longer mentions a report root.
|
||||
|
||||
## [1.91.177.0] - 2026-10-22
|
||||
|
||||
**The /cso runtime promotion workflow can finish.** Promote run 39691485727 failed after its `cso-runtime-release` approval. Its "Generate a fail-closed catalog candidate" step calls `gh api …/compare` to prove the staged commit is an ancestor of the qualification head, but the step had no `GH_TOKEN`. On a hosted runner `gh` then exits 4 ("set the GH_TOKEN environment variable"). The run stopped before it signed, pushed or opened anything.
|
||||
|
||||
### Fixed
|
||||
- **`.github/workflows/cso-runtime-promote.yml`**: the candidate step gets `GH_TOKEN: ${{ github.token }}`, like every other step there that calls `gh`.
|
||||
- **`test/workflow-gh-token.test.ts`** (new): fails when any step in any workflow invokes `gh` with no `GH_TOKEN` or `GITHUB_TOKEN` in its step, job or workflow env. actionlint does not check this. This step was the only one in the repository missing a token.
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
# Changelog
|
||||
|
||||
## [1.91.181.0] - 2026-10-23
|
||||
|
||||
**/cso can run its contained runtime checks.** Before this release the runtime catalog qualified nothing, so /cso always fell back to static assessment. It now qualifies ten pinned images: Node 24.4.0, Bun 1.3.10, Python 3.13.4 with uv 0.8.0, Rails on Ruby 3.4.4, and PostgreSQL 17.2, each for linux/amd64 and linux/arm64. With Docker installed, comprehensive /cso boots the target app inside the matching image and tests findings and repairs against it. The images passed the private evaluator's release qualification (run-5 statements, re-delivered under Garry's approval). The ingress then checked them in qualify run 39674065531 at 514fbfd.
|
||||
|
||||
### Added
|
||||
- **`lib/cso/runtime-catalog.json`** is now catalog `cso-v3-39674065531-133cb360842d`, which replaces `cso-v3-build-reviewed-2026-09-10`. Promote run 39841502986 generated it from qualification run 39674065531's authenticated statements and attested it. GitHub Actions committed the attested bytes (`sha256:078e0bf6…`). Each runtime's image is pinned to a public `ghcr.io/garrytan/gstack/cso-staging/…@sha256` digest, and each entry records its source commit (133cb36), SBOM and provenance digests, and requalification triggers (`preparationSha256` `c721b585…`).
|
||||
|
||||
### For contributors
|
||||
- **The /cso free tests no longer assume the committed runtime catalog is empty.** Six tests pinned `runtimes: []` and broke as soon as a promotion landed. Tests that need an unqualified catalog now build one with `unqualifiedRuntimeCatalog()`. The replay tests expect whichever fail-closed rejection the committed catalog produces, so the suite passes with the catalog both before and after a promotion.
|
||||
|
||||
## [1.91.180.0] - 2026-10-22
|
||||
|
||||
**The /cso docs say what the qualified catalogs now allow.** AGENTS.md, the README and `docs/cso-release-qualification.md` still said that no qualified runtime or scanner image existed, so /cso ran static assessment only. The scanner catalog has been qualified since #3042. The runtime catalog becomes qualified with the runtime promotion PR that ships alongside this one.
|
||||
|
||||
### Changed
|
||||
- **AGENTS.md**, **README.md** and **`docs/cso-release-qualification.md`**: the /cso entries name the ten qualified runtime images and the twelve scanner images, and say when /cso still falls back to static assessment: without Docker, or where no profile matches.
|
||||
|
||||
## [1.91.179.0] - 2026-10-22
|
||||
|
||||
**docsync-fault-interface fits its budgets under load again.** On 2026-10-18 it timed out under load on a Ubicloud full run. Its nested 36-case suite spent most of its time starting processes: each fault actor spawn loaded about 36 ms of imports (an empty bun takes 5.5 ms), inspect repeated Git reads, and the whole nested run sat under one 120 s outer test. With real Git on 4 cores, inner cases now take a 0.70 s median (max 1.25 s) unloaded, against 1.07 s before. Under 8 busy loops the max is 3.44 s, inside bun's default 5 s; before, cases reached 4.45 s. The worst outer test went from about 100 s of its 120 s cap to 33 s. No timeout, budget, retry, assertion or case changed.
|
||||
|
||||
### Fixed
|
||||
- **`test/helpers/docsync-fault-actor.ts`**: imports only light helper modules, hashes with `Bun.CryptoHasher` instead of loading `node:crypto`, and loads the redaction engine only on the snapshot-failure path. inspect makes 8 Git calls instead of 11, and the snapshot base check reuses the `--base` it just passed. Each actor spawn now costs 15–19 ms.
|
||||
- **`test/helpers/docsync-repo.ts`, `test/helpers/docsync-interface.ts`** (new): the fixture's Git and file helpers and `docsNativeInterface` moved out of the fixture and observer modules, so the actor stops loading the eval store and the QA observer. `docsync-fixture.ts` and `docsync-observer.ts` re-export them, so no importer changed. The fixture writes its Git identity with one append to `.git/config` instead of three `git config` calls.
|
||||
- **`bin/gstack-docs-candidate`**: snapshot lists `ls-files` once instead of twice and hashes with `Bun.CryptoHasher`. A snapshot spawn went from 68 ms to 46 ms, and the record it writes is byte-identical.
|
||||
- **`test/docsync-fault-interface.test.ts`**: the nested callback run is split into three outer tests by test-name pattern (10, 12 and 14 cases). Each part asserts its exact pass count, its exact filtered-out count and `0 fail`, so all 36 cases still run and the patterns are disjoint.
|
||||
|
||||
## [1.91.178.0] - 2026-10-22
|
||||
|
||||
**A /qa run no longer gets a hint that another report root could change its verdict.** Train gate 39646086155 failed qa-functional-webhook-fix once, and gate 39166935531 failed qa-functional-cli-fix the same way on 2026-10-18. Both times the agent ran `gstack-qa-evidence materialize`, read a fail verdict that ended "this verdict is final for this report root", and its very next call wrote a stray placeholder file in the fixture's parent directory (`qa-reports-placeholder-unused`, `qa-Q3vLqZ-placeholder`) before writing `report.md`. Each agent then reported the stray file itself. The out-of-fixture write detector was right both times and is unchanged.
|
||||
|
||||
### Fixed
|
||||
- **`lib/qa-evidence.ts`**: materialize's `next` instruction now ends "this verdict is final. Next, write the Markdown report beside evidence.json." It names the next write and where it goes, instead of qualifying the verdict by report root.
|
||||
- **`test/helpers/qa-functional-eval.ts`**: the functional fixture's declared interface says that every Write/Edit targets an owned path inside the working directory, and that a write anywhere else, including the parent directory, fails acceptance. That was already the acceptance rule; the prompt now states it.
|
||||
|
||||
### Gate integrity
|
||||
- No detector, threshold, trial count or case kind changed. `test/qa-evidence.test.ts` pins the new `next` wording and checks that it no longer mentions a report root.
|
||||
|
||||
## [1.91.177.0] - 2026-10-22
|
||||
|
||||
**The /cso runtime promotion workflow can finish.** Promote run 39691485727 failed after its `cso-runtime-release` approval. Its "Generate a fail-closed catalog candidate" step calls `gh api …/compare` to prove the staged commit is an ancestor of the qualification head, but the step had no `GH_TOKEN`. On a hosted runner `gh` then exits 4 ("set the GH_TOKEN environment variable"). The run stopped before it signed, pushed or opened anything.
|
||||
|
||||
### Fixed
|
||||
- **`.github/workflows/cso-runtime-promote.yml`**: the candidate step gets `GH_TOKEN: ${{ github.token }}`, like every other step there that calls `gh`.
|
||||
- **`test/workflow-gh-token.test.ts`** (new): fails when any step in any workflow invokes `gh` with no `GH_TOKEN` or `GITHUB_TOKEN` in its step, job or workflow env. actionlint does not check this. This step was the only one in the repository missing a token.
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
**gstack installs on a project that pins Bun 1.4.4 again.** gbrain master moved its CI to Bun 1.4.4 (gbrain #7374, the Brotli CVE-2026-111497 fix), and gstack's Capy installer then stopped every gbrain session with `RUNTIME_PIN_CONFLICT` ("verified archives: 1.4.2 1.4.3 … pins exactly 1.4.4"), because it installs only Bun archives whose SHA-256 it carries. It now carries 1.4.4, so the same run installs the pinned Bun and ends `gstack: ok`.
|
||||
|
||||
### Fixed
|
||||
- **`bin/gstack-bun-version.sh`**: `GSTACK_BUN_VERIFIED` is now 1.4.2 1.4.3 1.4.4, with 1.4.4's checksums for linux-x64, linux-x64-baseline and linux-aarch64, the targets 1.4.3 covers. They come from the release's `SHASUMS256.txt.asc`, whose clearsigned signature verifies against Bun's release key F3DCC08A8572C0749B3E18888EAB4D40A7B22B59 (Robobun, the key oven-sh/bun's own Dockerfiles assert), and each archive was downloaded and hashed to the same value. The 1.4.2 and 1.4.3 entries are unchanged and verify the same way. gstack's own minimum (1.4.2), install default (1.4.3) and CI pins do not move.
|
||||
- **`test/gstack-capy-install.test.ts`**: a gbrain-like repo whose CI pins only 1.4.4 refuses `~/.bun` 1.4.3 and installs the 1.4.4 archive. **`test/bun-version-drift.test.ts`**: every verified version must carry a checksum for each installer target. The existing "verified archives" text assertions list 1.4.4.
|
||||
|
||||
|
||||
**docsync-fault-interface fits its budgets under load again.** On 2026-10-18 it timed out under load on a Ubicloud full run. Its nested 36-case suite spent most of its time starting processes: each fault actor spawn loaded about 36 ms of imports (an empty bun takes 5.5 ms), inspect repeated Git reads, and the whole nested run sat under one 120 s outer test. With real Git on 4 cores, inner cases now take a 0.70 s median (max 1.25 s) unloaded, against 1.07 s before. Under 8 busy loops the max is 3.44 s, inside bun's default 5 s; before, cases reached 4.45 s. The worst outer test went from about 100 s of its 120 s cap to 33 s. No timeout, budget, retry, assertion or case changed.
|
||||
|
||||
### Fixed
|
||||
- **`test/helpers/docsync-fault-actor.ts`**: imports only light helper modules, hashes with `Bun.CryptoHasher` instead of loading `node:crypto`, and loads the redaction engine only on the snapshot-failure path. inspect makes 8 Git calls instead of 11, and the snapshot base check reuses the `--base` it just passed. Each actor spawn now costs 15–19 ms.
|
||||
- **`test/helpers/docsync-repo.ts`, `test/helpers/docsync-interface.ts`** (new): the fixture's Git and file helpers and `docsNativeInterface` moved out of the fixture and observer modules, so the actor stops loading the eval store and the QA observer. `docsync-fixture.ts` and `docsync-observer.ts` re-export them, so no importer changed. The fixture writes its Git identity with one append to `.git/config` instead of three `git config` calls.
|
||||
- **`bin/gstack-docs-candidate`**: snapshot lists `ls-files` once instead of twice and hashes with `Bun.CryptoHasher`. A snapshot spawn went from 68 ms to 46 ms, and the record it writes is byte-identical.
|
||||
- **`test/docsync-fault-interface.test.ts`**: the nested callback run is split into three outer tests by test-name pattern (10, 12 and 14 cases). Each part asserts its exact pass count, its exact filtered-out count and `0 fail`, so all 36 cases still run and the patterns are disjoint.
|
||||
|
||||
## [1.91.178.0] - 2026-10-22
|
||||
|
||||
**A /qa run no longer gets a hint that another report root could change its verdict.** Train gate 39646086155 failed qa-functional-webhook-fix once, and gate 39166935531 failed qa-functional-cli-fix the same way on 2026-10-18. Both times the agent ran `gstack-qa-evidence materialize`, read a fail verdict that ended "this verdict is final for this report root", and its very next call wrote a stray placeholder file in the fixture's parent directory (`qa-reports-placeholder-unused`, `qa-Q3vLqZ-placeholder`) before writing `report.md`. Each agent then reported the stray file itself. The out-of-fixture write detector was right both times and is unchanged.
|
||||
|
||||
### Fixed
|
||||
- **`lib/qa-evidence.ts`**: materialize's `next` instruction now ends "this verdict is final. Next, write the Markdown report beside evidence.json." It names the next write and where it goes, instead of qualifying the verdict by report root.
|
||||
- **`test/helpers/qa-functional-eval.ts`**: the functional fixture's declared interface says that every Write/Edit targets an owned path inside the working directory, and that a write anywhere else, including the parent directory, fails acceptance. That was already the acceptance rule; the prompt now states it.
|
||||
|
||||
### Gate integrity
|
||||
- No detector, threshold, trial count or case kind changed. `test/qa-evidence.test.ts` pins the new `next` wording and checks that it no longer mentions a report root.
|
||||
|
||||
## [1.91.177.0] - 2026-10-22
|
||||
|
||||
**The /cso runtime promotion workflow can finish.** Promote run 39691485727 failed after its `cso-runtime-release` approval. Its "Generate a fail-closed catalog candidate" step calls `gh api …/compare` to prove the staged commit is an ancestor of the qualification head, but the step had no `GH_TOKEN`. On a hosted runner `gh` then exits 4 ("set the GH_TOKEN environment variable"). The run stopped before it signed, pushed or opened anything.
|
||||
|
||||
### Fixed
|
||||
- **`.github/workflows/cso-runtime-promote.yml`**: the candidate step gets `GH_TOKEN: ${{ github.token }}`, like every other step there that calls `gh`.
|
||||
- **`test/workflow-gh-token.test.ts`** (new): fails when any step in any workflow invokes `gh` with no `GH_TOKEN` or `GITHUB_TOKEN` in its step, job or workflow env. actionlint does not check this. This step was the only one in the repository missing a token.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Changelog
|
||||
|
||||
## [1.91.182.0] - 2026-10-23
|
||||
|
||||
**gstack installs on a project that pins Bun 1.4.4 again.** gbrain master moved its CI to Bun 1.4.4 (gbrain #7374, the Brotli CVE-2026-111497 fix), and gstack's Capy installer then stopped every gbrain session with `RUNTIME_PIN_CONFLICT` ("verified archives: 1.4.2 1.4.3 … pins exactly 1.4.4"), because it installs only Bun archives whose SHA-256 it carries. It now carries 1.4.4, so the same run installs the pinned Bun and ends `gstack: ok`.
|
||||
|
||||
### Fixed
|
||||
- **`bin/gstack-bun-version.sh`**: `GSTACK_BUN_VERIFIED` is now 1.4.2 1.4.3 1.4.4, with 1.4.4's checksums for linux-x64, linux-x64-baseline and linux-aarch64, the targets 1.4.3 covers. They come from the release's `SHASUMS256.txt.asc`, whose clearsigned signature verifies against Bun's release key F3DCC08A8572C0749B3E18888EAB4D40A7B22B59 (Robobun, the key oven-sh/bun's own Dockerfiles assert), and each archive was downloaded and hashed to the same value. The 1.4.2 and 1.4.3 entries are unchanged and verify the same way. gstack's own minimum (1.4.2), install default (1.4.3) and CI pins do not move.
|
||||
- **`test/gstack-capy-install.test.ts`**: a gbrain-like repo whose CI pins only 1.4.4 refuses `~/.bun` 1.4.3 and installs the 1.4.4 archive. **`test/bun-version-drift.test.ts`**: every verified version must carry a checksum for each installer target. The existing "verified archives" text assertions list 1.4.4.
|
||||
|
||||
## [1.91.181.0] - 2026-10-23
|
||||
|
||||
**/cso can run its contained runtime checks.** Before this release the runtime catalog qualified nothing, so /cso always fell back to static assessment. It now qualifies ten pinned images: Node 24.4.0, Bun 1.3.10, Python 3.13.4 with uv 0.8.0, Rails on Ruby 3.4.4, and PostgreSQL 17.2, each for linux/amd64 and linux/arm64. With Docker installed, comprehensive /cso boots the target app inside the matching image and tests findings and repairs against it. The images passed the private evaluator's release qualification (run-5 statements, re-delivered under Garry's approval). The ingress then checked them in qualify run 39674065531 at 514fbfd.
|
||||
|
||||
### Added
|
||||
- **`lib/cso/runtime-catalog.json`** is now catalog `cso-v3-39674065531-133cb360842d`, which replaces `cso-v3-build-reviewed-2026-09-10`. Promote run 39841502986 generated it from qualification run 39674065531's authenticated statements and attested it. GitHub Actions committed the attested bytes (`sha256:078e0bf6…`). Each runtime's image is pinned to a public `ghcr.io/garrytan/gstack/cso-staging/…@sha256` digest, and each entry records its source commit (133cb36), SBOM and provenance digests, and requalification triggers (`preparationSha256` `c721b585…`).
|
||||
|
||||
### For contributors
|
||||
- **The /cso free tests no longer assume the committed runtime catalog is empty.** Six tests pinned `runtimes: []` and broke as soon as a promotion landed. Tests that need an unqualified catalog now build one with `unqualifiedRuntimeCatalog()`. The replay tests expect whichever fail-closed rejection the committed catalog produces, so the suite passes with the catalog both before and after a promotion.
|
||||
|
||||
## [1.91.180.0] - 2026-10-22
|
||||
|
||||
**The /cso docs say what the qualified catalogs now allow.** AGENTS.md, the README and `docs/cso-release-qualification.md` still said that no qualified runtime or scanner image existed, so /cso ran static assessment only. The scanner catalog has been qualified since #3042. The runtime catalog becomes qualified with the runtime promotion PR that ships alongside this one.
|
||||
|
||||
### Changed
|
||||
- **AGENTS.md**, **README.md** and **`docs/cso-release-qualification.md`**: the /cso entries name the ten qualified runtime images and the twelve scanner images, and say when /cso still falls back to static assessment: without Docker, or where no profile matches.
|
||||
|
||||
## [1.91.179.0] - 2026-10-22
|
||||
|
||||
**docsync-fault-interface fits its budgets under load again.** On 2026-10-18 it timed out under load on a Ubicloud full run. Its nested 36-case suite spent most of its time starting processes: each fault actor spawn loaded about 36 ms of imports (an empty bun takes 5.5 ms), inspect repeated Git reads, and the whole nested run sat under one 120 s outer test. With real Git on 4 cores, inner cases now take a 0.70 s median (max 1.25 s) unloaded, against 1.07 s before. Under 8 busy loops the max is 3.44 s, inside bun's default 5 s; before, cases reached 4.45 s. The worst outer test went from about 100 s of its 120 s cap to 33 s. No timeout, budget, retry, assertion or case changed.
|
||||
|
||||
### Fixed
|
||||
- **`test/helpers/docsync-fault-actor.ts`**: imports only light helper modules, hashes with `Bun.CryptoHasher` instead of loading `node:crypto`, and loads the redaction engine only on the snapshot-failure path. inspect makes 8 Git calls instead of 11, and the snapshot base check reuses the `--base` it just passed. Each actor spawn now costs 15–19 ms.
|
||||
- **`test/helpers/docsync-repo.ts`, `test/helpers/docsync-interface.ts`** (new): the fixture's Git and file helpers and `docsNativeInterface` moved out of the fixture and observer modules, so the actor stops loading the eval store and the QA observer. `docsync-fixture.ts` and `docsync-observer.ts` re-export them, so no importer changed. The fixture writes its Git identity with one append to `.git/config` instead of three `git config` calls.
|
||||
- **`bin/gstack-docs-candidate`**: snapshot lists `ls-files` once instead of twice and hashes with `Bun.CryptoHasher`. A snapshot spawn went from 68 ms to 46 ms, and the record it writes is byte-identical.
|
||||
- **`test/docsync-fault-interface.test.ts`**: the nested callback run is split into three outer tests by test-name pattern (10, 12 and 14 cases). Each part asserts its exact pass count, its exact filtered-out count and `0 fail`, so all 36 cases still run and the patterns are disjoint.
|
||||
|
||||
## [1.91.178.0] - 2026-10-22
|
||||
|
||||
**A /qa run no longer gets a hint that another report root could change its verdict.** Train gate 39646086155 failed qa-functional-webhook-fix once, and gate 39166935531 failed qa-functional-cli-fix the same way on 2026-10-18. Both times the agent ran `gstack-qa-evidence materialize`, read a fail verdict that ended "this verdict is final for this report root", and its very next call wrote a stray placeholder file in the fixture's parent directory (`qa-reports-placeholder-unused`, `qa-Q3vLqZ-placeholder`) before writing `report.md`. Each agent then reported the stray file itself. The out-of-fixture write detector was right both times and is unchanged.
|
||||
|
||||
### Fixed
|
||||
- **`lib/qa-evidence.ts`**: materialize's `next` instruction now ends "this verdict is final. Next, write the Markdown report beside evidence.json." It names the next write and where it goes, instead of qualifying the verdict by report root.
|
||||
- **`test/helpers/qa-functional-eval.ts`**: the functional fixture's declared interface says that every Write/Edit targets an owned path inside the working directory, and that a write anywhere else, including the parent directory, fails acceptance. That was already the acceptance rule; the prompt now states it.
|
||||
|
||||
### Gate integrity
|
||||
- No detector, threshold, trial count or case kind changed. `test/qa-evidence.test.ts` pins the new `next` wording and checks that it no longer mentions a report root.
|
||||
|
||||
## [1.91.177.0] - 2026-10-22
|
||||
|
||||
**The /cso runtime promotion workflow can finish.** Promote run 39691485727 failed after its `cso-runtime-release` approval. Its "Generate a fail-closed catalog candidate" step calls `gh api …/compare` to prove the staged commit is an ancestor of the qualification head, but the step had no `GH_TOKEN`. On a hosted runner `gh` then exits 4 ("set the GH_TOKEN environment variable"). The run stopped before it signed, pushed or opened anything.
|
||||
|
||||
### Fixed
|
||||
- **`.github/workflows/cso-runtime-promote.yml`**: the candidate step gets `GH_TOKEN: ${{ github.token }}`, like every other step there that calls `gh`.
|
||||
- **`test/workflow-gh-token.test.ts`** (new): fails when any step in any workflow invokes `gh` with no `GH_TOKEN` or `GITHUB_TOKEN` in its step, job or workflow env. actionlint does not check this. This step was the only one in the repository missing a token.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
**gstack installs on a project that pins Bun 1.4.4 again.** gbrain master moved its CI to Bun 1.4.4 (gbrain #7374, the Brotli CVE-2026-111497 fix), and gstack's Capy installer then stopped every gbrain session with `RUNTIME_PIN_CONFLICT` ("verified archives: 1.4.2 1.4.3 … pins exactly 1.4.4"), because it installs only Bun archives whose SHA-256 it carries. It now carries 1.4.4, so the same run installs the pinned Bun and ends `gstack: ok`.
|
||||
|
||||
### Fixed
|
||||
- **`bin/gstack-bun-version.sh`**: `GSTACK_BUN_VERIFIED` is now 1.4.2 1.4.3 1.4.4, with 1.4.4's checksums for linux-x64, linux-x64-baseline and linux-aarch64, the targets 1.4.3 covers. They come from the release's `SHASUMS256.txt.asc`, whose clearsigned signature verifies against Bun's release key F3DCC08A8572C0749B3E18888EAB4D40A7B22B59 (Robobun, the key oven-sh/bun's own Dockerfiles assert), and each archive was downloaded and hashed to the same value. The 1.4.2 and 1.4.3 entries are unchanged and verify the same way. gstack's own minimum (1.4.2), install default (1.4.3) and CI pins do not move.
|
||||
- **`test/gstack-capy-install.test.ts`**: a gbrain-like repo whose CI pins only 1.4.4 refuses `~/.bun` 1.4.3 and installs the 1.4.4 archive. **`test/bun-version-drift.test.ts`**: every verified version must carry a checksum for each installer target. The existing "verified archives" text assertions list 1.4.4.
|
||||
|
||||
|
||||
**docsync-fault-interface fits its budgets under load again.** On 2026-10-18 it timed out under load on a Ubicloud full run. Its nested 36-case suite spent most of its time starting processes: each fault actor spawn loaded about 36 ms of imports (an empty bun takes 5.5 ms), inspect repeated Git reads, and the whole nested run sat under one 120 s outer test. With real Git on 4 cores, inner cases now take a 0.70 s median (max 1.25 s) unloaded, against 1.07 s before. Under 8 busy loops the max is 3.44 s, inside bun's default 5 s; before, cases reached 4.45 s. The worst outer test went from about 100 s of its 120 s cap to 33 s. No timeout, budget, retry, assertion or case changed.
|
||||
|
||||
### Fixed
|
||||
- **`test/helpers/docsync-fault-actor.ts`**: imports only light helper modules, hashes with `Bun.CryptoHasher` instead of loading `node:crypto`, and loads the redaction engine only on the snapshot-failure path. inspect makes 8 Git calls instead of 11, and the snapshot base check reuses the `--base` it just passed. Each actor spawn now costs 15–19 ms.
|
||||
- **`test/helpers/docsync-repo.ts`, `test/helpers/docsync-interface.ts`** (new): the fixture's Git and file helpers and `docsNativeInterface` moved out of the fixture and observer modules, so the actor stops loading the eval store and the QA observer. `docsync-fixture.ts` and `docsync-observer.ts` re-export them, so no importer changed. The fixture writes its Git identity with one append to `.git/config` instead of three `git config` calls.
|
||||
- **`bin/gstack-docs-candidate`**: snapshot lists `ls-files` once instead of twice and hashes with `Bun.CryptoHasher`. A snapshot spawn went from 68 ms to 46 ms, and the record it writes is byte-identical.
|
||||
- **`test/docsync-fault-interface.test.ts`**: the nested callback run is split into three outer tests by test-name pattern (10, 12 and 14 cases). Each part asserts its exact pass count, its exact filtered-out count and `0 fail`, so all 36 cases still run and the patterns are disjoint.
|
||||
|
||||
## [1.91.181.0] - 2026-10-23
|
||||
|
||||
**/cso can run its contained runtime checks.** Before this release the runtime catalog qualified nothing, so /cso always fell back to static assessment. It now qualifies ten pinned images: Node 24.4.0, Bun 1.3.10, Python 3.13.4 with uv 0.8.0, Rails on Ruby 3.4.4, and PostgreSQL 17.2, each for linux/amd64 and linux/arm64. With Docker installed, comprehensive /cso boots the target app inside the matching image and tests findings and repairs against it. The images passed the private evaluator's release qualification (run-5 statements, re-delivered under Garry's approval). The ingress then checked them in qualify run 39674065531 at 514fbfd.
|
||||
|
||||
### Added
|
||||
- **`lib/cso/runtime-catalog.json`** is now catalog `cso-v3-39674065531-133cb360842d`, which replaces `cso-v3-build-reviewed-2026-09-10`. Promote run 39841502986 generated it from qualification run 39674065531's authenticated statements and attested it. GitHub Actions committed the attested bytes (`sha256:078e0bf6…`). Each runtime's image is pinned to a public `ghcr.io/garrytan/gstack/cso-staging/…@sha256` digest, and each entry records its source commit (133cb36), SBOM and provenance digests, and requalification triggers (`preparationSha256` `c721b585…`).
|
||||
|
||||
### For contributors
|
||||
- **The /cso free tests no longer assume the committed runtime catalog is empty.** Six tests pinned `runtimes: []` and broke as soon as a promotion landed. Tests that need an unqualified catalog now build one with `unqualifiedRuntimeCatalog()`. The replay tests expect whichever fail-closed rejection the committed catalog produces, so the suite passes with the catalog both before and after a promotion.
|
||||
|
||||
## [1.91.180.0] - 2026-10-22
|
||||
|
||||
**The /cso docs say what the qualified catalogs now allow.** AGENTS.md, the README and `docs/cso-release-qualification.md` still said that no qualified runtime or scanner image existed, so /cso ran static assessment only. The scanner catalog has been qualified since #3042. The runtime catalog becomes qualified with the runtime promotion PR that ships alongside this one.
|
||||
|
||||
### Changed
|
||||
- **AGENTS.md**, **README.md** and **`docs/cso-release-qualification.md`**: the /cso entries name the ten qualified runtime images and the twelve scanner images, and say when /cso still falls back to static assessment: without Docker, or where no profile matches.
|
||||
|
||||
## [1.91.179.0] - 2026-10-22
|
||||
|
||||
**docsync-fault-interface fits its budgets under load again.** On 2026-10-18 it timed out under load on a Ubicloud full run. Its nested 36-case suite spent most of its time starting processes: each fault actor spawn loaded about 36 ms of imports (an empty bun takes 5.5 ms), inspect repeated Git reads, and the whole nested run sat under one 120 s outer test. With real Git on 4 cores, inner cases now take a 0.70 s median (max 1.25 s) unloaded, against 1.07 s before. Under 8 busy loops the max is 3.44 s, inside bun's default 5 s; before, cases reached 4.45 s. The worst outer test went from about 100 s of its 120 s cap to 33 s. No timeout, budget, retry, assertion or case changed.
|
||||
|
||||
### Fixed
|
||||
- **`test/helpers/docsync-fault-actor.ts`**: imports only light helper modules, hashes with `Bun.CryptoHasher` instead of loading `node:crypto`, and loads the redaction engine only on the snapshot-failure path. inspect makes 8 Git calls instead of 11, and the snapshot base check reuses the `--base` it just passed. Each actor spawn now costs 15–19 ms.
|
||||
- **`test/helpers/docsync-repo.ts`, `test/helpers/docsync-interface.ts`** (new): the fixture's Git and file helpers and `docsNativeInterface` moved out of the fixture and observer modules, so the actor stops loading the eval store and the QA observer. `docsync-fixture.ts` and `docsync-observer.ts` re-export them, so no importer changed. The fixture writes its Git identity with one append to `.git/config` instead of three `git config` calls.
|
||||
- **`bin/gstack-docs-candidate`**: snapshot lists `ls-files` once instead of twice and hashes with `Bun.CryptoHasher`. A snapshot spawn went from 68 ms to 46 ms, and the record it writes is byte-identical.
|
||||
- **`test/docsync-fault-interface.test.ts`**: the nested callback run is split into three outer tests by test-name pattern (10, 12 and 14 cases). Each part asserts its exact pass count, its exact filtered-out count and `0 fail`, so all 36 cases still run and the patterns are disjoint.
|
||||
|
||||
## [1.91.178.0] - 2026-10-22
|
||||
|
||||
**A /qa run no longer gets a hint that another report root could change its verdict.** Train gate 39646086155 failed qa-functional-webhook-fix once, and gate 39166935531 failed qa-functional-cli-fix the same way on 2026-10-18. Both times the agent ran `gstack-qa-evidence materialize`, read a fail verdict that ended "this verdict is final for this report root", and its very next call wrote a stray placeholder file in the fixture's parent directory (`qa-reports-placeholder-unused`, `qa-Q3vLqZ-placeholder`) before writing `report.md`. Each agent then reported the stray file itself. The out-of-fixture write detector was right both times and is unchanged.
|
||||
|
||||
### Fixed
|
||||
- **`lib/qa-evidence.ts`**: materialize's `next` instruction now ends "this verdict is final. Next, write the Markdown report beside evidence.json." It names the next write and where it goes, instead of qualifying the verdict by report root.
|
||||
- **`test/helpers/qa-functional-eval.ts`**: the functional fixture's declared interface says that every Write/Edit targets an owned path inside the working directory, and that a write anywhere else, including the parent directory, fails acceptance. That was already the acceptance rule; the prompt now states it.
|
||||
|
||||
### Gate integrity
|
||||
- No detector, threshold, trial count or case kind changed. `test/qa-evidence.test.ts` pins the new `next` wording and checks that it no longer mentions a report root.
|
||||
|
||||
## [1.91.177.0] - 2026-10-22
|
||||
|
||||
**The /cso runtime promotion workflow can finish.** Promote run 39691485727 failed after its `cso-runtime-release` approval. Its "Generate a fail-closed catalog candidate" step calls `gh api …/compare` to prove the staged commit is an ancestor of the qualification head, but the step had no `GH_TOKEN`. On a hosted runner `gh` then exits 4 ("set the GH_TOKEN environment variable"). The run stopped before it signed, pushed or opened anything.
|
||||
|
||||
### Fixed
|
||||
- **`.github/workflows/cso-runtime-promote.yml`**: the candidate step gets `GH_TOKEN: ${{ github.token }}`, like every other step there that calls `gh`.
|
||||
- **`test/workflow-gh-token.test.ts`** (new): fails when any step in any workflow invokes `gh` with no `GH_TOKEN` or `GITHUB_TOKEN` in its step, job or workflow env. actionlint does not check this. This step was the only one in the repository missing a token.
|
||||
@@ -138,6 +138,7 @@ export const FREE_FIXTURES: Readonly<Record<string, readonly string[]>> = {
|
||||
'test/fixtures/investigate-owned-question-39195396438.json': ['test/workflow-boundaries-fixture.test.ts'],
|
||||
'test/fixtures/judge-calibration/**': ['scripts/judge-calibration.ts', 'test/judge-calibration.test.ts'],
|
||||
'test/fixtures/judge-panel-replay-2026-10-05.json': ['test/judge-panel-median.test.ts'],
|
||||
'test/fixtures/merge-train-3345/**': ['test/changelog-merge-damage.test.ts', 'test/compare-pr-version.test.ts'],
|
||||
'test/fixtures/module-size-ratchet.json': ['test/helpers/module-size.ts', 'test/module-size-ratchet.test.ts'],
|
||||
'test/fixtures/module-size/**': ['test/helpers/module-size.ts', 'test/module-size-ratchet.test.ts'],
|
||||
'test/fixtures/multi-agent-wave/**': ['test/headless-artifacts.test.ts', 'test/helpers/autoplan-run-fixture.ts', 'test/plan-reality.test.ts'],
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
* artifact downloads and check-run reads. Every call is logged, so tests can
|
||||
* prove what was (and was not) dispatched.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
@@ -58,7 +59,7 @@ export function repoKey(fx: TrainFixture): string {
|
||||
}
|
||||
|
||||
/** A same-repo PR: a branch from origin/main touching `file`, published as refs/pull/<n>/head (and /merge), known to the gh shim. */
|
||||
export function openPr(fx: TrainFixture, pr: string, opts: { file?: string; text?: string; changelog?: boolean; cross?: boolean; from?: string } = {}): string {
|
||||
export function openPr(fx: TrainFixture, pr: string, opts: { file?: string; text?: string; changelog?: boolean | ((text: string) => string); cross?: boolean; from?: string } = {}): string {
|
||||
const { repo } = fx;
|
||||
repo.git('checkout', '-q', opts.from ?? 'origin/main', '-B', `pr-${pr}`);
|
||||
const file = opts.file ?? `pr-${pr}.txt`;
|
||||
@@ -66,7 +67,7 @@ export function openPr(fx: TrainFixture, pr: string, opts: { file?: string; text
|
||||
fs.writeFileSync(path.join(repo.work, file), opts.text ?? `change from #${pr}\n`);
|
||||
if (opts.changelog !== false) {
|
||||
const cl = fs.readFileSync(path.join(repo.work, 'CHANGELOG.md'), 'utf8');
|
||||
fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), cl.replace('# Changelog\n', `# Changelog\n\n## [Unreleased]\n\n- #${pr} change\n`));
|
||||
fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), typeof opts.changelog === 'function' ? opts.changelog(cl) : cl.replace('# Changelog\n', `# Changelog\n\n## [Unreleased]\n\n- #${pr} change\n`));
|
||||
}
|
||||
repo.git('add', '-A');
|
||||
repo.git('commit', '-q', '-m', `feat #${pr}`);
|
||||
@@ -76,9 +77,19 @@ export function openPr(fx: TrainFixture, pr: string, opts: { file?: string; text
|
||||
return head;
|
||||
}
|
||||
|
||||
/** The repo key of a GitHub-form origin URL: sha256 of the URL with .git and trailing slashes dropped. */
|
||||
export const urlKey = (url: string) => createHash('sha256').update(url.replace(/\.git$/, '').replace(/\/+$/, '')).digest('hex').slice(0, 16);
|
||||
|
||||
/** Point the fixture clone's origin at a GitHub-form URL that git rewrites to the local bare origin (url.insteadOf). */
|
||||
export function githubOrigin(fx: TrainFixture, url: string, cwd = fx.repo.work): void {
|
||||
const git = (...args: string[]) => spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000, env: { ...process.env, ...fx.repo.env } });
|
||||
git('config', `url.${fx.repo.origin}.insteadOf`, url);
|
||||
git('remote', 'set-url', 'origin', url);
|
||||
}
|
||||
|
||||
/** A READY receipt as `ready --screen|--no-screen` writes it (the lane side is tested separately). */
|
||||
export function readyReceipt(fx: TrainFixture, pr: string, head: string, opts: { screen?: boolean; at?: string } = {}): void {
|
||||
const key = repoKey(fx);
|
||||
export function readyReceipt(fx: TrainFixture, pr: string, head: string, opts: { screen?: boolean; at?: string; key?: string } = {}): void {
|
||||
const key = opts.key ?? repoKey(fx);
|
||||
const file = path.join(fx.state, 'ship-queue', 'ready', key, `${pr}-${head}.json`);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
const at = opts.at ?? '2026-10-18T08:00:00.000Z';
|
||||
|
||||
@@ -8,13 +8,15 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { DISPATCH_INPUTS, dedupeKey, dispatchPlan, findDuplicateRun, makeNonce, nonceScope, type RunSummary } from '../scripts/eval-bg';
|
||||
import { DEFAULT_POLICY, TRAIN_KEYS_VERSION, validatePolicy } from '../lib/ship-policy';
|
||||
import { fillDue, orderSlots, parsePins } from '../lib/ship-queue-train';
|
||||
import { fillDue, orderSlots, parseOrder, parsePins, prsArgs } from '../lib/ship-queue-train';
|
||||
import { gstackPaidSelection, skillOwnCases, touchedSkills } from '../lib/ship-queue-train-ready';
|
||||
import type { TrainSlot } from '../lib/ship-queue-train-record';
|
||||
import { normalizeRemoteUrl, repoIdentity, type TrainSlot } from '../lib/ship-queue-train-record';
|
||||
import { INFRA_REDISPATCH, REBUILD_ONCE, classifyBisect, classifyGate } from '../lib/ship-queue-train-verdict';
|
||||
import { EVAL_POLICY } from './helpers/periodic-exclude-data';
|
||||
import { COMPLETE, FAIL, PASS, TRAIN_BLOCK } from './helpers/ship-queue-train-fixture';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
@@ -54,6 +56,54 @@ describe('ordering and fill', () => {
|
||||
expect(parsePins('12')).toMatch(/entries are <pr>@<sha>/);
|
||||
expect(parsePins(`12@${SHA},12@${SHA}`)).toBe('a PR appears twice in --prs');
|
||||
});
|
||||
|
||||
test('--order (GSTA-67, #3337 docs slotted ahead of the #3343 catalog it describes): an explicit order replaces screened-first and READY order; absent, the default is unchanged', () => {
|
||||
const docs = slot('3337', false, '2026-10-22T18:00:00Z');
|
||||
const catalog = slot('3343', false, '2026-10-22T19:00:00Z');
|
||||
expect(orderSlots([docs, catalog], 5).train.map(s => s.pr)).toEqual(['3337', '3343']);
|
||||
expect(orderSlots([docs, catalog], 5, undefined).train.map(s => s.pr)).toEqual(['3337', '3343']);
|
||||
const pins = [{ pr: '3337' }, { pr: '3343' }];
|
||||
const order = parseOrder('3343,#3337', pins);
|
||||
expect(order).toEqual(['3343', '3337']);
|
||||
expect(orderSlots([docs, catalog], 5, order as string[]).train.map(s => s.pr)).toEqual(['3343', '3337']);
|
||||
const screened = slot('9', true, '2026-10-22T20:00:00Z');
|
||||
const over = orderSlots([docs, catalog, screened], 2, ['3343', '3337', '9']);
|
||||
expect([over.train.map(s => s.pr), over.held.map(s => s.pr), over.split]).toEqual([['3343', '3337'], ['9'], false]);
|
||||
expect(parseOrder(undefined, pins)).toBeUndefined();
|
||||
for (const bad of ['3343', '3343,3337,3337', '3343,3337,1', '3343,x']) expect(parseOrder(bad, pins)).toEqual({ error: `--order must list every PR in --prs exactly once (--prs has #3337,#3343; got ${bad})` });
|
||||
expect(prsArgs([{ pr: '3343', head: SHA }, { pr: '3337', head: SHA }], true)).toBe(`--prs 3343@${SHA},3337@${SHA} --order 3343,3337`);
|
||||
expect(prsArgs([{ pr: '3343', head: SHA }], false)).toBe(`--prs 3343@${SHA}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('repo identity (GSTA-67, #3331 on 2026-10-21)', () => {
|
||||
test("every URL form of one GitHub repository gives one key, the github.com key gstack's records already use", () => {
|
||||
const canonical = 'https://github.com/garrytan/gstack';
|
||||
for (const url of ['https://github.com/garrytan/gstack', 'https://github.com/garrytan/gstack.git', 'https://git.capy.ai/garrytan/gstack', 'https://git.capy.ai/garrytan/gstack.git/',
|
||||
'git@github.com:garrytan/gstack.git', 'ssh://git@github.com/garrytan/gstack.git', 'ssh://git@ssh.github.com:443/garrytan/gstack.git', 'https://x-access-token:PASSWORD@github.com/garrytan/gstack.git',
|
||||
'http://www.github.com/GarryTan/GStack']) expect(normalizeRemoteUrl(url)).toBe(canonical);
|
||||
expect(normalizeRemoteUrl('https://gitlab.com/acme/widget.git')).toBe('https://gitlab.com/acme/widget');
|
||||
expect(normalizeRemoteUrl('https://user:PASSWORD@gitlab.com/acme/widget.git')).toBe('https://gitlab.com/acme/widget');
|
||||
expect(normalizeRemoteUrl('/tmp/fixture/origin.git')).toBe('/tmp/fixture/origin');
|
||||
});
|
||||
|
||||
test("#3331's lane (origin https://git.capy.ai/garrytan/gstack) now writes b321d228efd2bc14, and readers still accept the 1fb5dd69740a8cf8 receipt it wrote", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'repo-identity-'));
|
||||
try {
|
||||
const git = (...args: string[]) => spawnSync('git', args, { cwd: dir, encoding: 'utf8', timeout: 30_000 });
|
||||
git('init', '-q');
|
||||
git('remote', 'add', 'origin', 'https://git.capy.ai/garrytan/gstack');
|
||||
const lane = repoIdentity(dir);
|
||||
expect(lane.key).toBe('b321d228efd2bc14');
|
||||
expect(lane.url).toBe('https://github.com/garrytan/gstack');
|
||||
expect(lane.keys).toContain('1fb5dd69740a8cf8');
|
||||
git('remote', 'set-url', 'origin', 'https://github.com/garrytan/gstack.git');
|
||||
const coordinator = repoIdentity(dir);
|
||||
expect(coordinator.key).toBe('b321d228efd2bc14');
|
||||
expect(coordinator.keys).toEqual(expect.arrayContaining(['b321d228efd2bc14', '1fb5dd69740a8cf8']));
|
||||
expect(coordinator.keys[0]).toBe('b321d228efd2bc14');
|
||||
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
});
|
||||
|
||||
describe('verdict class mapping', () => {
|
||||
|
||||
@@ -19,7 +19,7 @@ import { TRAIN_HELP } from '../lib/ship-queue-train-cli';
|
||||
import { BASE_POLICY, cleanup, runBin } from './helpers/restamp-fixture';
|
||||
import { coordinatorClone } from './helpers/ship-queue-fixture';
|
||||
import {
|
||||
COMPLETE, FAIL, GREEN_GATE, PASS, RED_GATE, TRAIN_BLOCK, TRAIN_POLICY, dispatches, line, nonceOf, openPr, queue, readyReceipt, repoKey, trainFixture, trainJson,
|
||||
COMPLETE, FAIL, GREEN_GATE, PASS, RED_GATE, TRAIN_BLOCK, TRAIN_POLICY, dispatches, githubOrigin, line, nonceOf, openPr, queue, readyReceipt, repoKey, trainFixture, trainJson, urlKey,
|
||||
type Rule, type TrainFixture,
|
||||
} from './helpers/ship-queue-train-fixture';
|
||||
|
||||
@@ -127,6 +127,30 @@ describe('ready under a train policy: the screen choice', () => {
|
||||
expect(dispatches(fx)).toEqual([]);
|
||||
}, 120_000);
|
||||
|
||||
test("GSTA-67: a lane whose origin is another URL form of the repository (#3331's git.capy.ai) writes its receipt under the owner/repo key", () => {
|
||||
const { fx, head, pregate } = readyFixture();
|
||||
githubOrigin(fx, 'https://git.capy.ai/fixture/repo.git');
|
||||
const ok = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen']);
|
||||
expect(ok.status).toBe(EXIT.ok);
|
||||
const canonical = urlKey('https://github.com/fixture/repo');
|
||||
expect(canonical).not.toBe(urlKey('https://git.capy.ai/fixture/repo'));
|
||||
expect(line(ok.out, 'READY_RECEIPT:')).toContain(path.join(fx.state, 'ship-queue', 'ready', canonical, `1-${head}.json`));
|
||||
}, 120_000);
|
||||
|
||||
test("GSTA-67: ready refuses a head the Version Gate refuses (#3345's CHANGELOG replaced a released heading)", () => {
|
||||
const fx = make();
|
||||
const head = openPr(fx, '1', { changelog: cl => cl.replace('## [1.2.3.0] - 2026-10-01', '## [Unreleased]\n\n- #1 change') });
|
||||
fx.write(d => { d.ci[head] = [{ status: 'completed', conclusion: 'success' }]; });
|
||||
const tree = runBin(fx.repo, 'gstack-wtree', []).out.trim();
|
||||
const pregate = path.join(fx.repo.root, 'pregate.json');
|
||||
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree, head, verdict: 'pass', checks: [{ id: 'unit', status: 'pass' }] }));
|
||||
const r = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen']);
|
||||
expect(r.status).toBe(EXIT.refused);
|
||||
expect(r.err).toContain(`the Version Gate refuses ${head.slice(0, 12)}: CHANGELOG.md drops released heading 1.2.3.0`);
|
||||
expect(r.err).toContain('(QUEUE_NOT_READY)');
|
||||
expect(fs.existsSync(path.join(fx.state, 'ship-queue', 'ready'))).toBe(false);
|
||||
}, 120_000);
|
||||
|
||||
test('--screen dispatches one screen with the PR, its merge SHA and the policy; a repeat adopts it; a broad screen never adopts the narrow one', () => {
|
||||
const { fx, head, pregate } = readyFixture();
|
||||
const dry = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen', '--dry-run']);
|
||||
@@ -207,6 +231,57 @@ describe('train build', () => {
|
||||
expect(dispatches(fx)).toEqual([]);
|
||||
}, 120_000);
|
||||
|
||||
test("GSTA-67: a PR whose CHANGELOG drops a released heading (#3345's pin 1eb7092) is a CHANGELOG conflict, never merged with the damage", () => {
|
||||
const fx = make();
|
||||
const h1 = openPr(fx, '1');
|
||||
const h2 = openPr(fx, '2', { changelog: cl => cl.replace('## [1.2.3.0] - 2026-10-01', '## [Unreleased]\n\n- #2 change') });
|
||||
readyReceipt(fx, '1', h1);
|
||||
readyReceipt(fx, '2', h2);
|
||||
const r = queue(fx, ['train', 'build', '--prs', `1@${h1},2@${h2}`, '--dry-run']);
|
||||
expect(r.status).toBe(EXIT.fail);
|
||||
expect(line(r.out, 'QUEUE_TRAIN_CONFLICT:')).toContain('stopped at #2 (conflicts: CHANGELOG.md (ours drops released heading 1.2.3.0 that the merge base carries)) after merging #1');
|
||||
expect(dispatches(fx)).toEqual([]);
|
||||
}, 120_000);
|
||||
|
||||
test("GSTA-67: READY receipts under any URL form of the repository admit (#3331's receipt was keyed on git.capy.ai, the coordinator's origin on github.com)", () => {
|
||||
const fx = make();
|
||||
githubOrigin(fx, 'https://github.com/fixture/repo.git');
|
||||
const h1 = openPr(fx, '1');
|
||||
const h2 = openPr(fx, '2');
|
||||
readyReceipt(fx, '1', h1, { key: urlKey('https://git.capy.ai/fixture/repo') });
|
||||
readyReceipt(fx, '2', h2, { key: urlKey('https://github.com/fixture/repo') });
|
||||
const r = queue(fx, ['train', 'build', '--prs', `1@${h1},2@${h2}`, '--dry-run']);
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
expect(line(r.out, 'ORDER:')).toBe('ORDER: 1:#1(unscreened) 2:#2(unscreened)');
|
||||
const h3 = openPr(fx, '3');
|
||||
readyReceipt(fx, '3', h3, { key: urlKey('https://gitlab.com/fixture/repo') });
|
||||
expect(queue(fx, ['train', 'build', '--prs', `3@${h3}`, '--dry-run']).err).toMatch(/no READY receipt for #3 .*\(QUEUE_NOT_READY\)/);
|
||||
}, 120_000);
|
||||
|
||||
test('GSTA-67 --order: an explicit slot order (#3343 catalog before the #3337 docs that describe it) replaces READY order and carries into NEXT; the default is unchanged', () => {
|
||||
const fx = make();
|
||||
const docs = openPr(fx, '3337');
|
||||
const catalog = openPr(fx, '3343');
|
||||
readyReceipt(fx, '3337', docs, { at: '2026-10-18T07:00:00.000Z' });
|
||||
readyReceipt(fx, '3343', catalog, { at: '2026-10-18T08:00:00.000Z' });
|
||||
const pins = `3337@${docs},3343@${catalog}`;
|
||||
expect(line(queue(fx, ['train', 'build', '--prs', pins, '--dry-run']).out, 'ORDER:')).toBe('ORDER: 1:#3337(unscreened) 2:#3343(unscreened)');
|
||||
const dry = queue(fx, ['train', 'build', '--prs', pins, '--order', '3343,3337', '--dry-run']);
|
||||
expect(dry.status).toBe(EXIT.ok);
|
||||
expect(line(dry.out, 'ORDER:')).toBe('ORDER: 1:#3343(unscreened) 2:#3337(unscreened) (explicit --order)');
|
||||
expect(dry.out).toMatch(new RegExp(`^SLOT: 1 #3343 head=${catalog} `, 'm'));
|
||||
expect(next(dry.out)).toBe(`NEXT: gstack-ship-queue train build --prs 3343@${catalog},3337@${docs} --order 3343,3337`);
|
||||
const partial = queue(fx, ['train', 'build', '--prs', pins, '--order', '3343']);
|
||||
expect(partial.status).toBe(EXIT.refused);
|
||||
expect(partial.err).toContain('--order must list every PR in --prs exactly once (--prs has #3337,#3343; got 3343)');
|
||||
expect(queue(fx, ['train', 'build', '--train', 'x', '--order', '3343']).status).toBe(EXIT.usage);
|
||||
fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; });
|
||||
const real = queue(fx, ['train', 'build', '--prs', pins, '--order', '3343,3337']);
|
||||
expect(real.status).toBe(EXIT.ok);
|
||||
const t = trainJson(fx, nonceOf(real.out));
|
||||
expect([t.slots.map((s: any) => s.pr), t.explicit_order, t.prefixes[1].pr]).toEqual([['3343', '3337'], true, '3343']);
|
||||
}, 180_000);
|
||||
|
||||
test('refusals: a fork PR, a moved head, a PR without READY, and a train still filling (until --urgent)', () => {
|
||||
const fx = make();
|
||||
const h1 = openPr(fx, '1');
|
||||
@@ -557,4 +632,50 @@ describe('verify, freeze, repair and clear', () => {
|
||||
expect(moved.status).toBe(EXIT.fail);
|
||||
expect(moved.err).toMatch(new RegExp(`#2 moved past its pinned head ${h2.slice(0, 12)} and is not T2 modulo stamps \\(${stray.slice(0, 12)}: .*\\(QUEUE_TRAIN_MOVED\\)`));
|
||||
}, 300_000);
|
||||
|
||||
test("GSTA-67 (#3345 at 593e61c): a merged-in head whose CHANGELOG was resolved by hand verifies even where T<i>'s mechanical CHANGELOG differs; an invented line or a head without main is still refused", () => {
|
||||
const fx = make();
|
||||
const h1 = openPr(fx, '1', { changelog: false });
|
||||
const h2 = openPr(fx, '2', { changelog: cl => cl.replace('# Changelog\n', '# Changelog\n\n## [Unreleased]\n\n- #2 change\n').replace('## [1.2.2.0] - 2026-09-01\n\n', '') });
|
||||
readyReceipt(fx, '1', h1);
|
||||
readyReceipt(fx, '2', h2);
|
||||
fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; });
|
||||
const nonce = nonceOf(queue(fx, ['train', 'build', '--prs', `1@${h1},2@${h2}`]).out);
|
||||
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
|
||||
const t = trainJson(fx, nonce);
|
||||
expect(fx.repo.git('show', `${t.prefixes[2].commit}:CHANGELOG.md`)).not.toContain('## [1.2.2.0]');
|
||||
const coord = coordinatorClone(fx.repo);
|
||||
const v = () => queue(fx, ['verify', '--pr', '2', '--train', nonce], {}, coord);
|
||||
const main1 = publishMain(fx, t.prefixes[1].commit, '1.2.4.0');
|
||||
const { repo } = fx;
|
||||
const laneHead = (entry: string, parents: string[]) => {
|
||||
repo.git('reset', '-q', '--hard');
|
||||
repo.git('checkout', '-q', '-B', 'lane2', h2);
|
||||
repo.git('rm', '-rq', '.');
|
||||
repo.git('checkout', t.prefixes[2].commit, '--', '.');
|
||||
fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.5.0\n');
|
||||
fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), repo.git('show', `${main1}:CHANGELOG.md`).replace('# Changelog\n', `# Changelog\n\n## [1.2.5.0] - 2026-10-18\n\n${entry}\n`) + '\n');
|
||||
repo.git('add', '-A');
|
||||
const head = repo.git('commit-tree', repo.git('write-tree'), ...parents.flatMap(p => ['-p', p]), '-m', 'merge main into #2 (CHANGELOG by hand), stamp 1.2.5.0');
|
||||
repo.git('push', '-q', '-f', 'origin', `${head}:refs/pull/2/head`);
|
||||
return head;
|
||||
};
|
||||
|
||||
const byHand = laneHead('- #2 change', [h2, main1]);
|
||||
const ok = v();
|
||||
expect(ok.status).toBe(EXIT.ok);
|
||||
expect(ok.out).toContain(`head: ${byHand} (T2 with main merged in, same modulo stamps (1 stamp file(s)) except CHANGELOG.md, resolved as main's CHANGELOG.md plus #2's own entry)`);
|
||||
expect(line(ok.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${byHand}`);
|
||||
|
||||
const invented = laneHead('- #2 change\n- added after the gate', [h2, main1]);
|
||||
const refused = v();
|
||||
expect(refused.status).toBe(EXIT.fail);
|
||||
expect(refused.err).toContain(`#2 moved past its pinned head ${h2.slice(0, 12)} and is not T2 modulo stamps (${invented.slice(0, 12)}: differs from T2 in CHANGELOG.md, which is not main's plus #2's own entry: the PR's entry holds a line its pin never added: "- added after the gate"; against the pin it differs: `);
|
||||
expect(refused.err).toContain('(QUEUE_TRAIN_MOVED)');
|
||||
|
||||
const orphan = laneHead('- #2 change', [h2]);
|
||||
const noMain = v();
|
||||
expect(noMain.status).toBe(EXIT.fail);
|
||||
expect(noMain.err).toContain(`(${orphan.slice(0, 12)}: does not contain main ${main1.slice(0, 12)}; against the pin it differs: `);
|
||||
}, 300_000);
|
||||
});
|
||||
Reference in new issue
Block a user