v1.92.57.0 stamp: merge main f5cb6ec1eb into #3825 (land)

This commit is contained in:
garrytan committed 2026-11-02 04:50:52 +00:00
commit 0b43aaa113
71 files changed
+1940 -333

No files matched your search

+5 -3
View File
@@ -15,7 +15,7 @@ import { parseWorkflow } from '../lib/pregate/workflows';
const ROOT = path.resolve(import.meta.dir, '..');
const read = (rel: string) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
const FILES = { 'free-tests.yml': { aggregate: 'free-tests', profile: 'ci-reuse/free-tests', plan: 'free-plan', exempt: [] as string[] }, 'windows-free-tests.yml': { aggregate: 'windows-free-tests', profile: 'ci-reuse/windows-free-tests', plan: 'windows-plan', exempt: ['windows-record-durations'] } };
const FILES = { 'free-tests.yml': { aggregate: 'free-tests', profile: 'ci-reuse/free-tests', plan: 'free-plan', exempt: [] as string[], manual: false }, 'windows-free-tests.yml': { aggregate: 'windows-free-tests', profile: 'ci-reuse/windows-free-tests', plan: 'windows-plan', exempt: ['windows-record-durations'], manual: true } };
const REUSE_IF = "github.event_name == 'push' && github.ref == 'refs/heads/main'";
const SKIP = "steps.route.outputs.reused != 'true'";
const PROFILES = JSON.parse(read('.gstack/pregate.json')).run_profiles as Record<string, { workflow: string; jobs: string[]; artifact: string; events: string[]; os: string }>;
@@ -74,8 +74,10 @@ for (const [file, spec] of Object.entries(FILES)) {
for (const name of Object.keys(jobs).filter(downstream)) expect([name, jobs[name].if]).toEqual([name, expect.stringMatching(/!cancelled\(\)|always\(\)/)]);
});
test('pregate still sees every test lane as required (no condition reads inputs or workflow_dispatch)', () => {
for (const lane of parseWorkflow(file, source, 'linux').filter(l => l.runsTests && !spec.exempt.includes(l.job))) expect([lane.id, lane.manual]).toEqual([lane.id, false]);
test(spec.manual ? 'pregate reads every test lane as manual: the nightly Windows suite is never a PR obligation (windows-smoke.yml is)' : 'pregate still sees every test lane as required (no condition reads inputs or workflow_dispatch)', () => {
const lanes = parseWorkflow(file, source, 'linux').filter(l => l.runsTests && !spec.exempt.includes(l.job));
expect(lanes.length).toBeGreaterThan(0);
for (const lane of lanes) expect([lane.id, lane.manual]).toEqual([lane.id, spec.manual]);
});
test('the aggregate routes first, before any download; the strict path follows unchanged and alone uploads tested-tree after verification', () => {
+1
View File
@@ -174,6 +174,7 @@
{ "file": "lib/ship-queue-train-verify.ts", "movedFrom": "(new: verify --train, the queue freeze and its backstop, plan GSTA-49 T1)" },
{ "file": "lib/ship-queue-train-ready.ts", "movedFrom": "(new: ready --screen|--no-screen and the READY receipt, plan GSTA-49 T1)" },
{ "file": "lib/ship-queue-train-cli.ts", "movedFrom": "(new: merge-train argument parsing and output, plan GSTA-49 T1)" },
{ "file": "lib/ship-queue-land-lock.ts", "movedFrom": "lib/ship-queue-land.ts (the land lock, shared with train land, #3599)" },
{ "file": "lib/gate-cost.ts", "movedFrom": "(new: paid gate cost by model and token class, per-case costs, selection replays and the merge-train model, GSTA-49 T0)" },
{ "file": "scripts/gate-cost-replay.ts", "movedFrom": "(new: CLI behind lib/gate-cost.ts: cached report artifacts, merged-PR replay and the train model table, GSTA-49 T0)" },
{ "file": "lib/gate-cost-history.ts", "movedFrom": "(new: red attribution, red-head screen replay, READY times and the READY-arrival train replay, GSTA-49 T0b)" },
+1 -1
View File
@@ -82,7 +82,7 @@ test('the propose step touches only the two seeds, never the allowlist, on one b
expect(script).not.toContain('free-test-seed-allowlist.json');
expect(script).toContain('branch=bot/free-duration-seeds');
expect(script).toContain('bun test test/free-seed-ratchet.test.ts');
for (const workflow of ['free-tests.yml', 'windows-free-tests.yml', 'quality-gate.yml']) {
for (const workflow of ['free-tests.yml', 'windows-smoke.yml', 'quality-gate.yml']) {
expect(script).toContain(`gh workflow run ${workflow} --repo "$GITHUB_REPOSITORY" --ref "$branch"`);
expect(load(workflow).on).toHaveProperty('workflow_dispatch');
}
+98 -3
View File
@@ -8,6 +8,7 @@
* version-source shapes (package-manifest pin, monorepo pin, absent source).
*/
import { afterEach, 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 { EXIT } from '../lib/headless-artifacts';
@@ -163,7 +164,7 @@ describe('three PRs, reordered twice, zero manual edits', () => {
expect(changed.status).toBe(EXIT.fail);
expect(changed.out).toMatch(/^TREE: changed \(pr-b\.txt\)$/m);
expect(changed.out).toMatch(/^gate-reuse: not-eligible \(tree changed: pr-b\.txt\) — fix: .* \(GATE_REUSE_NOT_ELIGIBLE\)$/m);
});
}, 30_000);
test('--dry-run prints the full diff and writes nothing; --was overrides the mention source', () => {
const repo = fixture();
@@ -191,7 +192,7 @@ describe('three PRs, reordered twice, zero manual edits', () => {
const stamped = restamp(repo, ['--next']);
expect(stamped.status).toBe(EXIT.ok);
expect(stamped.out).toMatch(/^next: NEW_VERSION=1\.2\.4\.0; record the release decision, then write the CHANGELOG entry text only/m);
});
}, 30_000);
// Value: protects=a bump never repo's --next never invents a MICRO stamp: without --bump or --version it refuses and writes nothing, and an explicit level stamps as before; fails_when=restamp falls back to micro for a repo whose policy names no level;
// why_new=GST-380 added bump never; seam=gstack-ship-policy, gstack-version-bump write; no_claim=the claim queue read
@@ -454,7 +455,7 @@ describe('gate-ahead (--after), predecessor movement and queue staleness', () =>
const viaGh = runBin(repo, 'gstack-tree-receipt', ['--from', path.join(repo.work, '.gstack/tmp/gate-ahead.json'), '--predecessor-pr', '1', '--bundle', bundle, '--base', 'main', '--json'], ghShim(repo, { '1': { headRefOid: repo.git('rev-parse', 'pr-a') } }));
expect(JSON.parse(viaGh.out).predecessor.moved).toBe(true);
expect(JSON.parse(viaGh.out).code).toBe('RESTAMP_PREDECESSOR_MOVED');
});
}, 30_000);
test('a gate-ahead merge that conflicts outside the stamp files is RESTAMP_MERGE_CONFLICT with nothing written', () => {
const repo = fixture();
@@ -587,3 +588,97 @@ describe('version-source shapes', () => {
expect(fs.existsSync(path.join(repo.work, 'VERSION'))).toBe(false);
});
});
describe('gstack-restamp stages what it writes (#3531)', () => {
/** Branch b forks from main; branch a stamps and merges first, so b's base merge conflicts only on the CHANGELOG. */
function behindMain(): FixtureRepo {
const repo = fixture();
makePr(repo, 'pr-b');
makePr(repo, 'pr-a');
expect(restamp(repo, ['--next']).status).toBe(EXIT.ok);
repo.git('add', '-A');
repo.git('commit', '-q', '-m', 'stamp pr-a');
mergeToMain(repo, 'pr-a');
repo.git('fetch', '-q', 'origin');
repo.git('checkout', '-q', 'pr-b');
return repo;
}
const cached = (repo: FixtureRepo) => repo.git('diff', '--cached', '--name-only').split('\n').filter(Boolean);
const unstaged = (repo: FixtureRepo) => repo.git('diff', '--name-only').split('\n').filter(Boolean);
const filesOf = (out: string) => (/^FILES: (.*)$/m.exec(out)?.[1] ?? '').split(' ').filter(Boolean);
test('after --next on a staged no-commit base merge, every FILES entry is staged, STAGED: names them, and a bare commit carries the stamp', () => {
const repo = behindMain();
const merge = spawnSync('git', ['merge', '--no-ff', '--no-commit', '-q', 'origin/main'], { cwd: repo.work, encoding: 'utf8', timeout: 30_000, env: { ...process.env, ...repo.env } });
if (merge.status !== 0) {
fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), mergeChangelogs(repo.git('show', ':2:CHANGELOG.md') + '\n', repo.git('show', ':3:CHANGELOG.md') + '\n')!);
repo.git('add', 'CHANGELOG.md');
}
const r = restamp(repo, ['--next']);
expect(r.status, r.err).toBe(EXIT.ok);
const files = filesOf(r.out);
expect(files).toEqual(expect.arrayContaining(['VERSION', 'CHANGELOG.md']));
expect(cached(repo)).toEqual(expect.arrayContaining(files));
expect(unstaged(repo).filter(f => files.includes(f))).toEqual([]);
const staged = /^STAGED: (.*)$/m.exec(r.out)?.[1]?.split(' ') ?? [];
expect(staged).toEqual(expect.arrayContaining(files));
repo.git('commit', '-q', '--no-edit');
expect(repo.git('show', 'HEAD:VERSION')).toBe('1.2.5.0');
expect(repo.git('show', 'HEAD:CHANGELOG.md')).toMatch(/^# Changelog\n\n## \[1\.2\.5\.0\]/);
expect(fs.existsSync(path.join(repo.work, '.gstack/tmp/restamp-staging.json'))).toBe(false);
});
test('a stamp file with unstaged edits of its own refuses before writing (RESTAMP_UNSTAGED_EDITS); nothing written or staged', () => {
const repo = fixture();
makePr(repo, 'pr-c');
fs.appendFileSync(path.join(repo.work, 'CHANGELOG.md'), '\n- an edit the caller has not staged\n');
const r = restamp(repo, ['--next']);
expect(r.status).toBe(EXIT.refused);
expect(r.err).toMatch(/CHANGELOG\.md holds? unstaged edits of your own.*nothing written.*\(RESTAMP_UNSTAGED_EDITS\)/);
expect(readFile(repo, 'VERSION')).toBe('1.2.3.0\n');
expect(cached(repo)).toEqual([]);
});
test('a staging failure prints RESTAMP_NOT_STAGED with the files written and a marker; the noop rerun stages them', () => {
const repo = fixture();
makePr(repo, 'pr-d');
const lock = path.join(repo.work, '.git', 'index.lock');
fs.writeFileSync(lock, '');
const r = restamp(repo, ['--next']);
expect(r.status).toBe(EXIT.fail);
expect(r.err).toContain('(RESTAMP_NOT_STAGED)');
expect(r.out).not.toMatch(/^STAGED:/m);
expect(readFile(repo, 'VERSION')).toBe('1.2.4.0\n');
expect(cached(repo)).toEqual([]);
expect(fs.existsSync(path.join(repo.work, '.gstack/tmp/restamp-staging.json'))).toBe(true);
fs.rmSync(lock, { force: true });
const again = restamp(repo, ['--next']);
expect(again.status, again.err).toBe(EXIT.ok);
expect(again.out).toMatch(/^RESTAMP: .*\(noop;/m);
expect(cached(repo)).toEqual(expect.arrayContaining(['VERSION', 'CHANGELOG.md']));
expect(again.out).toMatch(/^STAGED: .*VERSION/m);
expect(fs.existsSync(path.join(repo.work, '.gstack/tmp/restamp-staging.json'))).toBe(false);
});
// fails_when=a staging marker left by a failure the lane then cleared by hand makes every later noop restamp exit 1 with RESTAMP_NOT_STAGED, whose fix (rerun) can never clear it (outside voice, PR #3737); why_new=the test above reruns on the same unchanged files; seam=.git/index.lock; no_claim=how the lane cleared the failure
test('a stale staging marker (its files changed since: a hand commit, then another stamped branch) is dropped by the noop rerun instead of failing it', () => {
const repo = fixture();
makePr(repo, 'pr-a');
expect(restamp(repo, ['--next']).status).toBe(EXIT.ok);
repo.git('commit', '-q', '-m', 'stamp pr-a');
makePr(repo, 'pr-d');
const lock = path.join(repo.work, '.git', 'index.lock');
fs.writeFileSync(lock, '');
expect(restamp(repo, ['--next']).err).toContain('(RESTAMP_NOT_STAGED)');
fs.rmSync(lock, { force: true });
repo.git('add', '-A');
repo.git('commit', '-q', '-m', 'stamp pr-d by hand');
repo.git('checkout', '-q', 'pr-a');
const noop = restamp(repo, ['--next']);
expect(noop.status, noop.err).toBe(EXIT.ok);
expect(noop.err).not.toContain('RESTAMP_NOT_STAGED');
expect(noop.out).toMatch(/^NOTE: .*CHANGELOG\.md.* changed since an earlier restamp wrote (it|them); not staged/m);
expect(fs.existsSync(path.join(repo.work, '.gstack/tmp/restamp-staging.json'))).toBe(false);
expect(restamp(repo, ['--next']).status).toBe(EXIT.ok);
});
});
+2 -2
View File
@@ -562,10 +562,10 @@ describe('ready on a PR that conflicts with the base (CHANGELOG-only conflicts,
const { r, at } = readyWith('CONFLICTING', { check_runs: [] });
expect(r.status).toBe(EXIT.refused);
expect(line(r.out, 'ci: ')).toBe(`ci: none at ${at} (passed=0 failed=0 pending=0 skipped=0)`);
expect(line(r.out, 'ci-hint: ')).toBe(`ci-hint: PR conflicts with the base (no merge ref, so pull_request CI did not run on ${at}); dispatch the free workflows on the branch (gh workflow run free-tests.yml --ref pr-a && gh workflow run windows-free-tests.yml --ref pr-a), clear the pregate lanes with their run URLs (gstack-pregate clear --remote <lane> --run-url <run>), then rerun ready (advisory)`);
expect(line(r.out, 'ci-hint: ')).toBe(`ci-hint: PR conflicts with the base (no merge ref, so pull_request CI did not run on ${at}); dispatch the free workflows on the branch (gh workflow run free-tests.yml --ref pr-a && gh workflow run windows-smoke.yml --ref pr-a), clear the pregate lanes with their run URLs (gstack-pregate clear --remote <lane> --run-url <run>), then rerun ready (advisory)`);
for (const name of ['x;$(curl evil)', '--field']) {
const odd = readyWith('CONFLICTING', { check_runs: [] }, { headRefName: name });
expect(line(odd.r.out, 'ci-hint: ')).toContain('gh workflow run free-tests.yml --ref <branch> && gh workflow run windows-free-tests.yml --ref <branch>');
expect(line(odd.r.out, 'ci-hint: ')).toContain('gh workflow run free-tests.yml --ref <branch> && gh workflow run windows-smoke.yml --ref <branch>');
}
const fork = readyWith('CONFLICTING', { check_runs: [] }, { isCrossRepository: true });
expect(line(fork.r.out, 'ci-hint: ')).toBe(`ci-hint: PR conflicts with the base (no merge ref, so pull_request CI did not run on ${fork.at}) and its head is on a fork, so the free workflows cannot be dispatched here; the coordinator decides (advisory)`);
+36 -1
View File
@@ -1,7 +1,12 @@
/** Train setups shared by the test/ship-queue-train*.test.ts files (moved from test/ship-queue-train.test.ts, GSTA-151). */
import { expect } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { GREEN_GATE, nonceOf, openPr, queue, readyReceipt, type Rule, type TrainFixture } from './ship-queue-train-fixture';
import { mergeChangelogs } from '../../lib/changelog-check';
import { EXIT } from '../../lib/headless-artifacts';
import { runBin } from './restamp-fixture';
import { GREEN_GATE, line, nonceOf, openPr, queue, readyReceipt, type Rule, type TrainFixture } from './ship-queue-train-fixture';
export function twoPrTrain(fx: TrainFixture, gate: Rule = GREEN_GATE) {
const h1 = openPr(fx, '1');
@@ -27,3 +32,33 @@ export function publishMain(fx: TrainFixture, prefixCommit: string, version: str
repo.git('fetch', '-q', 'origin');
return repo.git('rev-parse', 'HEAD');
}
/** The lane's slot stamp on its pin (main merged in first from slot 2 on): gstack-restamp --next, then the stamp files committed and pushed as the PR head. */
export function laneStamp(fx: TrainFixture, pr: string, pin: string, o: { mergeMain?: boolean; dropStamp?: boolean } = {}): string {
const { repo } = fx;
repo.git('reset', '-q', '--hard');
repo.git('fetch', '-q', 'origin');
repo.git('checkout', '-q', '-B', `lane${pr}`, pin);
if (o.mergeMain) {
const m = spawnSync('git', ['merge', '--no-ff', '--no-commit', '-q', 'origin/main'], { cwd: repo.work, encoding: 'utf8', timeout: 30_000, env: { ...process.env, ...repo.env } });
if (m.status !== 0) {
const side = (n: number) => repo.git('show', `:${n}:CHANGELOG.md`) + '\n';
fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), mergeChangelogs(side(2), side(3), side(1))!);
repo.git('add', 'CHANGELOG.md');
}
}
const index = repo.git('write-tree');
const stamp = runBin(repo, 'gstack-restamp', ['--next', '--base', 'main', '--allow-repo-commands'], fx.env);
expect(stamp.status, stamp.err).toBe(EXIT.ok);
const files = line(stamp.out, 'FILES:').slice('FILES: '.length).split(' ').filter(Boolean);
if (o.dropStamp) repo.git('read-tree', index);
else repo.git('add', '-A', '--', ...files);
repo.git('commit', '-q', '--no-edit', '-m', `slot stamp #${pr}`);
if (o.dropStamp) repo.git('checkout', '-q', '--', '.');
const head = repo.git('rev-parse', 'HEAD');
repo.git('push', '-q', '-f', 'origin', `${head}:refs/pull/${pr}/head`);
return head;
}
/** GitHub reports the PR MERGED, which verify --train needs before it confirms a merge (#3531). */
export const markMerged = (fx: TrainFixture, pr: string) => fx.write(d => { (d.prs[pr] as any).state = 'MERGED'; });
+35
View File
@@ -147,6 +147,41 @@ export function trainJson(fx: TrainFixture, nonce: string, state = fx.state): an
}
export const dispatches = (fx: TrainFixture, workflow?: string) => fx.read().log.filter(l => l.cmd === 'workflow run' && (!workflow || l.workflow === workflow));
/** For train land and land: pr view reads the head from origin's branch; pr edit and pr merge act on origin; any commit without recorded CI reads green. */
export function wrapLand(fx: TrainFixture): void {
const dir = path.join(fx.repo.root, 'gh-land-wrap');
fs.mkdirSync(dir, { recursive: true });
const shim = path.join(fx.repo.root, 'gh-train-shim', 'shim.js');
fs.writeFileSync(path.join(dir, 'gh'), [
'#!/usr/bin/env node',
"const fs=require('fs');const cp=require('child_process');const D=process.env.LAND_WRAP_DATA;const a=process.argv.slice(2);",
"const d=JSON.parse(fs.readFileSync(D,'utf8'));const save=()=>fs.writeFileSync(D,JSON.stringify(d));",
"const g=(...x)=>cp.execFileSync('git',['--git-dir',d.origin,...x],{encoding:'utf8',timeout:30000}).trim();",
"const rev=r=>{try{return g('rev-parse','--verify','--quiet',r)}catch{return ''}};",
"if(a[0]==='pr'&&a[1]==='view'&&d.prs[a[2]]){const p=d.prs[a[2]];const h=rev('refs/heads/'+p.headRefName);if(h)p.headRefOid=h;save();console.log(JSON.stringify(p));process.exit(0)}",
"if(a[0]==='pr'&&a[1]==='edit'){(d.labels=d.labels||{})[a[2]]=a[a.indexOf('--add-label')+1];save();process.exit(0)}",
"if(a[0]==='pr'&&a[1]==='merge'){const p=d.prs[a[2]];const want=a[a.indexOf('--match-head-commit')+1];if(rev('refs/heads/'+p.headRefName)!==want){console.error('Head branch was modified');process.exit(1)}",
" const before=rev('main');let tree=rev(want+'^{tree}');",
" if(d.outsideOnMerge){const blob=cp.execFileSync('git',['--git-dir',d.origin,'hash-object','-w','--stdin'],{input:'outside\\n',encoding:'utf8',timeout:30000}).trim();",
" const ls=g('ls-tree',before+'^{tree}')+'\\n100644 blob '+blob+'\\toutside.txt';const ot=cp.execFileSync('git',['--git-dir',d.origin,'mktree'],{input:ls+'\\n',encoding:'utf8',timeout:30000}).trim();",
" g('update-ref','refs/heads/main',g('commit-tree',ot,'-p',before,'-m','outside'));tree=g('merge-tree','--write-tree','--merge-base='+before,rev('main'),want).split('\\n')[0];d.outsideOnMerge=false}",
" g('update-ref','refs/heads/main',g('commit-tree',tree,'-p',rev('main'),'-m','squash #'+a[2]));p.state='MERGED';save();process.exit(0)}",
"const paged=/commits\\/([0-9a-f]+)\\/(check-runs|status)\\?.*page=(\\d+)/.exec(a[1]||'');",
"if(a[0]==='api'&&paged&&!(d.ci||{})[paged[1]]&&!((d.statuses||{})[paged[1]])){const list=paged[2]==='check-runs'?[{name:'free-tests',status:'completed',conclusion:'success',id:1,completed_at:'2026-10-26T00:00:00Z'}]:[];const key=paged[2]==='check-runs'?'check_runs':'statuses';console.log(JSON.stringify(paged[3]==='1'?{total_count:list.length,state:'success',[key]:list}:{total_count:list.length,[key]:[]}));process.exit(0)}",
`const r=cp.spawnSync('node',[${JSON.stringify(shim)},D,...a],{stdio:'inherit',timeout:120000});process.exit(r.status??1);`,
'',
].join('\n'), { mode: 0o755 });
fx.env.PATH = `${dir}:${fx.env.PATH}`;
(fx.env as Record<string, string>).LAND_WRAP_DATA = fx.dataFile;
}
/** A changelog_fragments PR: a fragment under changelog.d/, pushed as pr-<n>, with a READY receipt. */
export function fragmentPr(fx: TrainFixture, pr: string, fragment = `- #${pr} change\n`): string {
const head = openPr(fx, pr, { file: `changelog.d/pr-${pr}.md`, text: fragment, changelog: false });
fx.repo.git('push', '-q', 'origin', `${head}:refs/heads/pr-${pr}`);
readyReceipt(fx, pr, head);
return head;
}
function shimSource(): string {
return [
"const fs = require('fs'); const path = require('path'); const cp = require('child_process');",
+33 -9
View File
@@ -8,7 +8,7 @@ import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { promptHash, sectionTexts, withWorkspace } from '../lib/review-brief';
import { CHUNK_CHARS, buildManifest, chunkCount, chunkOf, markReader, renderStage, specialistFindings, type Manifest } from '../lib/review-pass';
import { CHUNK_CHARS, PARENT_ONLY_QA, buildManifest, chunkCount, chunkOf, markReader, renderStage, specialistFindings, type Manifest } from '../lib/review-pass';
import type { Dispatch } from '../lib/review-depth';
const ROOT = path.resolve(import.meta.dir, '..');
@@ -87,6 +87,13 @@ describe('pass manifest', () => {
expect(m.readers[0]).toMatchObject({ state: 'created', path: '4' });
});
// Value: protects=an unreadable extra prompt stops as a BriefError naming the reader; fails_when=a directory prompt_file throws a raw
// EISDIR from the QA guard's read (GST-450 adversarial #4); seam=none
test('a directory extra prompt stops as an unreadable prompt file, not a raw read error', () => {
const d = manifest([spec('testing')], { extras: [{ name: 'plan', prompt_file: tmp('rp-qa-') }] });
expect(() => renderStage(d, 'specialists', texts, () => '')).toThrow(/cannot read audit\/plan's prompt file/);
});
test('a brief over 1 MiB with invalid UTF-8 in its diff travels in bounded chunks that rejoin to the rendered sha', () => {
const m = manifest([{ role: 'outside-voice', name: 'outside-voice', effort: 'host', exclude: [] }]);
const diff = Buffer.concat([Buffer.from('+line \u00e9\n'.repeat(90_000)), Buffer.from([0xff, 0xfe, 0x0a]), Buffer.from('+"quoted" \\ back\n'.repeat(20_000))]).toString('utf8');
@@ -504,16 +511,33 @@ describe('Capy pass program (fake host, real bins)', () => {
const elsewhere = tmp('rp-cwd-');
const out = path.join(dir, '.state/run/p1');
fs.mkdirSync(out, { recursive: true });
fs.writeFileSync(path.join(out, 'qa.prompt.md'), 'QA brief');
fs.writeFileSync(path.join(out, 'plan.prompt.md'), 'Plan completion brief');
const h = host(elsewhere, env, { lateSummary: true });
const res = await runPass(h, { base: 'main', out, pass: 'x13p1', cwd: dir, planFlags: ['--depth-floor', 'full'],
extras: [{ name: 'qa', prompt_file: path.join(out, 'qa.prompt.md'), model: { model_id: 'anthropic/claude-haiku-5-5', reasoning_mode: 'low' } }] });
extras: [{ name: 'plan', prompt_file: path.join(out, 'plan.prompt.md'), model: { model_id: 'anthropic/claude-haiku-5-5', reasoning_mode: 'low' } }] });
expect(res).not.toContain('STOP:');
expect(res).toContain('NEXT: judge (Phase 4)');
expect(h.creates.find(c => c.title === 'ship x13p1 audit/qa')!.prompt).toBe(withWorkspace('QA brief', fs.realpathSync(dir)));
expect(h.creates.find(c => c.title === 'ship x13p1 audit/plan')!.prompt).toBe(withWorkspace('Plan completion brief', fs.realpathSync(dir)));
}, 180_000);
// Value: protects=Step 9.2.1's probes stay the parent's, as its section says, so QA coverage is never a subagent's refusal; fails_when=the
// pass program dispatches a QA extra (C4-36, C5-36: the QA reader refused both passes, "parent-only", and the parent ran the probes after it);
// why_new=extras took any prompt (GST-450); seam=the pass program; no_claim=the parent's probes themselves (DRIVER Phase 4)
test('a QA extra, by name or by its section\'s parent-only brief, stops the pass before any reader is created', async () => {
const section = fs.readFileSync(path.join(ROOT, 'ship/sections/review-army.md'), 'utf8');
expect(section).toContain(PARENT_ONLY_QA);
const brief = section.slice(section.indexOf('### Step 9.2.1'), section.indexOf('### Step 9.3'));
for (const [name, text] of [['qa', 'QA brief'], ['exploratory', brief]] as const) {
const { dir, env } = repo();
const out = path.join(dir, '.state/run/p1');
fs.mkdirSync(out, { recursive: true });
fs.writeFileSync(path.join(out, name + '.prompt.md'), text);
const h = host(dir, env);
const res = await runPass(h, { base: 'main', out, pass: 'x14p1', planFlags: ['--depth-floor', 'full'],
extras: [{ name, prompt_file: path.join(out, name + '.prompt.md'), model: { model_id: 'anthropic/claude-haiku-5-5', reasoning_mode: 'low' } }] });
expect([name, res.includes('STOP:'), res.includes("Step 9.2.1 is the parent's report-only discovery"), h.creates.length]).toEqual([name, true, true, 0]);
}
}, 240_000);
test('decision 3.5 runs in the program: one fix-check reader whose prompt is the render CLI brief byte for byte, sent and collected', async () => {
const { dir, env } = repo();
@@ -704,8 +728,8 @@ describe('a scoped pass after a blocker fix (GST-619)', () => {
const out = path.join(dir, '.state/run/p2');
const extras = path.join(dir, '.state/extras.json');
fs.mkdirSync(path.join(dir, '.state'), { recursive: true });
fs.writeFileSync(path.join(dir, '.state/qa.md'), 'QA the fix.\n');
fs.writeFileSync(extras, JSON.stringify([{ name: 'qa', prompt_file: path.join(dir, '.state/qa.md') }]));
fs.writeFileSync(path.join(dir, '.state/plan.md'), 'Check the plan items against the fix.\n');
fs.writeFileSync(extras, JSON.stringify([{ name: 'plan', prompt_file: path.join(dir, '.state/plan.md') }]));
const args = ['pass', '--base', 'main', '--out', out, '--pass', 'p2', '--extras', extras, '--', '--pass', '1', '--last', 'full', '--pass1-depth', 'standard', '--fixed-blocking', ...flags, '--range-base', rangeBase.slice(0, 7)];
const p = bin(dir, env, args);
expect(p.status, p.stdout + p.stderr).toBe(0);
@@ -727,7 +751,7 @@ describe('a scoped pass after a blocker fix (GST-619)', () => {
const s = scoped([]);
expect(s.m.next).toBe('scoped');
expect(s.m.diff_base).toBe(s.mergeBase);
expect(s.m.readers.map(r => [r.reader, r.diff_base ?? null])).toEqual([['red-team', s.rangeBase], ['adversarial-native', null], ['outside-voice', null], ['audit/qa', null]]);
expect(s.m.readers.map(r => [r.reader, r.diff_base ?? null])).toEqual([['red-team', s.rangeBase], ['adversarial-native', null], ['outside-voice', null], ['audit/plan', null]]);
settle(s, 'specialists');
const m = settle(s, 'red-team');
const redTeam = fs.readFileSync(m.readers.find(r => r.reader === 'red-team')!.file!, 'utf8');
@@ -745,7 +769,7 @@ describe('a scoped pass after a blocker fix (GST-619)', () => {
test('after Step 11 ran (--adversarial-ran), the whole scoped pass reads the fix range', () => {
const s = scoped(['--adversarial-ran']);
expect(s.m.diff_base).toBe(s.rangeBase);
expect(s.m.readers.map(r => [r.reader, r.diff_base ?? null])).toEqual([['red-team', s.rangeBase], ['adversarial-native', null], ['audit/qa', null]]);
expect(s.m.readers.map(r => [r.reader, r.diff_base ?? null])).toEqual([['red-team', s.rangeBase], ['adversarial-native', null], ['audit/plan', null]]);
}, 120_000);
});
-2
View File
@@ -34,8 +34,6 @@ const KNOWN_SITES: Record<string, string> = {
'bin/gstack-issue-links': 'outside this wave: the gstack-issue-guard spawn moves to runScriptSync',
// Found when the bin-constant rule was added (fix wave PR 5, item 10b).
'bin/gstack-brain-cache': 'outside this wave: the gstack-config salience read moves to runScriptSync',
// Found when the scanner learned the import.meta.url bin path (#3541).
'bin/gstack-ship-receipt': 'the gstack-session-kind spawn moves to runScriptSync in the #3541 ship-receipt follow-up',
'lib/autoplan-admission.ts': 'returns before the spawn on win32; the bin/gstack-lease touch moves to runScriptSync with that bin\'s owner',
};
+23 -2
View File
@@ -219,16 +219,37 @@ describe('variant G: the call plan, observe budget, waits and audit readers (GST
expect(flat(read('ship/sections/budget-handoff.md.tmpl'))).toContain('the wait until the last settles (`done`, `failed` or a final summary; a fresh `idle` has not run)');
});
// Value: protects=audits that cannot block (Steps 7, 8, 9.2.1, 14.5) run on a small model at low effort with their sections' own briefs, and skip their reader when --audit decides them; fails_when=an audit reader inherits the lane's model and effort (#36: the coverage audit alone was 2.45M at Opus), or an audit brief is paraphrased;
// Value: protects=audits that cannot block (Steps 7, 8, 14.5) run on a small model at low effort with their sections' own briefs, and skip their reader when --audit decides them; fails_when=an audit reader inherits the lane's model and effort (#36: the coverage audit alone was 2.45M at Opus), or an audit brief is paraphrased;
// why_new=audit model routing is new; seam=none; no_claim=the audits' recall
test('audit readers run on a small model at low effort, named for Capy, and Phase 3 dispatches only what READER names', () => {
expect(flat(doc)).toContain('Audit readers (Steps 7, 8, 9.2.1, 14.5) cannot block, so each runs on a small model at low effort; their findings go to the PR body');
expect(flat(doc)).toContain('Audit readers (Steps 7, 8, 14.5) cannot block, so each runs on a small model at low effort; their findings go to the PR body');
expect(flat(doc)).toContain('`Coverage:` and Step 8\'s line come from `--audit` when no reader ran');
const capy = read('capy/skill/gstack/SKILL.md');
expect(capy).toContain('model: { model_id: "anthropic/claude-sonnet-5-5", reasoning_mode: "low" }');
expect(capy).toContain('`anthropic/claude-haiku-5-5` also fits');
});
// Value: protects=Step 9.2.1's report-only probes run in the parent's own Phase 4 program, as the section says, with its Reads in Phase 3, and
// their result line reaches the record, at no extra call; fails_when=the driver or the Capy skill sends Step 9.2.1 out as a QA reader
// (C4-36, C5-36: it refused both passes, "parent-only", and QA coverage was empty), the probes leave the record, or they take their own call;
// why_new=#3648 made Step 9.2.1 a reader (GST-450); seam=none; no_claim=the probes' recall
test('Step 9.2.1 is the parent\'s: Reads in Phase 3, probes in Phase 4\'s one program, Exploratory QA in the record, never an extra', () => {
const f = flat(doc);
const phase = (n: number) => f.slice(f.indexOf(`## ${n}. `), f.indexOf(`## ${n + 1}. `));
expect(phase(3)).not.toMatch(/QA reader/);
expect(phase(3)).toContain("9.2.1's Reads only");
expect(phase(4)).toContain("Step 8.1, 9.2.1's probes, 8.2 (`Scope Check:`)");
const at = ['Step 8.1', "9.2.1's probes", '8.2 (`Scope Check:`)', 'Steps 9.3, 9.4 and the repeat decision'].map(s => phase(4).indexOf(s));
expect(at[0]).toBeGreaterThanOrEqual(0);
expect(at).toEqual([...at].sort((a, b) => a - b));
expect(phase(4)).toContain('one program');
expect(f).toContain('the plan checks, `Exploratory QA:`, `Scope Check:`), **Decisions**');
const capy = flat(read('capy/skill/gstack/SKILL.md'));
expect(capy).toContain('Steps 7, 8 and 14.5 (`step: "14.5"`) use `extras`');
expect(capy).not.toMatch(/9\.2\.1[^.]*`extras`/);
expect(flat(read('ship/sections/review-army.md'))).toContain('### Step 9.2.1: Exploratory QA (before Fix-First) Only the parent runs report-only discovery.');
});
// Value: protects=a driven /ship dispatches every blocking reader on its rendered brief file unchanged, checks it with `sent`, merges findings only through `collect`, gates judgment with `judge` and verifies the run before the bind; fails_when=the driver lets the model write or trim a brief again or merge findings in prose (round 4b: arm D lost 8 blockers with 75 of 112 briefs paraphrased);
// why_new=the driver named no renderer or hash check; seam=none; no_claim=the parent's adherence, which verify checks after the fact
test('briefs go out as rendered and findings come back unchanged: render, sent, collect, judge, verify', () => {
+368
View File
@@ -0,0 +1,368 @@
/**
* The shared land lock (lib/ship-queue-land-lock.ts, #3599): `land` and `train land` take the same
* refs/notes/gstack-land lock, so a standalone land and a train slot can never both publish onto one main.
* Module cases run against a bare origin and two clones (the race, the read states, owner kinds, the
* --break-lock rules); command cases drive bin/gstack-ship-queue against the train fixture and its gh
* shims (both directions, the train-scoped between phase, refusal and resume, the freeze-owned lock and the
* repair train's takeover).
*/
import { afterEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT } from '../lib/headless-artifacts';
import { acquireLandLock, LAND_LOCK_REF, readLock, writeLock, type LandLock, type LockOwner } from '../lib/ship-queue-land-lock';
import { BASE_POLICY, cleanup, makeFixtureRepo, type FixtureRepo } from './helpers/restamp-fixture';
import { coordinatorClone } from './helpers/ship-queue-fixture';
import {
GREEN_GATE, TRAIN_POLICY, fragmentPr, nonceOf, queue, repoKey, trainFixture, trainJson, wrapLand, type TrainFixture,
} from './helpers/ship-queue-train-fixture';
const repos: FixtureRepo[] = [];
const fixtures: TrainFixture[] = [];
afterEach(() => {
while (repos.length) cleanup(repos.pop()!);
while (fixtures.length) cleanup(fixtures.pop()!.repo);
});
const T0 = Date.parse('2026-10-31T12:00:00Z');
const at = (min: number) => () => new Date(T0 + min * 60_000);
/** Run fn with the fixture's git identity in process.env (the lock writes commits in-process). */
function withRepoEnv<T>(repo: FixtureRepo, fn: () => T): T {
const saved = Object.fromEntries(Object.keys(repo.env).map(k => [k, process.env[k]]));
Object.assign(process.env, repo.env);
try { return fn(); } finally {
for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; }
}
}
function bareRepo(): FixtureRepo { const r = makeFixtureRepo({ policy: BASE_POLICY, prefix: 'gstack-land-lock-' }); repos.push(r); return r; }
const standalone = (pr: string): LockOwner => ({ pr });
const slotOf = (train: string, slot: number, pr: string): LockOwner => ({ train, slot, pr });
function take(root: string, owner: LockOwner, o: { journal?: string; now?: () => Date; breakLock?: boolean; breakTrainLock?: boolean; liveTrain?: (nonce: string) => boolean; phase?: LandLock['phase'] } = {}) {
return acquireLandLock({
root, holder: 'test:1', owner, journal: o.journal ?? ('train' in owner && owner.train ? `train/${owner.train}/slot-${owner.slot}` : `land/${owner.pr}`),
phase: () => o.phase ?? 'stamping', now: o.now ?? at(0), breakLock: o.breakLock, breakTrainLock: o.breakTrainLock, liveTrain: o.liveTrain,
});
}
const failureOf = (r: ReturnType<typeof take>) => ('failure' in r ? r.failure : null);
describe('the lock module', () => {
test('two writers racing the same lock sha leave one winner', () => {
const repo = bareRepo();
const other = coordinatorClone(repo);
withRepoEnv(repo, () => {
const lock = (pr: string): LandLock => ({ pr, holder: `h${pr}`, started_at: new Date(T0).toISOString(), phase: 'stamping', journal: `land/${pr}` });
expect(readLock(repo.work).state).toBe('absent');
const first = writeLock(repo.work, '', lock('1'));
const second = writeLock(other, '', lock('2'));
expect([first.ok, second.ok]).toEqual([true, false]);
const seen = readLock(other);
expect(seen.state === 'held' && seen.lock.pr).toBe('1');
const a = writeLock(repo.work, seen.sha, lock('3'));
const b = writeLock(other, seen.sha, lock('4'));
expect([a.ok, b.ok]).toEqual([true, false]);
const final = readLock(other);
expect(final.state === 'held' && final.lock.pr).toBe('3');
});
}, 60_000);
test('absent, released, held and unreadable are distinct; an unreadable lock refuses, and --break-lock replaces it', () => {
const repo = bareRepo();
withRepoEnv(repo, () => {
const root = repo.work;
expect(readLock(root)).toMatchObject({ state: 'absent' });
const got = take(root, standalone('5'));
expect(failureOf(got)).toBeNull();
expect(readLock(root)).toMatchObject({ state: 'held', lock: { pr: '5', phase: 'stamping' } });
if ('held' in got) expect(got.held.release()).toBe(true);
expect(readLock(root)).toMatchObject({ state: 'released' });
const junk = repo.git('commit-tree', '4b825dc642cb6eb9a060e54bf8d69288fbee4904', '-m', 'not a lock');
repo.git('push', '-q', '-f', 'origin', `${junk}:${LAND_LOCK_REF}`);
expect(readLock(root)).toMatchObject({ state: 'unreadable' });
const refused = take(root, standalone('6'));
expect(failureOf(refused)).toMatchObject({ code: 'QUEUE_LAND_LOCKED' });
expect(failureOf(refused)!.message).toContain('cannot be read');
const broke = take(root, standalone('6'), { breakLock: true });
expect(failureOf(broke)).toBeNull();
expect(broke.lines.join('\n')).toContain(`LOCK: broke an unreadable lock at ${junk.slice(0, 12)}`);
expect(readLock(root)).toMatchObject({ state: 'held', lock: { pr: '6' } });
});
}, 60_000);
test('ownership compares the owner kind and journal: the same PR across commands and across trains refuses; a resume of the same journal and a sequential acquisition succeed', () => {
const repo = bareRepo();
withRepoEnv(repo, () => {
const root = repo.work;
const mine = take(root, standalone('7'), { phase: 'waiting' });
expect(failureOf(mine)).toBeNull();
const asSlot = take(root, slotOf('trainA', 1, '7'));
expect(failureOf(asSlot)).toMatchObject({ code: 'QUEUE_LAND_LOCKED' });
expect(failureOf(asSlot)!.message).toMatch(/^#7 holds the land lock \(test:1, phase waiting, since /);
const resumed = take(root, standalone('7'), { now: at(3) });
expect(failureOf(resumed)).toBeNull();
expect(readLock(root)).toMatchObject({ state: 'held', lock: { pr: '7', started_at: new Date(T0).toISOString() } });
if ('held' in resumed) resumed.held.release();
const trainA = take(root, slotOf('trainA', 1, '7'), { phase: 'pushing' });
expect(failureOf(trainA)).toBeNull();
expect(readLock(root)).toMatchObject({ state: 'held', lock: { train: 'trainA', slot: 1, pr: '7' } });
const trainB = take(root, slotOf('trainB', 1, '7'));
expect(failureOf(trainB)!.message).toMatch(/^train trainA slot 1 \(#7\) holds the land lock/);
expect(failureOf(take(root, standalone('7')))!.message).toMatch(/^train trainA slot 1 \(#7\) holds the land lock/);
expect(failureOf(take(root, slotOf('trainA', 2, '8')))!.message).toMatch(/^train trainA slot 1 \(#7\) holds the land lock/);
const same = take(root, slotOf('trainA', 1, '7'));
expect(failureOf(same)).toBeNull();
if ('held' in same) same.held.release();
expect(failureOf(take(root, standalone('8')))).toBeNull();
});
}, 60_000);
test('--break-lock prints the holder, refuses a live train run\'s lock without --break-train-lock, and keeps the staleness, mid-push, between and freeze rules', () => {
const repo = bareRepo();
withRepoEnv(repo, () => {
const root = repo.work;
const hold = (owner: LockOwner, phase: LandLock['phase']) => {
const r = readLock(root);
if (r.state === 'held') writeLock(root, r.sha, { released: true, by: 'test', at: new Date(T0).toISOString() });
expect(failureOf(take(root, owner, { phase }))).toBeNull();
};
hold(slotOf('trainA', 2, '9'), 'waiting');
const live = take(root, standalone('3'), { breakLock: true, now: at(1) });
expect(failureOf(live)).toMatchObject({ code: 'QUEUE_LAND_LOCKED' });
expect(failureOf(live)!.message).toContain('--break-train-lock');
expect(live.lines).toContain(`LOCK_HOLDER: train trainA slot 2 (#9) holder=test:1 phase=waiting since=${new Date(T0).toISOString()}`);
const ended = take(root, standalone('3'), { breakLock: true, now: at(1), liveTrain: () => false });
expect(failureOf(ended)).toBeNull();
expect(ended.lines).toContain("LOCK: broke train trainA slot 2 (#9)'s lock (test:1, phase waiting)");
hold(slotOf('trainA', 2, '9'), 'waiting');
const forced = take(root, standalone('3'), { breakLock: true, breakTrainLock: true, now: at(1) });
expect(failureOf(forced)).toBeNull();
hold(slotOf('trainA', 1, '9'), 'between');
const young = take(root, standalone('3'), { breakLock: true, breakTrainLock: true, now: at(1) });
expect(failureOf(young)!.message).toMatch(/train trainA slot 1 \(#9\) is between slots \(60 s ago\); --break-lock refused/);
expect(failureOf(take(root, standalone('3'), { breakLock: true, breakTrainLock: true, now: at(11) }))).toBeNull();
hold(slotOf('trainA', 1, '9'), 'pushing');
expect(failureOf(take(root, standalone('3'), { breakLock: true, breakTrainLock: true, now: at(1) }))!.message).toMatch(/train trainA slot 1 \(#9\)'s holder is mid-push \(60 s ago\); --break-lock refused/);
hold(slotOf('trainA', 1, '9'), 'freeze');
const frozen = take(root, standalone('3'), { breakLock: true, breakTrainLock: true, now: at(60) });
expect(failureOf(frozen)).toMatchObject({ code: 'QUEUE_TRAIN_FROZEN' });
expect(failureOf(take(root, slotOf('trainB', 1, '4'), { breakLock: true, breakTrainLock: true, now: at(60) }))).toMatchObject({ code: 'QUEUE_TRAIN_FROZEN' });
hold(standalone('5'), 'waiting');
const plain = take(root, standalone('3'), { breakLock: true, now: at(1) });
expect(plain.lines).toContain(`LOCK_HOLDER: #5 holder=test:1 phase=waiting since=${new Date(T0).toISOString()}`);
expect(plain.lines).toContain("LOCK: broke #5's lock (test:1, phase waiting)");
});
}, 60_000);
});
const POLICY = { ...TRAIN_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.91.336.0' };
function stampedTrain(prs = ['1', '2']) {
const fx = trainFixture(POLICY, 'gstack-land-lock-'); fixtures.push(fx); wrapLand(fx);
const heads = prs.map(pr => fragmentPr(fx, pr));
fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; });
const nonce = nonceOf(queue(fx, ['train', 'build', '--prs', prs.map((pr, i) => `${pr}@${heads[i]}`).join(',')]).out);
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
return { fx, heads, nonce };
}
const trainLand = (fx: TrainFixture, nonce: string, pr: string, extra: string[] = []) => queue(fx, ['train', 'land', '--train', nonce, '--pr', pr, '--no-wait', ...extra]);
const land = (fx: TrainFixture, pr: string, extra: string[] = []) => queue(fx, ['land', '--pr', pr, '--no-wait', '--allow-repo-commands', ...extra]);
const originRev = (fx: TrainFixture, ref: string) => fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', '--verify', '-q', ref);
/** The lock as origin holds it: the parsed lock.json, or null when origin has no lock ref. */
function originLock(fx: TrainFixture): any {
const sha = (() => { try { return originRev(fx, LAND_LOCK_REF); } catch { return ''; } })();
return sha ? JSON.parse(fx.repo.git('--git-dir', fx.repo.origin, 'show', `${sha}:lock.json`)) : null;
}
const freezeFile = (fx: TrainFixture) => path.join(fx.state, 'ship-queue', 'freeze');
describe('land and train land share the lock', () => {
test('a train slot holding the lock refuses a standalone land (no takeover, no freeze; --break-lock names the train and wants --break-train-lock); the train keeps it between slots and releases it after the last', () => {
const { fx, heads, nonce } = stampedTrain();
const pin3 = fragmentPr(fx, '3');
const pushed = trainLand(fx, nonce, '1');
expect(pushed.out, pushed.err).toMatch(/^PUSHED: [0-9a-f]{40}/m);
expect(originLock(fx)).toMatchObject({ train: nonce, slot: 1, pr: '1', phase: 'waiting' });
const lockSha = originRev(fx, LAND_LOCK_REF);
const blocked = land(fx, '3');
expect(blocked.status).toBe(EXIT.refused);
expect(blocked.err).toMatch(new RegExp(`train ${nonce} slot 1 \\(#1\\) holds the land lock .*phase waiting.*\\(QUEUE_LAND_LOCKED\\)`));
const broke = land(fx, '3', ['--break-lock']);
expect(broke.status).toBe(EXIT.refused);
expect(broke.err).toContain('--break-train-lock');
expect(broke.out).toMatch(new RegExp(`^LOCK_HOLDER: train ${nonce} slot 1 \\(#1\\) holder=\\S+ phase=waiting since=`, 'm'));
expect(originRev(fx, LAND_LOCK_REF)).toBe(lockSha);
expect(originRev(fx, 'refs/heads/pr-3')).toBe(pin3);
expect(fs.existsSync(freezeFile(fx))).toBe(false);
const merged = trainLand(fx, nonce, '1');
expect(merged.status, merged.err).toBe(EXIT.ok);
expect(originLock(fx)).toMatchObject({ train: nonce, slot: 1, pr: '1', phase: 'between' });
expect(land(fx, '3').err).toMatch(new RegExp(`train ${nonce} slot 1 \\(#1\\) holds the land lock .*phase between.*\\(QUEUE_LAND_LOCKED\\)`));
expect(originRev(fx, 'refs/heads/pr-3')).toBe(pin3);
const second = trainLand(fx, nonce, '2');
expect(second.out, second.err).toMatch(/^LOCK: train \S+ slot 2 takes over its train's between-slots lock/m);
expect(originLock(fx)).toMatchObject({ train: nonce, slot: 2, pr: '2', phase: 'waiting' });
expect(trainLand(fx, nonce, '2').status).toBe(EXIT.ok);
expect(originLock(fx)).toMatchObject({ released: true });
expect(trainJson(fx, nonce).state).toBe('merged');
expect(land(fx, '3').out).toMatch(/^PUSHED: [0-9a-f]{40}/m);
void heads;
}, 300_000);
// fails_when=settleLock releases the lock on a refusal partway through a train, so one transient gh or fetch failure on slot 2 lets a standalone land publish onto S1 and the rerun freezes the queue (red team, PR #3737 pass 1); why_new=the test above refuses slot 1 only, before anything merged; seam=the fixture's gh pr view; no_claim=the freeze itself
test('a refusal partway through a train (slot 1 merged, slot 2 cannot read its PR) keeps the lock between slots, so a standalone land still refuses', () => {
const { fx, nonce } = stampedTrain();
fragmentPr(fx, '3');
trainLand(fx, nonce, '1');
expect(trainLand(fx, nonce, '1').status).toBe(EXIT.ok);
const row = fx.read().prs['2'];
fx.write(d => { delete d.prs['2']; });
const refused = trainLand(fx, nonce, '2');
expect(refused.err).toContain('(QUEUE_PROOF_MISSING)');
expect(refused.out).toMatch(/^LOCK: between /m);
expect(originLock(fx)).toMatchObject({ train: nonce, phase: 'between' });
expect(land(fx, '3').err).toMatch(/holds the land lock .*phase between.*\(QUEUE_LAND_LOCKED\)/);
fx.write(d => { d.prs['2'] = row; });
expect(trainLand(fx, nonce, '2').out).toMatch(/^PUSHED: [0-9a-f]{40}/m);
}, 300_000);
/** Another train's lock, written to origin as that train's run would have left it. */
function seedLock(fx: TrainFixture, lock: Partial<LandLock> & { train: string; phase: LandLock['phase'] }): void {
withRepoEnv(fx.repo, () => {
const cur = readLock(fx.repo.work);
expect(writeLock(fx.repo.work, cur.sha, { pr: '9', slot: 1, holder: 'old:1', started_at: '2026-10-31T12:00:00.000Z', phase_at: '2026-10-31T12:00:00.000Z', journal: `train/${lock.train}/slot-1`, ...lock } as LandLock).ok).toBe(true);
});
}
/** A freeze record for this repository, cleared at `cleared`, written to the state root as the clearing verify leaves it. */
function clearedFreeze(fx: TrainFixture, train: string, cleared: string): void {
const file = path.join(freezeFile(fx), `${repoKey(fx)}.json`);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify({ schema: 1, kind: 'freeze', repo_key: repoKey(fx), train, merged: ['9'], unmerged: [], main: { sha: '0'.repeat(40), tree: '0'.repeat(40) }, reason: 'test', backstop: null, repair: null, created_at: '2026-10-31T11:00:00.000Z', cleared_at: cleared, cleared_by: 'test', updated_at: cleared }));
}
// fails_when=trainTakeover reads "no active freeze record here" as "the freeze is cleared", so a train whose notes fetch failed, or whose freezer never pushed the record, takes the freeze lock and lands onto a frozen main (red team, PR #3737 pass 2); why_new=the freeze test above only exercises the admitted repair train's takeover; seam=a lock and a freeze record written straight to origin and the state root; no_claim=how the record went missing
test('another train\'s freeze lock is taken over only on a freeze record cleared after the lock: no record, or one cleared before it, refuses', () => {
const { fx, nonce } = stampedTrain(['1']);
const old = 'gate.train-old';
seedLock(fx, { train: old, phase: 'freeze' });
const noRecord = trainLand(fx, nonce, '1');
expect(noRecord.status).toBe(EXIT.refused);
expect(noRecord.err).toMatch(/train gate\.train-old slot 1 \(#9\) holds the land lock .*phase freeze.*\(QUEUE_LAND_LOCKED\)/);
expect(noRecord.out).not.toMatch(/^PUSHED:/m);
expect(originLock(fx)).toMatchObject({ train: old, phase: 'freeze' });
clearedFreeze(fx, old, '2026-10-31T11:30:00.000Z');
expect(trainLand(fx, nonce, '1').err).toContain('(QUEUE_LAND_LOCKED)');
clearedFreeze(fx, old, '2026-10-31T12:30:00.000Z');
const taken = trainLand(fx, nonce, '1');
expect(taken.out, taken.err).toContain(`LOCK: train ${nonce} takes over train ${old} slot 1 (#9)'s freeze lock; that freeze was cleared at 2026-10-31T12:30:00.000Z`);
expect(taken.out).toMatch(/^PUSHED: [0-9a-f]{40}/m);
}, 300_000);
// fails_when=a freeze lock whose release failed after its freeze cleared refuses every standalone land for good, or a lock ref deleted on origin still reads held from this clone's cached copy (adversarial review, PR #3737 Step 11); why_new=the tests above take the freeze lock over only from train land; seam=a lock and a freeze record written straight to origin and the state root; no_claim=why the release failed
test('a standalone land takes over a freeze lock once a fetched freeze record shows it cleared after the lock, and a lock deleted on origin reads as absent', () => {
const { fx } = stampedTrain(['1']);
fragmentPr(fx, '3');
seedLock(fx, { train: 'gate.train-old', phase: 'freeze' });
const frozen = land(fx, '3');
expect(frozen.status).toBe(EXIT.refused);
expect(frozen.err).toMatch(/train gate\.train-old slot 1 \(#9\) holds the land lock .*phase freeze.*\(QUEUE_LAND_LOCKED\)/);
clearedFreeze(fx, 'gate.train-old', '2026-10-31T12:30:00.000Z');
const taken = land(fx, '3');
expect(taken.out, taken.err).toContain("LOCK: taking over train gate.train-old slot 1 (#9)'s freeze lock; that freeze was cleared at 2026-10-31T12:30:00.000Z");
expect(taken.out).toMatch(/^PUSHED: [0-9a-f]{40}/m);
const { fx: other } = stampedTrain(['1']);
fragmentPr(other, '3');
seedLock(other, { train: 'gate.train-old', phase: 'waiting' });
expect(land(other, '3').err).toContain('(QUEUE_LAND_LOCKED)');
other.repo.git('--git-dir', other.repo.origin, 'update-ref', '-d', LAND_LOCK_REF);
expect(land(other, '3').out).toMatch(/^PUSHED: [0-9a-f]{40}/m);
}, 300_000);
// fails_when=trainTakeover treats a live train as terminal (two landers publish onto one main) or never takes over a finished train's lock (a stale lock blocks every later train); why_new=no other test seats a foreign train's lock; seam=a second train record written into the state root; no_claim=how that train ended
test('another train\'s lock is taken over once that train\'s record is terminal, and refused while it is still merging', () => {
const { fx, nonce } = stampedTrain(['1']);
const other = `${nonce}-other`;
const put = (state: string) => {
const dir = path.join(fx.state, 'ship-queue', 'trains', other);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'train.json'), JSON.stringify({ ...trainJson(fx, nonce), nonce: other, state }));
};
put('merging');
seedLock(fx, { train: other, phase: 'waiting' });
expect(trainLand(fx, nonce, '1').err).toMatch(/holds the land lock .*phase waiting.*\(QUEUE_LAND_LOCKED\)/);
put('merged');
const taken = trainLand(fx, nonce, '1');
expect(taken.out, taken.err).toContain(`takes over train ${other} slot 1 (#9)'s lock; that train is merged`);
expect(taken.out).toMatch(/^PUSHED: [0-9a-f]{40}/m);
}, 300_000);
test('a standalone land holding the lock refuses train land; a refusal before any push releases the train\'s lock', () => {
const { fx, heads, nonce } = stampedTrain();
fragmentPr(fx, '3');
expect(land(fx, '3').out).toMatch(/^PUSHED: /m);
const blocked = trainLand(fx, nonce, '1');
expect(blocked.status).toBe(EXIT.refused);
expect(blocked.err).toMatch(/#3 holds the land lock .*phase waiting.*\(QUEUE_LAND_LOCKED\)/);
expect(blocked.out).not.toMatch(/^PUSHED:/m);
expect(originRev(fx, 'refs/heads/pr-1')).toBe(heads[0]);
expect(land(fx, '3').out).toContain('LOCK: released');
const stale = trainLand(fx, nonce, '1');
expect(stale.err).toMatch(/nothing of train \S+ merged, so the queue is not frozen.*\(QUEUE_TRAIN_TREE_MISMATCH\)/);
expect(originLock(fx)).toMatchObject({ released: true });
}, 300_000);
test('after a push the train keeps its lock through a failed wait and a resume takes it back', () => {
const { fx, nonce } = stampedTrain(['1']);
const stamp = /^PUSHED: ([0-9a-f]{40})/m.exec(trainLand(fx, nonce, '1').out)![1]!;
fx.write(d => { d.ci[stamp] = [{ name: 'free-tests', status: 'completed', conclusion: 'failure' }] as any; });
const red = trainLand(fx, nonce, '1');
expect(red.status).not.toBe(EXIT.ok);
expect(originLock(fx)).toMatchObject({ train: nonce, slot: 1, pr: '1', phase: 'waiting' });
fx.write(d => { delete d.ci[stamp]; });
const resumed = trainLand(fx, nonce, '1');
expect(resumed.status, resumed.err).toBe(EXIT.ok);
expect(originLock(fx)).toMatchObject({ released: true });
}, 300_000);
test('a stop that freezes leaves the lock in a freeze phase that --break-lock never breaks; only the admitted repair train takes it over, and it is released once the freeze clears', () => {
const { fx, nonce } = stampedTrain();
trainLand(fx, nonce, '1');
expect(trainLand(fx, nonce, '1').status).toBe(EXIT.ok);
trainLand(fx, nonce, '2');
fx.write(d => { (d as any).outsideOnMerge = true; });
const drift = trainLand(fx, nonce, '2');
expect(drift.err).toContain('(QUEUE_TRAIN_TREE_MISMATCH)');
expect(drift.out).toMatch(/^QUEUE_TRAIN_FROZEN: /m);
expect(originLock(fx)).toMatchObject({ train: nonce, phase: 'freeze' });
const lockSha = originRev(fx, LAND_LOCK_REF);
fragmentPr(fx, '3');
const refused = land(fx, '3', ['--break-lock', '--break-train-lock']);
expect(refused.status).toBe(EXIT.refused);
expect(refused.err).toContain('(QUEUE_TRAIN_FROZEN)');
expect(originRev(fx, LAND_LOCK_REF)).toBe(lockSha);
const h4 = fragmentPr(fx, '4');
const repair = queue(fx, ['train', 'build', '--repair', '--prs', `4@${h4}`]);
expect(repair.status, repair.err).toBe(EXIT.ok);
const rn = nonceOf(repair.out);
expect(queue(fx, ['train', 'verdict', '--train', rn]).status).toBe(EXIT.ok);
const taken = trainLand(fx, rn, '4');
expect(taken.out, taken.err).toMatch(new RegExp(`^LOCK: repair train ${rn} takes over the freeze lock of train ${nonce}`, 'm'));
expect(originLock(fx)).toMatchObject({ train: rn, slot: 1, pr: '4', phase: 'freeze' });
expect(land(fx, '3', ['--break-lock', '--break-train-lock']).err).toContain('(QUEUE_TRAIN_FROZEN)');
const cleared = trainLand(fx, rn, '4');
expect(cleared.status, cleared.err).toBe(EXIT.ok);
expect(cleared.out).toMatch(/^FREEZE: cleared /m);
expect(originLock(fx)).toMatchObject({ released: true });
}, 600_000);
});
+8 -2
View File
@@ -5,7 +5,7 @@ import * as path from 'node:path';
import { EXIT } from '../lib/headless-artifacts';
import { cleanup } from './helpers/restamp-fixture';
import { coordinatorClone } from './helpers/ship-queue-fixture';
import { publishMain, twoPrTrain } from './helpers/ship-queue-train-cases';
import { laneStamp, markMerged, publishMain, twoPrTrain } from './helpers/ship-queue-train-cases';
import {
TRAIN_POLICY, dispatches, line, nonceOf, openPr, queue, readyReceipt, repoKey, trainFixture, trainJson,
type TrainFixture,
@@ -28,8 +28,10 @@ describe('verify, freeze, repair and clear', () => {
const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord);
const released = (prefix: string, version: string) => fx.repo.git('show', `${prefix}:CHANGELOG.md`).replace('## [Unreleased]', `## [${version}] - 2026-10-18`) + '\n';
const main1 = publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: released(t.prefixes[1].commit, '1.2.4.0') });
markMerged(fx, '1');
expect(v('1').status).toBe(EXIT.ok);
expect(line(v('2').out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${h2}`);
const s2 = laneStamp(fx, '2', h2, { mergeMain: true });
expect(line(v('2').out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${s2}`);
// Main's CHANGELOG plus #2's stamped entry, with one line the pin never wrote.
const smuggled = fx.repo.git('show', `${main1}:CHANGELOG.md`).replace('# Changelog\n', '# Changelog\n\n## [1.2.5.0] - 2026-10-18\n\n- #2 change\n- added after the gate\n') + '\n';
const broken = publishMain(fx, t.prefixes[2].commit, '1.2.5.0', { file: 'CHANGELOG.md', text: smuggled });
@@ -111,6 +113,7 @@ describe('verify, freeze, repair and clear', () => {
const rn = nonceOf(queue(fx, ['train', 'build', '--repair', '--prs', `3@${h3}`]).out);
expect(queue(fx, ['train', 'verdict', '--train', rn]).status).toBe(EXIT.ok);
publishMain(fx, trainJson(fx, rn).prefixes[1].commit, '1.2.6.0');
markMerged(fx, '3');
const cleared = queue(fx, ['verify', '--pr', '3', '--train', rn], {}, coord);
expect(cleared.out).toMatch(/^FREEZE: cleared /m);
expect(next(cleared.out)).toBe(`NEXT: gh pr view 2 --json state,headRefOid (unreadable at the freeze: #2@${h2.slice(0, 12)}); seat each that is OPEN in a train`);
@@ -125,6 +128,7 @@ describe('verify, freeze, repair and clear', () => {
const coord = coordinatorClone(fx.repo);
const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord);
publishMain(fx, t.prefixes[1].commit, '1.2.4.0');
markMerged(fx, '1');
expect(v('1').status).toBe(EXIT.ok);
const later = publishMain(fx, t.prefixes[2].commit, '1.2.5.0', { file: 'stray.txt', text: 'a later train\n' });
fx.write(d => { (d.prs['2'] as any).state = 'MERGED'; });
@@ -157,6 +161,7 @@ describe('verify, freeze, repair and clear', () => {
const coord = coordinatorClone(fx.repo);
const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord);
publishMain(fx, t.prefixes[1].commit, '1.2.4.0');
markMerged(fx, '1');
expect(v('1').status).toBe(EXIT.ok);
const later = publishMain(fx, t.prefixes[2].commit, '1.2.5.0', { file: 'stray.txt', text: 'a later train\n' });
const tree = fx.repo.git('rev-parse', `${later}^{tree}`);
@@ -181,6 +186,7 @@ describe('verify, freeze, repair and clear', () => {
const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord);
const released = fx.repo.git('show', `${t.prefixes[1].commit}:CHANGELOG.md`).replace('## [Unreleased]', '## [1.2.4.0] - 2026-10-18') + '\n';
const main1 = publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: released });
markMerged(fx, '1');
expect(v('1').status).toBe(EXIT.ok);
const foreign = publishMain(fx, t.prefixes[1].commit, '1.2.4.1', { file: 'stray.txt', text: 'landed between the two slots\n' });
const text = fx.repo.git('show', `${main1}:CHANGELOG.md`).replace('# Changelog\n', '# Changelog\n\n## [1.2.5.0] - 2026-10-18\n\n- #2 change\n- added after the gate\n') + '\n';
+7 -36
View File
@@ -10,47 +10,13 @@ import * as path from 'node:path';
import { EXIT } from '../lib/headless-artifacts';
import { cleanup } from './helpers/restamp-fixture';
import {
GREEN_GATE, RED_GATE, TRAIN_POLICY, line, nonceOf, openPr, queue, readyReceipt, shippedBody, trainFixture, trainJson, type TrainFixture,
GREEN_GATE, RED_GATE, TRAIN_POLICY, fragmentPr, line, nonceOf, openPr, queue, readyReceipt, shippedBody, trainFixture, trainJson, wrapLand as wrap, type TrainFixture,
} from './helpers/ship-queue-train-fixture';
const POLICY = { ...TRAIN_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.91.336.0' };
const fixtures: TrainFixture[] = [];
afterEach(() => { while (fixtures.length) cleanup(fixtures.pop()!.repo); });
/** pr view reads the head from origin's branch; pr edit and pr merge act on origin; any commit without recorded CI reads green. */
function wrap(fx: TrainFixture): void {
const dir = path.join(fx.repo.root, 'gh-land-wrap');
fs.mkdirSync(dir, { recursive: true });
const shim = path.join(fx.repo.root, 'gh-train-shim', 'shim.js');
fs.writeFileSync(path.join(dir, 'gh'), [
'#!/usr/bin/env node',
"const fs=require('fs');const cp=require('child_process');const D=process.env.LAND_WRAP_DATA;const a=process.argv.slice(2);",
"const d=JSON.parse(fs.readFileSync(D,'utf8'));const save=()=>fs.writeFileSync(D,JSON.stringify(d));",
"const g=(...x)=>cp.execFileSync('git',['--git-dir',d.origin,...x],{encoding:'utf8',timeout:30000}).trim();",
"const rev=r=>{try{return g('rev-parse','--verify','--quiet',r)}catch{return ''}};",
"if(a[0]==='pr'&&a[1]==='view'&&d.prs[a[2]]){const p=d.prs[a[2]];const h=rev('refs/heads/'+p.headRefName);if(h)p.headRefOid=h;save();console.log(JSON.stringify(p));process.exit(0)}",
"if(a[0]==='pr'&&a[1]==='edit'){(d.labels=d.labels||{})[a[2]]=a[a.indexOf('--add-label')+1];save();process.exit(0)}",
"if(a[0]==='pr'&&a[1]==='merge'){const p=d.prs[a[2]];const want=a[a.indexOf('--match-head-commit')+1];if(rev('refs/heads/'+p.headRefName)!==want){console.error('Head branch was modified');process.exit(1)}",
" const before=rev('main');let tree=rev(want+'^{tree}');",
" if(d.outsideOnMerge){const blob=cp.execFileSync('git',['--git-dir',d.origin,'hash-object','-w','--stdin'],{input:'outside\\n',encoding:'utf8',timeout:30000}).trim();",
" const ls=g('ls-tree',before+'^{tree}')+'\\n100644 blob '+blob+'\\toutside.txt';const ot=cp.execFileSync('git',['--git-dir',d.origin,'mktree'],{input:ls+'\\n',encoding:'utf8',timeout:30000}).trim();",
" g('update-ref','refs/heads/main',g('commit-tree',ot,'-p',before,'-m','outside'));tree=g('merge-tree','--write-tree','--merge-base='+before,rev('main'),want).split('\\n')[0];d.outsideOnMerge=false}",
" g('update-ref','refs/heads/main',g('commit-tree',tree,'-p',rev('main'),'-m','squash #'+a[2]));p.state='MERGED';save();process.exit(0)}",
"const paged=/commits\\/([0-9a-f]+)\\/(check-runs|status)\\?.*page=(\\d+)/.exec(a[1]||'');",
"if(a[0]==='api'&&paged&&!(d.ci||{})[paged[1]]&&!((d.statuses||{})[paged[1]])){const list=paged[2]==='check-runs'?[{name:'free-tests',status:'completed',conclusion:'success',id:1,completed_at:'2026-10-26T00:00:00Z'}]:[];const key=paged[2]==='check-runs'?'check_runs':'statuses';console.log(JSON.stringify(paged[3]==='1'?{total_count:list.length,state:'success',[key]:list}:{total_count:list.length,[key]:[]}));process.exit(0)}",
`const r=cp.spawnSync('node',[${JSON.stringify(shim)},D,...a],{stdio:'inherit',timeout:120000});process.exit(r.status??1);`,
'',
].join('\n'), { mode: 0o755 });
fx.env.PATH = `${dir}:${fx.env.PATH}`;
(fx.env as Record<string, string>).LAND_WRAP_DATA = fx.dataFile;
}
function fragmentPr(fx: TrainFixture, pr: string, fragment = `- #${pr} change\n`): string {
const head = openPr(fx, pr, { file: `changelog.d/pr-${pr}.md`, text: fragment, changelog: false });
fx.repo.git('push', '-q', 'origin', `${head}:refs/heads/pr-${pr}`);
readyReceipt(fx, pr, head);
return head;
}
function stampedTrain(gate = GREEN_GATE, policy: Record<string, unknown> = POLICY) {
const fx = trainFixture(policy, 'gstack-train-land-'); fixtures.push(fx); wrap(fx);
const h1 = fragmentPr(fx, '1');
@@ -190,10 +156,15 @@ describe('stamped trains: build and land', () => {
}, 300_000);
test('a failed stamp push with the branch still at the pin fails retryably instead of reporting a move', () => {
const { fx, nonce } = stampedTrain();
const { fx, h1, nonce } = stampedTrain();
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
const hook = path.join(fx.repo.origin, 'hooks', 'pre-receive');
fs.writeFileSync(hook, '#!/bin/sh\nexit 1\n', { mode: 0o755 });
const refused = land(fx, nonce, '1');
expect(refused.err).toMatch(/the land lock push to origin failed and the lock there is unchanged; nothing was pushed.*\(QUEUE_NOT_READY\)/);
expect(refused.err).not.toContain('another lander');
expect(fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', 'refs/heads/pr-1')).toBe(h1);
fs.writeFileSync(hook, '#!/bin/sh\nwhile read old new ref; do [ "$ref" = refs/heads/pr-1 ] && exit 1; done\nexit 0\n', { mode: 0o755 });
const failed = land(fx, nonce, '1');
expect(failed.err).toMatch(/the stamp push failed and pr-1 still reads.*\(QUEUE_NOT_READY\)/);
expect(failed.err).not.toContain('QUEUE_TRAIN_MOVED');
+27
View File
@@ -449,6 +449,33 @@ describe('ready --dispatch-ci', () => {
expect([fork.status, fork.err]).toEqual([EXIT.refused, expect.stringMatching(/#1's head is on a fork.*not dispatched.*\(QUEUE_TRAIN_UNTRUSTED\)/)]);
expect(queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen', '--dispatch-ci', '../evil.yml']).status).toBe(EXIT.usage);
}, 120_000);
// Value: protects=a conflicting PR cut before main gained windows-smoke.yml still gets its free-tests dispatch when the hint names both workflows (GitHub reads the workflow file from the branch, so dispatching the missing one could only fail);
// fails_when=the missing workflow is dispatched, a missing workflow refuses the whole command, or a workflow the branch has is skipped; why_new=windows-smoke.yml replaced windows-free-tests.yml in the hint while open branches predate it; seam=git objects in the fixture checkout; no_claim=GitHub's exact error for a workflow missing at the ref
test('skips a workflow the base has but the PR head predates, with a note, and dispatches the rest', () => {
const { fx, head, pregate } = readyFixture();
const { repo } = fx;
repo.git('checkout', '-q', 'origin/main');
fs.mkdirSync(path.join(repo.work, '.github', 'workflows'), { recursive: true });
fs.writeFileSync(path.join(repo.work, '.github', 'workflows', 'windows-smoke.yml'), 'name: Windows Smoke\n');
repo.git('add', '-A');
repo.git('commit', '-q', '-m', 'add windows-smoke.yml');
repo.git('push', '-q', 'origin', 'HEAD:main');
repo.git('fetch', '-q', 'origin', 'main');
repo.git('checkout', '-q', 'pr-1');
fx.write(d => {
(d.prs['1'] as any).mergeable = 'CONFLICTING';
d.ci[head] = [];
d.rules['free-tests.yml'] = { ci: 'free-tests', headSha: head };
d.rules['windows-smoke.yml'] = { ci: 'windows-smoke', headSha: head };
});
const r = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen', '--dispatch-ci', 'free-tests.yml,windows-smoke.yml', '--timeout', '5']);
expect(r.out.split('\n').filter(l => l.startsWith('CI_DISPATCH:'))).toEqual([
'CI_DISPATCH: free-tests.yml --ref pr-1 run 100 https://example.invalid/runs/100 success',
`CI_DISPATCH: windows-smoke.yml not dispatched (.github/workflows/windows-smoke.yml is on main but not in ${head.slice(0, 12)}; the branch predates it, so its pull_request run comes with the stamp commit)`]);
expect(dispatches(fx).map(l => l.workflow)).toEqual(['free-tests.yml']);
expect(line(r.out, 'ci:')).toBe(`ci: green at ${head.slice(0, 12)} (passed=1 failed=0 pending=0 skipped=0)`);
}, 120_000);
});
describe('train build --dry-run --stack-tests', () => {
+155 -13
View File
@@ -6,7 +6,7 @@ import { mergeChangelogs, parseChangelog } from '../lib/changelog-check';
import { EXIT } from '../lib/headless-artifacts';
import { cleanup } from './helpers/restamp-fixture';
import { coordinatorClone } from './helpers/ship-queue-fixture';
import { publishMain, twoPrTrain } from './helpers/ship-queue-train-cases';
import { laneStamp, markMerged, publishMain, twoPrTrain } from './helpers/ship-queue-train-cases';
import {
GREEN_GATE, TRAIN_POLICY, dispatches, line, nonceOf, openPr, queue, readyReceipt, repoKey, setPrBody, shippedBody, trainFixture, trainJson,
type TrainFixture,
@@ -27,21 +27,24 @@ describe('verify, freeze, repair and clear', () => {
const coord = coordinatorClone(fx.repo);
const v = (pr: string, train = nonce) => queue(fx, ['verify', '--pr', pr, '--train', train], {}, coord);
const s1 = laneStamp(fx, '1', h1);
const pre1 = v('1');
expect(pre1.status).toBe(EXIT.ok);
expect(pre1.out).toContain(`VERIFY_TRAIN: #1 may merge as slot 1 of train ${nonce}`);
expect(line(pre1.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${h1}`);
expect(line(pre1.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${s1}`);
const early = v('2');
expect(early.status).toBe(EXIT.fail);
expect(early.err).toMatch(/is neither T1 nor T2 modulo stamps.*nothing of train \S+ merged, so the queue is not frozen.*\(QUEUE_TRAIN_TREE_MISMATCH\)/);
publishMain(fx, t.prefixes[1].commit, '1.2.4.0');
publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: fx.repo.git('show', `${s1}:CHANGELOG.md`) + '\n' });
markMerged(fx, '1');
const post1 = v('1');
expect(post1.status).toBe(EXIT.ok);
expect(post1.out).toMatch(/^main: [0-9a-f]{40} = T1 \(same modulo stamps \(1 stamp file\(s\)\)\)$/m);
expect(post1.out).toMatch(/^main: [0-9a-f]{40} = T1 \(same modulo stamps \(2 stamp file\(s\)\)\)$/m);
expect(post1.out).toContain(`VERIFY_TRAIN: #1 merged; main's tree is T1 of train ${nonce} (1/2 merged)`);
expect(next(post1.out)).toBe(`NEXT: gstack-ship-queue verify --pr 2 --train ${nonce}`);
expect(line(v('2').out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${h2}`);
const s2 = laneStamp(fx, '2', h2, { mergeMain: true });
expect(line(v('2').out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${s2}`);
const broken = publishMain(fx, t.prefixes[2].commit, '1.2.5.0', { file: 'stray.txt', text: 'not in the train\n' });
const mismatch = v('2');
@@ -69,8 +72,16 @@ describe('verify, freeze, repair and clear', () => {
expect(queue(fx, ['train', 'verdict', '--train', rn]).status).toBe(EXIT.ok);
expect(v('2').err).toContain('(QUEUE_TRAIN_FROZEN)');
expect(dispatches(fx, 'evals.yml').filter(d => d.ref === 'main').length).toBe(1);
expect(line(v('3', rn).out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${h3}`);
// The broken main still carries T2's [Unreleased] section, so #3's lane stamps its own entry by hand.
fx.repo.git('checkout', '-q', '-B', 'lane3', h3);
fs.writeFileSync(path.join(fx.repo.work, 'VERSION'), '1.2.6.0\n');
fs.writeFileSync(path.join(fx.repo.work, 'CHANGELOG.md'), fx.repo.git('show', `${h3}:CHANGELOG.md`).replace('## [Unreleased]', '## [1.2.6.0] - 2026-10-18') + '\n');
fx.repo.git('commit', '-qam', 'slot stamp #3');
const s3 = fx.repo.git('rev-parse', 'HEAD');
fx.repo.git('push', '-q', '-f', 'origin', `${s3}:refs/pull/3/head`);
expect(line(v('3', rn).out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${s3}`);
publishMain(fx, trainJson(fx, rn).prefixes[1].commit, '1.2.6.0');
markMerged(fx, '3');
const cleared = v('3', rn);
expect(cleared.status).toBe(EXIT.ok);
expect(cleared.out).toMatch(/^FREEZE: cleared \(repair #3 via train \S+; main [0-9a-f]{12} carries a green record\)$/m);
@@ -85,14 +96,10 @@ describe('verify, freeze, repair and clear', () => {
const coord = coordinatorClone(fx.repo);
const v = () => queue(fx, ['verify', '--pr', '1', '--train', nonce], {}, coord);
const { repo } = fx;
repo.git('checkout', '-q', '-B', 'lane1', h1);
fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.4.0\n');
repo.git('commit', '-qam', 'v1.2.4.0 stamp');
const stamped = repo.git('rev-parse', 'HEAD');
repo.git('push', '-q', '-f', 'origin', `${stamped}:refs/pull/1/head`);
const stamped = laneStamp(fx, '1', h1);
const ok = v();
expect(ok.status).toBe(EXIT.ok);
expect(ok.out).toContain(`head: ${stamped} (same modulo stamps (1 stamp file(s)))`);
expect(ok.out).toContain(`head: ${stamped} (same modulo stamps (5 stamp file(s)))`);
expect(ok.out).toContain(`SHIP_RECEIPT: pr=1 head=${h1.slice(0, 12)} version=- base=main (binds the pinned head)`);
expect(line(ok.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${stamped}`);
setPrBody(fx, '1', shippedBody('1', 'b'.repeat(40)));
@@ -133,6 +140,7 @@ describe('verify, freeze, repair and clear', () => {
const coord = coordinatorClone(fx.repo);
const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord);
const main1 = publishMain(fx, t.prefixes[1].commit, '1.2.4.0');
markMerged(fx, '1');
expect(v('1').status).toBe(EXIT.ok);
const { repo } = fx;
@@ -142,6 +150,7 @@ describe('verify, freeze, repair and clear', () => {
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', `${t.prefixes[2].commit}:CHANGELOG.md`).replace('## [Unreleased]', '## [1.2.5.0] - 2026-10-18') + '\n');
if (extra) fs.writeFileSync(path.join(repo.work, extra.file), extra.text);
repo.git('add', '-A');
const tree = repo.git('write-tree');
@@ -224,8 +233,10 @@ describe('verify, freeze, repair and clear', () => {
const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord);
// Slot 1: #1 squash-merges and is released as 1.2.4.0 (its [Unreleased] re-headed).
expect(line(v('1').out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${h1}`);
const s1 = laneStamp(fx, '1', h1);
expect(line(v('1').out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${s1}`);
const main1 = publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: repo.git('show', `${t.prefixes[1].commit}:CHANGELOG.md`).replace('## [Unreleased]', '## [1.2.4.0] - 2026-10-18') + '\n' });
markMerged(fx, '1');
expect(v('1').out).toContain(`VERIFY_TRAIN: #1 merged; main's tree is T1 of train ${nonce} (1/2 merged)`);
// Slot 2: the lane merges main into its pin; mergeChangelogs carries the child past its released parent.
@@ -249,6 +260,7 @@ describe('verify, freeze, repair and clear', () => {
// The squash merge publishes exactly that tree; the post-merge verify confirms it instead of freezing the queue.
const main2 = publishMain(fx, laneHead, '1.2.5.0');
expect(repo.git('rev-parse', `${main2}^{tree}`)).toBe(repo.git('rev-parse', `${laneHead}^{tree}`));
markMerged(fx, '2');
const post = v('2');
expect([post.status, post.err]).toEqual([EXIT.ok, '']);
expect(line(post.out, 'main:')).toBe(`main: ${main2} = T2 (same modulo stamps (1 stamp file(s)) except CHANGELOG.md, main ${main1.slice(0, 12)}'s CHANGELOG.md plus #2's own entry)`);
@@ -256,3 +268,133 @@ describe('verify, freeze, repair and clear', () => {
expect(queue(fx, ['train', 'status']).out).not.toContain('QUEUE_TRAIN_FROZEN');
}, 300_000);
});
describe('verify --train proves the slot stamp and a GitHub merge (#3531)', () => {
/** A main commit that carries an unstamped [Unreleased] entry, the way a bare commit after a no-commit merge shipped #3527. */
function unstampedMain(fx: TrainFixture, entry: string): string {
const { repo } = fx;
repo.git('fetch', '-q', 'origin');
repo.git('checkout', '-q', '-B', 'mainsim', 'origin/main');
fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), fs.readFileSync(path.join(repo.work, 'CHANGELOG.md'), 'utf8').replace('# Changelog\n', `# Changelog\n\n## [Unreleased]\n\n${entry}\n`));
repo.git('commit', '-qam', 'merged without its stamp');
repo.git('push', '-q', 'origin', 'HEAD:refs/heads/main');
repo.git('fetch', '-q', 'origin');
return repo.git('rev-parse', 'HEAD');
}
const freezeFile = (fx: TrainFixture) => path.join(fx.state, 'ship-queue', 'freeze', `${repoKey(fx)}.json`);
test('#3527: an unstamped slot head (the bare pin, or a no-commit base merge committed without the restamp it wrote) refuses QUEUE_TRAIN_UNSTAMPED naming both versions; the stamped head passes with STAMP_VERSION from the head', () => {
const fx = make();
const { h1, h2, nonce } = twoPrTrain(fx);
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
const t = trainJson(fx, nonce);
const coord = coordinatorClone(fx.repo);
const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord);
const bare = v('1');
expect(bare.status).toBe(EXIT.refused);
expect(bare.err).toMatch(/#1's head [0-9a-f]{12} is not stamped above main: version: the head's VERSION 1\.2\.3\.0 is not above main's 1\.2\.3\.0.*\(QUEUE_TRAIN_UNSTAMPED\)/);
expect(bare.out).not.toContain('MERGE_CONDITION:');
expect(next(bare.out)).toMatch(/^NEXT: #1's lane runs gstack-restamp --next on its head, commits what restamp staged/);
const s1 = laneStamp(fx, '1', h1);
const ok = v('1');
expect(ok.status, ok.err).toBe(EXIT.ok);
expect(ok.out).toContain('STAMP_VERSION: 1.2.4.0');
expect(line(ok.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${s1}`);
const unread = queue(fx, ['verify', '--pr', '1', '--train', nonce, '--no-fetch'], {}, coord);
expect(unread.status).toBe(EXIT.refused);
expect(unread.err).toMatch(/#1's current head was not read \(--no-fetch\).*\(QUEUE_PROOF_MISSING\)/);
expect(unread.out).not.toContain('MERGE_CONDITION:');
publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: fx.repo.git('show', `${s1}:CHANGELOG.md`) + '\n' });
markMerged(fx, '1');
expect(v('1').out).toContain(`VERIFY_TRAIN: #1 merged; main's tree is T1 of train ${nonce} (1/2 merged)`);
laneStamp(fx, '2', h2, { mergeMain: true, dropStamp: true });
const dropped = v('2');
expect(dropped.status, dropped.err).toBe(EXIT.refused);
expect(dropped.err).toMatch(/#2's head [0-9a-f]{12} is not stamped above main: version: the head's VERSION 1\.2\.4\.0 is not above main's 1\.2\.4\.0.*\(QUEUE_TRAIN_UNSTAMPED\)/);
expect(dropped.out).not.toContain('MERGE_CONDITION:');
expect(fs.existsSync(freezeFile(fx))).toBe(false);
const s2 = laneStamp(fx, '2', h2, { mergeMain: true });
const stamped = v('2');
expect(stamped.status, stamped.err).toBe(EXIT.ok);
expect(stamped.out).toContain('STAMP_VERSION: 1.2.5.0');
expect(line(stamped.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${s2}`);
}, 300_000);
test('the CHANGELOG rule is named: a head whose VERSION moved but whose entry is still [Unreleased] refuses', () => {
const fx = make();
const { h1, nonce } = twoPrTrain(fx);
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
const { repo } = fx;
repo.git('checkout', '-q', '-B', 'lane1', h1);
fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.4.0\n');
repo.git('commit', '-qam', 'VERSION only');
repo.git('push', '-q', '-f', 'origin', `${repo.git('rev-parse', 'HEAD')}:refs/pull/1/head`);
const r = queue(fx, ['verify', '--pr', '1', '--train', nonce], {}, coordinatorClone(fx.repo));
expect(r.status).toBe(EXIT.refused);
expect(r.err).toMatch(/is not stamped above main: changelog: CHANGELOG\.md's top section at the head is \[Unreleased\], not \[1\.2\.4\.0\].*\(QUEUE_TRAIN_UNSTAMPED\)/);
}, 300_000);
test('claim-at-open: a pin that carries its own stamp verifies at head == pin', () => {
const fx = make({ ...TRAIN_POLICY, queue_mode: 'claim-at-open' });
const h1 = openPr(fx, '1', { file: 'VERSION', text: '1.2.4.0\n', changelog: cl => cl.replace('# Changelog\n', '# Changelog\n\n## [1.2.4.0] - 2026-10-18\n\n- #1 change\n') });
readyReceipt(fx, '1', h1);
fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; });
const nonce = nonceOf(queue(fx, ['train', 'build', '--prs', `1@${h1}`, '--urgent']).out);
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
const r = queue(fx, ['verify', '--pr', '1', '--train', nonce], {}, coordinatorClone(fx.repo));
expect(r.status, r.err).toBe(EXIT.ok);
expect(r.out).toContain('STAMP_VERSION: 1.2.4.0');
expect(line(r.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${h1}`);
}, 300_000);
test('#3530: an open release-only PR whose stamp-only diff makes main equal its prefix modulo stamps is not reported merged; it takes the pre-merge path', () => {
const fx = make();
unstampedMain(fx, '- #3527 change');
const h5 = openPr(fx, '5', { file: 'VERSION', text: '1.2.4.0\n', changelog: cl => cl.replace('## [Unreleased]', '## [1.2.4.0] - 2026-10-18') });
readyReceipt(fx, '5', h5);
fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; });
const nonce = nonceOf(queue(fx, ['train', 'build', '--prs', `5@${h5}`, '--urgent']).out);
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
const r = queue(fx, ['verify', '--pr', '5', '--train', nonce], {}, coordinatorClone(fx.repo));
expect(r.out).not.toMatch(/^VERIFY_TRAIN: #5 merged/m);
expect(r.status, r.err).toBe(EXIT.ok);
expect(r.out).toContain(`VERIFY_TRAIN: #5 may merge as slot 1 of train ${nonce}`);
expect(line(r.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${h5}`);
expect(trainJson(fx, nonce).merged).toEqual([]);
}, 300_000);
test('no false freeze: main at the next prefix while GitHub reports the PR OPEN, or cannot be read, refuses QUEUE_PROOF_MISSING and never freezes; MERGED confirms', () => {
const fx = make();
const { h1, nonce } = twoPrTrain(fx);
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
const t = trainJson(fx, nonce);
const coord = coordinatorClone(fx.repo);
const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord);
laneStamp(fx, '1', h1);
expect(v('1').status).toBe(EXIT.ok);
publishMain(fx, t.prefixes[1].commit, '1.2.4.0');
markMerged(fx, '1');
expect(v('1').status).toBe(EXIT.ok);
publishMain(fx, t.prefixes[2].commit, '1.2.5.0');
const open = v('2');
expect(open.status).toBe(EXIT.refused);
expect(open.err).toMatch(/GitHub reports #2 as OPEN, not MERGED.*\(QUEUE_PROOF_MISSING\)/);
expect(open.out).not.toMatch(/^VERIFY_TRAIN: #2 merged/m);
expect(open.out).not.toMatch(/^QUEUE_TRAIN_FROZEN:/m);
expect(next(open.out)).toContain('rerun once GitHub reports #2 MERGED');
const saved = fx.read().prs['2'];
fx.write(d => { delete d.prs['2']; });
const unread = v('2');
expect(unread.err).toContain('(QUEUE_PROOF_MISSING)');
expect(unread.out).not.toMatch(/^QUEUE_TRAIN_FROZEN:/m);
expect(fs.existsSync(freezeFile(fx))).toBe(false);
fx.write(d => { d.prs['2'] = { ...(saved as object), state: 'MERGED' }; });
const done = v('2');
expect(done.status, done.err).toBe(EXIT.ok);
expect(done.out).toContain(`VERIFY_TRAIN: #2 merged; main's tree is T2 of train ${nonce} (2/2 merged)`);
}, 300_000);
});
+56
View File
@@ -0,0 +1,56 @@
/**
* #3541 (the /ship half): `gstack-ship-receipt write` with no --session-kind
* and no GSTACK_SESSION_KIND asks bin/gstack-session-kind, a bash script.
* Spawned by path it never starts on Windows (no shebangs), so every receipt
* written there carried no session_kind.
*
* Windows-safe by construction: both bins launch through
* lib/script-invocation.ts (Git Bash on win32), state lives under os.tmpdir().
*/
import { afterEach, beforeEach, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { spawnSync } from 'node:child_process';
import { runScriptSync } from '../lib/script-invocation';
const ROOT = path.resolve(import.meta.dir, '..');
let tmp = '', repo = '', env: NodeJS.ProcessEnv = {};
beforeEach(() => {
tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-receipt-kind-')));
repo = path.join(tmp, 'repo');
fs.mkdirSync(repo);
const gitconfig = path.join(tmp, 'gitconfig');
fs.writeFileSync(gitconfig, '');
env = {
...process.env, HOME: tmp, USERPROFILE: tmp, GSTACK_STATE_ROOT: path.join(tmp, 'state'), GSTACK_HOME: path.join(tmp, 'state'), GSTACK_SESSION_KIND: '',
GIT_CONFIG_GLOBAL: gitconfig, GIT_CONFIG_NOSYSTEM: '1', GIT_DIR: undefined, GIT_WORK_TREE: undefined, GIT_INDEX_FILE: undefined,
};
const git = (...args: string[]) => {
const r = spawnSync('git', args, { cwd: repo, env, encoding: 'utf8', timeout: 30_000 });
expect(r.status, r.stderr).toBe(0);
};
git('init', '-q', '-b', 'main');
fs.writeFileSync(path.join(repo, 'a.txt'), 'a\n');
git('add', 'a.txt');
git('-c', 'user.email=fixture@example.test', '-c', 'user.name=Fixture', '-c', 'commit.gpgsign=false', 'commit', '-qm', 'fixture');
git('update-ref', 'refs/remotes/origin/main', 'HEAD');
git('switch', '-q', '-c', 'feat');
});
afterEach(() => fs.rmSync(tmp, { recursive: true, force: true }));
test('write without a session kind records what gstack-session-kind prints (#3541)', () => {
const kind = runScriptSync(path.join(ROOT, 'bin', 'gstack-session-kind'), [], { cwd: repo, env, encoding: 'utf8', timeout: 30_000 });
expect(kind.status, kind.stderr).toBe(0);
const body = path.join(tmp, 'body.md');
fs.writeFileSync(body, '## Summary\n');
const w = runScriptSync(path.join(ROOT, 'bin', 'gstack-ship-receipt'), ['write', '--pr', '1', '--base', 'main', '--body', body, '--json'], {
cwd: repo, env, encoding: 'utf8', timeout: 120_000,
});
expect(w.error).toBeUndefined();
expect(w.status, w.stderr).toBe(0);
expect(JSON.parse(w.stdout).session_kind).toBe(kind.stdout.trim());
}, 150_000);
+47
View File
@@ -202,6 +202,53 @@ describe('a version stamp after review (GBRA-251, gbrain #8095)', () => {
});
});
describe('a restamp after a lost slot (GST-895, GBRA-218, gbrain #8134)', () => {
// Value: protects=after a lost slot and a merge of the base, the restamp moves the branch's own stale stamp (older
// than the reviewed tree's VERSION, newer than its fork point) and finish routes that line as a stamp; a row the
// base carries (another PR's release at the same version), a merged base row the base later edited, or a branch row
// at or older than the fork point, moved the same way still stops;
// fails_when=stampedFiles floors every moved version at the reviewed tree's VERSION (behavior=1 on gbrain #8134) or
// drops the floor for base lines; seam=lib/ship-bin-common.ts stampedFiles; no_claim=the restamp writer
const policy = JSON.stringify({ stamp_files: ['VERSION', 'src/notice.ts'], stamp_patterns: ["since: '{version}'"] });
const notice = (...rows: [string, string][]) => `export const NOTICES = [\n${rows.map(([v, text]) => ` { since: '${v}', text: '${text}' },`).join('\n')}\n];\n`;
const lostSlot = (ownRow: string) => {
const f = fx({ '.gstack/ship-policy.json': policy, VERSION: '1.0.0.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old']) },
{ VERSION: '1.0.1.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], [ownRow, 'mine']) });
f.advanceBase({ VERSION: '1.0.2.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.1.0', 'theirs']) });
mergeMain(f, { VERSION: '1.0.2.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.1.0', 'theirs'], [ownRow, 'mine']) });
return { f, reviewed: wtree(f), reviewedBase: mergeBase(f), post: snapshot(f) };
};
const behaviorStop = 'STOP: behavior, tests or build inputs changed since review (src/notice.ts): insert 5–11.5 → 12–14 → 16 before Step 17 (stage 2.1)';
test('the moved stale stamp is metadata; a moved base row or pre-fork row stops', () => {
const own = lostSlot('1.0.1.0');
own.f.write({ VERSION: '1.0.3.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.1.0', 'theirs'], ['1.0.3.0', 'mine']) }); own.f.commit('restamp 1.0.3.0');
const ok = finish(own.f, own.reviewed, own.post, ['--reviewed-base', own.reviewedBase]);
expect([lineOf(ok.stdout, 'ROUTE'), lineOf(ok.stdout, 'DOCS')]).toEqual([expect.stringMatching(/ behavior=0 docs=0 metadata=2 tests=0 /), expect.not.stringContaining('src/notice.ts')]);
const base = lostSlot('1.0.1.0');
base.f.write({ VERSION: '1.0.3.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.3.0', 'theirs'], ['1.0.3.0', 'mine']) }); base.f.commit('restamp plus the base row');
const b = finish(base.f, base.reviewed, base.post, ['--reviewed-base', base.reviewedBase]);
expect([b.status, lineOf(b.stdout, 'STOP')]).toEqual([3, behaviorStop]);
const old = lostSlot('0.9.5.0');
old.f.write({ VERSION: '1.0.3.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.1.0', 'theirs'], ['1.0.3.0', 'mine']) }); old.f.commit('moves a pre-fork row');
const o = finish(old.f, old.reviewed, old.post, ['--reviewed-base', old.reviewedBase]);
expect([o.status, lineOf(o.stdout, 'STOP')]).toEqual([3, behaviorStop]);
const atFork = lostSlot('1.0.0.0');
atFork.f.write({ VERSION: '1.0.3.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.1.0', 'theirs'], ['1.0.3.0', 'mine']) }); atFork.f.commit('moves a row at the fork version');
const a = finish(atFork.f, atFork.reviewed, atFork.post, ['--reviewed-base', atFork.reviewedBase]);
expect([a.status, lineOf(a.stdout, 'STOP')]).toEqual([3, behaviorStop]);
const edited = lostSlot('1.0.1.0');
edited.f.advanceBase({ VERSION: '1.0.2.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.1.0', 'theirs, edited']) }); edited.f.git('fetch', '-q', 'origin');
edited.f.write({ VERSION: '1.0.3.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.3.0', 'theirs'], ['1.0.3.0', 'mine']) }); edited.f.commit('moves a merged base row the base later edited');
const e = finish(edited.f, edited.reviewed, edited.post, ['--reviewed-base', edited.reviewedBase]);
expect([e.status, lineOf(e.stdout, 'STOP')]).toEqual([3, behaviorStop]);
});
});
describe('adjacent base edits (GST-384, #3631, founderstar#441)', () => {
// Value: protects=a base edit next to the branch's unchanged hunk asks for a scoped review of only those hunks (finish
// context=N, carry RECEIPT_CARRY_SCOPED_REVIEW), and a clean scoped review lets it through; fails_when=it forces the
+5 -5
View File
@@ -189,7 +189,7 @@ describe('collectHealth over recorded fixture data', () => {
[/workflows\/free-tests\.yml\/runs/, () => ({ workflow_runs: [run(60), run(61, { conclusion: 'failure' })] })],
[/runs\/60\/artifacts/, () => ({ artifacts: [{ id: 61, name: 'flake-ledger-2', size_in_bytes: 10 }] })],
[/runs\/61\/artifacts/, () => ({ artifacts: [] })],
[/workflows\/windows-free-tests\.yml\/runs/, () => ({ workflow_runs: windowsRuns })],
[/workflows\/windows-smoke\.yml\/runs/, () => ({ workflow_runs: windowsRuns })],
], {
11: { 'manifest.json': JSON.stringify({ prCoverage: { mode: 'full-fallback', e2e: ['gate-a', 'gate-b'], judges: ['judge-a'],
shadow: { e2e: ['gate-a', 'gate-b'], wouldDrop: [], wouldAdd: [], provenance: [], predictedSavingUsd: 0, unpricedCases: 0 } } }) },
@@ -218,7 +218,7 @@ describe('collectHealth over recorded fixture data', () => {
expect(value('census-redispatch').value).toBe('0 of 1 census(es) re-dispatched; longest paid slice job 20.0 min');
expect(value('quarantine-evidence').value).toContain('insufficient evidence (not the same as healthy)');
expect(value('free-flaky-pass-rate').value).toStartWith('50.0% (1/2 main runs');
expect(value('free-wall').value).toBe('free-tests 0.0 min (1 runs); windows-free-tests (successful PR runs) 0.0 min (1 runs)');
expect(value('free-wall').value).toBe('free-tests 0.0 min (1 runs); windows-smoke (successful PR runs) 0.0 min (1 runs)');
expect(value('free-serial').value).toBe('3 s over 3 files');
for (const entry of report.metrics.filter(entry => entry.id !== 'net-complexity')) {
expect(entry.rule.length).toBeGreaterThan(10);
@@ -287,10 +287,10 @@ describe('collectHealth over recorded fixture data', () => {
const pr = (id: number, minutes: number, extra: Record<string, unknown> = {}) => run(id, { event: 'pull_request', head_branch: `pr-${id}`,
run_started_at: day(1), updated_at: new Date(Date.parse(day(1)) + minutes * 60_000).toISOString(), ...extra });
expect(wall([pr(70, 6), pr(71, 7), pr(74, 8), pr(72, 30, { conclusion: 'failure' }), run(73, { event: 'workflow_dispatch', run_started_at: day(3), updated_at: day(1) })]).value)
.toBe('free-tests 0.0 min (1 runs); windows-free-tests (successful PR runs) 7.0 min (3 runs)');
.toBe('free-tests 0.0 min (1 runs); windows-smoke (successful PR runs) 7.0 min (3 runs)');
expect(wall([run(73, { event: 'workflow_dispatch', run_started_at: day(3), updated_at: day(1) })]).value)
.toBe('free-tests 0.0 min (1 runs); windows-free-tests (successful PR runs) no successful PR runs in window');
expect(wall([]).rule).toContain('workflow_dispatch duration recordings excluded');
.toBe('free-tests 0.0 min (1 runs); windows-smoke (successful PR runs) no successful PR runs in window');
expect(wall([]).rule).toContain('workflow_dispatch runs excluded');
});
test('a rate-limited API degrades every remote metric to a named unavailable reason, never 0, and keeps the issue open', () => {
+1
View File
@@ -50,6 +50,7 @@ describe('ubi-runner run, verify and quota against a fake API and a fake ssh', (
const err = r.stderr.toString();
expect(r.exitCode, err).toBe(0);
expect(err).toMatch(/UBICLOUD: left=0 owner=thrd1 vm=ubirun-thrd1-\d{10}-[0-9a-f]{8}-t3 others=1\n/);
expect(err).toMatch(/carries a 3h TTL \(this owner's next up or reap destroys it once it expires\); if this process dies first: /);
expect(f.listed()).toBe('eu-central-h1 ubirun-thrd1-9999999999-sib1-t3\n');
// Value: protects=UBI_LEFT_FILE carries the same leftover line pregate-remote.sh parses, apart from the command's output; fails_when=the file is not written or its format drifts from pregate-remote.sh's parser; why_new=the pregate stub hand-copies the format; seam=none; no_claim=real VM teardown (fake API and ssh)
expect(readFileSync(leftFile, 'utf8')).toMatch(/^UBICLOUD: left=0 owner=thrd1 vm=ubirun-thrd1-\d{10}-[0-9a-f]{8}-t3 others=1\n$/);
+3 -3
View File
@@ -210,12 +210,12 @@ describe('ubi-runner run, verify and quota against a fake API and a fake ssh', (
}, 90_000);
test('reap reports a failed list and a failed destroy as failures; UBI_TTL_HOURS rejects leading zeros and more than three digits', () => {
const f = fakeVm({ listed: 'eu-central-h1 ubirun-other-1000000000-bbbb-t1\n' });
const f = fakeVm({ listed: 'eu-central-h1 ubirun-thrd1-1000000000-bbbb-t1\n' });
try {
writeFileSync(join(f.root, 'bin/curl'), '#!/usr/bin/env bash\nout=""; while [ $# -gt 0 ]; do [ "$1" = -o ] && out=$2; shift; done\nbody=$(cat)\ncase "$body" in\n *\'"list"\'*) echo "eu-central-h1 ubirun-other-1000000000-bbbb-t1" > "$out"; printf 200 ;;\n *\'"show"\'*) printf \'state: running\\nsize: standard-16\\nip4: 192.0.2.1\\n\' > "$out"; printf 200 ;;\n *) echo "boom" > "$out"; printf 500 ;;\nesac\n');
writeFileSync(join(f.root, 'bin/curl'), '#!/usr/bin/env bash\nout=""; while [ $# -gt 0 ]; do [ "$1" = -o ] && out=$2; shift; done\nbody=$(cat)\ncase "$body" in\n *\'"list"\'*) echo "eu-central-h1 ubirun-thrd1-1000000000-bbbb-t1" > "$out"; printf 200 ;;\n *\'"show"\'*) printf \'state: running\\nsize: standard-16\\nip4: 192.0.2.1\\n\' > "$out"; printf 200 ;;\n *) echo "boom" > "$out"; printf 500 ;;\nesac\n');
const destroyFails = run(f, ['reap']);
expect(destroyFails.exitCode).toBe(1);
expect(destroyFails.stderr.toString()).toContain('failed to reap eu-central-h1/ubirun-other-1000000000-bbbb-t1');
expect(destroyFails.stderr.toString()).toContain('failed to reap eu-central-h1/ubirun-thrd1-1000000000-bbbb-t1');
expect(destroyFails.stderr.toString()).toContain('reap: 1 of 1 expired VMs could not be destroyed');
writeFileSync(join(f.root, 'bin/curl'), '#!/usr/bin/env bash\nout=""; while [ $# -gt 0 ]; do [ "$1" = -o ] && out=$2; shift; done\ncat >/dev/null; echo "down" > "$out"; printf 503\n');
const listFails = run(f, ['reap']);
+29 -7
View File
@@ -204,25 +204,25 @@ esac
} finally { rmSync(f.root, { recursive: true, force: true }); }
});
test('up reaps expired TTL VMs of any owner first, names its own VM with -t<hours>, and the quota preflight refuses before any create request', () => {
const listed = 'eu-central-h1 ubirun-other-1000000000-bbbb-t1\neu-central-h1 ubirun-peer-9999999999-cccc\n';
test('up reaps this owner\'s expired TTL VMs first and leaves another owner\'s, names its own VM with -t<hours>, and the quota preflight refuses before any create request', () => {
const listed = 'eu-central-h1 ubirun-thrd1-1000000000-hhhh-t1\neu-central-h1 ubirun-peer-9999999999-cccc\n';
const f = fakeApi({ destroy: 200, listed });
try {
const r = Bun.spawnSync(['bash', join(DIR, 'ubi-runner.sh'), 'up'], { env: { ...process.env, PATH: `${join(f.root, 'bin')}:${process.env.PATH}`, UBICLOUD_API_KEY: 'offline', UBI_RUNNER_STATE: join(f.root, 'state'), UBI_POLL_SECONDS: '1', UBI_VM_RECORD: f.record, UBI_OWNER: 'thrd1', UBI_QUOTA_VCPUS: '31' }, timeout: 90_000 });
expect(r.exitCode).toBe(1);
const err = r.stderr.toString();
expect(err).toMatch(/reaping expired eu-central-h1\/ubirun-other-1000000000-bbbb-t1 \(owner other; its TTL ended \d+ min ago\)/);
expect(err).toMatch(/reaping expired eu-central-h1\/ubirun-thrd1-1000000000-hhhh-t1 \(owner thrd1; its TTL ended \d+ min ago\)/);
expect(err).toMatch(/quota preflight refused eu-central-h1\/ubirun-thrd1-\d{10}-[0-9a-f]{8}-t3 \(standard-16\): it needs 16 vCPUs and the project's VMs already use 16 of 31/);
expect(err).toContain('create refused before any request: vCPU quota exhausted');
const destroyed = f.calls().split('\n').filter(l => l.includes('destroy'));
expect(destroyed).toHaveLength(1);
expect(destroyed[0]).toContain('ubirun-other-1000000000-bbbb-t1');
expect(destroyed[0]).toContain('ubirun-thrd1-1000000000-hhhh-t1');
expect(f.calls()).not.toContain('"create"');
expect(existsSync(f.record)).toBe(false);
} finally { rmSync(f.root, { recursive: true, force: true }); }
});
test('UBI_REAP=0 skips the reap and UBI_TTL_HOURS sets the name\'s TTL; reap destroys expired TTLs and this owner\'s old TTL-less names, never unexpired, recent, another owner\'s TTL-less, untagged or foreign ones', () => {
test('UBI_REAP=0 skips the reap and UBI_TTL_HOURS sets the name\'s TTL; reap destroys this owner\'s expired TTLs and old TTL-less names, never unexpired, recent, another owner\'s expired or TTL-less, untagged or foreign ones', () => {
const now = Math.floor(Date.now() / 1000);
const listed = `eu-central-h1 ubirun-other-1000000000-bbbb-t1\neu-central-h1 ubirun-thrd1-9999999999-dddd-t1\neu-central-h1 ubirun-thrd1-1000000000-aaaa\neu-central-h1 ubirun-1000000000-cccc\neu-central-h1 ubirun-thrd1-${now - 3 * 3600}-eeee\neu-central-h1 ubirun-gbra240-1000000000-gggg\neu-central-h1 team-db-1000000000-ffff\n`;
const f = fakeApi({ destroy: 200, listed });
@@ -235,7 +235,8 @@ esac
expect(reap.exitCode).toBe(0);
expect(reap.stderr.toString()).toMatch(/reaping TTL-less eu-central-h1\/ubirun-thrd1-1000000000-aaaa \(owner thrd1; \d+h old, legacy sweep at 4h\)/);
const destroyed = f.calls().split('\n').filter(l => l.includes('destroy')).map(l => l.match(/ubirun-[a-z0-9-]+|team-db-[a-z0-9-]+/)?.[0]).sort();
expect(destroyed).toEqual(['ubirun-other-1000000000-bbbb-t1', 'ubirun-thrd1-1000000000-aaaa']);
expect(destroyed).toEqual(['ubirun-thrd1-1000000000-aaaa']);
expect(reap.stderr.toString()).toContain('reap: left 1 expired VMs of other owners; only their owners reap them');
expect(f.run('reap').stderr.toString()).toContain('reap: no expired VMs');
// Value: protects=UBI_LEGACY_HOURS sets reap's bound for TTL-less ubirun names (a 3h-old name is spared at 4h, reaped at 2h); fails_when=reap ignores UBI_LEGACY_HOURS and keeps a fixed bound; why_new=only the default bound and the validation are tested; seam=none; no_claim=real Ubicloud list and destroy (fake curl)
const lowered = Bun.spawnSync(['bash', join(DIR, 'ubi-runner.sh'), 'reap'], { env: { ...process.env, PATH: `${join(f.root, 'bin')}:${process.env.PATH}`, UBICLOUD_API_KEY: 'offline', UBI_RUNNER_STATE: join(f.root, 'state'), UBI_POLL_SECONDS: '1', UBI_VM_RECORD: f.record, UBI_OWNER: 'thrd1', UBI_LEGACY_HOURS: '2' }, timeout: 90_000 });
@@ -243,13 +244,34 @@ esac
expect(lowered.stderr.toString()).toMatch(/reaping TTL-less eu-central-h1\/ubirun-thrd1-\d{10}-eeee \(owner thrd1; 3h old, legacy sweep at 2h\)/);
expect(f.calls()).toMatch(/ubirun-thrd1-\d{10}-eeee[^\n]*destroy/);
// Value: protects=the legacy sweep never destroys another lane's or an untagged TTL-less VM (GST-324: 22 gbra240 eval VMs lost); fails_when=the sweep matches any owner again; why_new=the cross-owner sweep destroyed a paid eval; seam=none; no_claim=real Ubicloud list and destroy (fake curl)
expect(f.calls()).not.toMatch(/(team-db|dddd|gggg|cccc)[^\n]*destroy/);
expect(f.calls()).not.toMatch(/(team-db|dddd|gggg|cccc|bbbb)[^\n]*destroy/);
const ownerless = Bun.spawnSync(['bash', join(DIR, 'ubi-runner.sh'), 'reap'], { env: { ...process.env, PATH: `${join(f.root, 'bin')}:${process.env.PATH}`, UBICLOUD_API_KEY: 'offline', UBI_RUNNER_STATE: join(f.root, 'state'), UBI_POLL_SECONDS: '1', UBI_VM_RECORD: f.record, UBI_OWNER: '', UBI_LEGACY_HOURS: '1' }, timeout: 90_000 });
expect(ownerless.exitCode).toBe(0);
expect(ownerless.stderr.toString()).not.toContain('reaping TTL-less');
} finally { rmSync(f.root, { recursive: true, force: true }); }
}, 30_000);
// Value: protects=reap and every up destroy only the caller's own VMs, never another owner's, even one whose TTL ran out (the 2026-10-30 owner-scoped-deletes rule; GST-324 lost 22 of gbrain's eval VMs to a cross-owner sweep); fails_when=reap judges another owner's VM by its TTL again; why_new=the old cases asserted the cross-owner TTL reap this rule forbids; seam=none; no_claim=real Ubicloud list and destroy (fake curl)
test('reap and up destroy only the caller\'s own expired VMs: another owner\'s and an untagged expired VM survive (GST-573)', () => {
const listed = 'eu-central-h1 ubirun-other-1000000000-bbbb-t1\neu-central-h1 ubirun-1000000000-cccc-t1\neu-central-h1 ubirun-thrd1-1000000000-hhhh-t1\n';
const f = fakeApi({ destroy: 200, listed });
try {
const reap = f.run('reap');
expect(reap.exitCode).toBe(0);
expect(reap.stderr.toString()).toMatch(/reaping expired eu-central-h1\/ubirun-thrd1-1000000000-hhhh-t1 \(owner thrd1; its TTL ended \d+ min ago\)/);
expect(reap.stderr.toString()).toContain('reap: left 2 expired VMs of other owners; only their owners reap them');
const destroyed = f.calls().split('\n').filter(l => l.includes('destroy'));
expect(destroyed).toHaveLength(1);
expect(destroyed[0]).toContain('ubirun-thrd1-1000000000-hhhh-t1');
const up = Bun.spawnSync(['bash', join(DIR, 'ubi-runner.sh'), 'up'], { env: { ...process.env, PATH: `${join(f.root, 'bin')}:${process.env.PATH}`, UBICLOUD_API_KEY: 'offline', UBI_RUNNER_STATE: join(f.root, 'state'), UBI_POLL_SECONDS: '1', UBI_VM_RECORD: f.record, UBI_OWNER: 'thrd1', UBI_QUOTA_VCPUS: '1' }, timeout: 90_000 });
expect(up.stderr.toString()).toContain('create refused before any request');
expect(up.stderr.toString()).toContain('reap: left 2 expired VMs of other owners; only their owners reap them');
expect(up.stderr.toString()).not.toMatch(/reaping[^\n]*(ubirun-other|ubirun-1000000000-cccc)/);
expect(f.calls().split('\n').filter(l => l.includes('destroy'))).toHaveLength(1);
for (const name of ['ubirun-other-1000000000-bbbb-t1', 'ubirun-1000000000-cccc-t1']) expect(readFileSync(join(f.root, 'listed'), 'utf8')).toContain(name);
} finally { rmSync(f.root, { recursive: true, force: true }); }
}, 90_000);
// Value: protects=a project with no VMs (Ubicloud answers vm list with a bare newline) reads as empty everywhere vm_rows feeds; fails_when=a blank list row becomes a VM named "-" again and reap dies on it or usage asks to show it; why_new=every fake list in this file had at least one VM, so #3678 shipped green; seam=none; no_claim=the real API's empty-body shape (taken from #3678's reading of ubi_cli.rb)
test('an empty project (vm list answers a bare or blank line) has no VMs: reap destroys nothing and exits 0, usage prints total 0 0, list --mine prints nothing (#3678)', () => {
for (const listed of ['\n', ' \n']) {
+50 -3
View File
@@ -14,6 +14,8 @@ const root = path.resolve(import.meta.dir, '..');
const load = (file: string) => Bun.YAML.parse(readFileSync(path.join(root, '.github/workflows', file), 'utf8')) as any;
const windows = load('windows-free-tests.yml');
const native = load('native-qualification.yml');
const smoke = load('windows-smoke.yml');
const smokeText = readFileSync(path.join(root, '.github/workflows/windows-smoke.yml'), 'utf8');
/**
* Ratchet: the Windows-safe subset may not silently shrink. If you deleted or
@@ -22,9 +24,9 @@ const native = load('native-qualification.yml');
*/
const WINDOWS_CURATED_FLOOR = 795;
test('the Windows lane plans, runs one strict shard per job and verifies every result', () => {
expect(Object.keys(windows.on).sort()).toEqual(['pull_request', 'push', 'workflow_call', 'workflow_dispatch']);
expect(windows.on.push).toEqual({ branches: ['main'] });
test('the Windows lane runs nightly and by hand, plans, runs one strict shard per job and verifies every result', () => {
expect(Object.keys(windows.on).sort()).toEqual(['schedule', 'workflow_call', 'workflow_dispatch']);
expect(windows.on.schedule).toEqual([{ cron: '17 9 * * *' }]);
expect(Object.keys(windows.on.workflow_dispatch.inputs)).toEqual(['record_durations']);
expect(Object.keys(windows.on.workflow_call.inputs)).toEqual(['record_durations']);
const plan = windows.jobs['windows-plan'];
@@ -35,6 +37,7 @@ test('the Windows lane plans, runs one strict shard per job and verifies every r
const shard = windows.jobs['windows-free-shard'];
expect(shard['runs-on']).toBe('windows-latest');
expect(shard.needs).toEqual(['windows-plan', 'reuse']);
expect(shard.if).toStartWith('${{ !cancelled() && !inputs.record_durations && ');
expect(shard.strategy).toEqual({ 'fail-fast': false, matrix: '${{ fromJSON(needs.windows-plan.outputs.matrix) }}' });
const run = shard.steps.find((step: any) => step.name === 'Run Windows-safe shard');
expect(run.run).toStartWith('bun run test:windows --ci-run "$RUNNER_TEMP/windows-plan.json" --shard ${{ matrix.shard }} --result ');
@@ -77,6 +80,50 @@ test('a record_durations dispatch times each Windows-safe file alone and uploads
expect(job.steps.at(-1).if).toBe("always() && steps.seed.outcome == 'success'");
});
test('windows-smoke is the PR gate: every pull request and main push, one windows-latest job', () => {
expect(Object.keys(smoke.on).sort()).toEqual(['pull_request', 'push', 'workflow_dispatch']);
expect(smoke.on.pull_request).toEqual({ branches: ['main'] });
expect(smoke.on.push).toEqual({ branches: ['main'] });
expect(smoke.permissions).toEqual({ contents: 'read' });
expect(Object.keys(smoke.jobs)).toEqual(['windows-smoke']);
const job = smoke.jobs['windows-smoke'];
expect(job['runs-on']).toBe('windows-latest');
expect(job.if).toBeUndefined();
const step = (name: string) => job.steps.find((s: any) => s.name === name);
expect(step('./setup installs into an isolated HOME').run).toContain('./setup --host claude');
const start = step('The core skills start').run;
expect(start).toContain('for skill in ship review qa-only investigate browse; do');
expect(start).toContain("grep -q '^SKILL_START_PROTO: 1'");
expect(step("/browse on gstack's own browser runs a piped chain flow from an unrelated directory").run).toContain('| "$BROWSE" chain');
const tests = step('Fastest Windows-safe free tests for install and the core skills');
expect(tests.run).toContain('bun run scripts/test-free-shards.ts --files-from "$RUNNER_TEMP/windows-smoke-files.txt"');
expect(tests.env.GSTACK_FREE_RETRY_FLAKY).toBe('1');
});
test('every windows-smoke test is Windows-safe, seeded fast, and says why it is there', () => {
const run: string = smoke.jobs['windows-smoke'].steps.find((s: any) => s.name === 'Fastest Windows-safe free tests for install and the core skills').run;
const files = run.slice(run.indexOf("<<'LIST'\n") + 9, run.indexOf('\nLIST')).split('\n');
expect(files.length).toBeGreaterThan(0);
expect(new Set(files).size).toBe(files.length);
const { safe } = curateWindowsSafe(collectFreeTestFiles(root), root);
expect(files.filter(file => !safe.includes(file))).toEqual([]);
const seed = JSON.parse(readFileSync(path.join(root, 'scripts/free-test-durations-windows.json'), 'utf8')).durations as Record<string, number>;
expect(files.filter(file => !(seed[file] <= 5_000))).toEqual([]);
expect(files.reduce((sum, file) => sum + seed[file]!, 0)).toBeLessThan(60_000);
for (const file of files) expect(smokeText).toMatch(new RegExp('# ' + path.basename(file, '.test.ts').replace(/[.-]/g, '\\$&') + ' \\([0-9.]+\\): \\S'));
});
test('no Windows job restores or saves the bun install cache', () => {
// GSTA-203 PR 1: a cold install (16-17 s) beat restoring the ~450 MB
// windows-bun entry (33-42 s), and PR merge-ref saves filled the 10 GB quota.
for (const file of ['windows-free-tests.yml', 'windows-smoke.yml', 'windows-setup-e2e.yml']) {
for (const [name, job] of Object.entries<any>(load(file).jobs)) {
const caches = (job.steps ?? []).filter((step: any) => step.uses?.startsWith('actions/cache'));
expect({ file, job: name, caches }).toEqual({ file, job: name, caches: [] });
}
}
});
test('Windows shards use the pinned Node runtime and retain complete logs on every run', () => {
const steps = windows.jobs['windows-free-shard'].steps;
const node = steps.find((step: any) => step.uses?.startsWith('actions/setup-node@'));