Files
gstack/test/ship-release.test.ts
T
Garry Tanandcapy-ai[bot] 914e7b6fb3 Write PR titles, commit messages and branch names in plain English (GST-1825) (#4146)
* Add a check that keeps tracker codes and internal jargon out of PR titles and branch names

gstack-plain-title checks the readable front of a title, branch name, PR opening or changelog entry against the repository's .gstack/plain-titles.json; one trailing issue code stays allowed. A Plain title workflow runs it on every new pull request, and /ship's push refuses a never-pushed branch whose name fails it.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Move the PR body's machine-read lines into a closing Agent notes section

The ship receipt, its runs block and the Plan line now live in one collapsed section at the end of the body, and every reader looks there first while still accepting the old receipt-at-top layout. gstack-post refuses to post a title the plain-title check refuses, and land uses the checked PR title as main's commit title.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Teach /ship to write plain titles, PR openings, commit messages and changelog entries

The README now lists what gstack is for, so a PR opening can name the goal it serves.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Keep tests, size budgets and generated files in step with plain titles and Agent notes

Copy lists that install result-code modules now include the plain-title codes, the ship goldens and size ledger are refreshed, the new workflow is classified, and the pinned prose tests read the new title and body layout.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Record the test-profile list change as non-behavioral for the shared test harness

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Keep text people add below the PR notes, and fix three smaller review findings

The notes section now ends at its own closing tag, so a collapsible added after it survives the next post. The opening check splits the body the same way the notes reader does. Wave PR titles no longer name the area, which the plain-title check could refuse. The Capy line in the PR-body steps now puts Approvals in the notes.

Review-Finding: p1/specialist-batched#1 reader=testing
Review-Finding: p1/red-team#1 reader=red-team
Review-Finding: p1/red-team#2 reader=red-team
Review-Finding: p1/red-team#3 reader=red-team
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Mask credentials in refused-title lines and keep older PRs' plan lines readable

A refused title's printed match is masked by the secret scanner first. The Plan line reader falls back to the whole body when the Agent notes section has none, so a body written before this change keeps its plan when its first receipt lands. The notes section also ends at its own closing tag when its opener is split or marked open, or when a collapsible inside it is left unclosed.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Print a fixed placeholder when a credential in a refused title cannot be masked

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Keep a plain-title policy edit from scheduling every paid eval

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Record the paid-eval list change as non-behavioral for the shared test harness

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Recheck the PR title on every edit so a base change cannot turn a refused title green

Review-Finding: s11/targeted#1 reader=targeted
Review-Finding: s11/adversarial-native#1 reader=adversarial-native
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-11-08 00:59:38 +00:00

488 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* gstack-ship-release (GSTA-167 PR 4): /ship Step 12 (version), Steps 15-16 (finish) and
* Step 17 (push). These are the checks those steps used to spell out as shell blocks,
* moved into the bin; each STOP is a place the prose leaves the decision to the agent.
*/
import { afterEach, describe, expect, setDefaultTimeout, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { BUMP_NEVER_KEY_VERSION } from '../lib/ship-policy';
import { lineOf, RELEASE, shipFixture, type ShipFx } from './helpers/ship-bin-fixture';
setDefaultTimeout(60_000);
const fixtures: ShipFx[] = [];
const fx = (...a: Parameters<typeof shipFixture>) => { const f = shipFixture(...a); fixtures.push(f); return f; };
afterEach(() => { while (fixtures.length) fixtures.pop()!.cleanup(); });
const LANE = 'tests=printf " 3 pass\\n 0 fail\\nRan 3 tests across 1 file. [5.00ms]\\n"';
const read = (f: ShipFx, p: string) => fs.readFileSync(path.join(f.work, p), 'utf8');
const wtree = (f: ShipFx) => f.run(path.join(path.dirname(RELEASE), 'gstack-wtree'), []).stdout.trim();
const snapshot = (f: ShipFx, name = 'post') => {
const out = path.join(f.root, `${name}.json`);
expect(f.run(path.join(path.dirname(RELEASE), 'gstack-docs-candidate'), ['snapshot', '--out', out, '--audit-id', 'a1', '--mode', 'edit', '--base', 'main', '--docs', 'README.md']).status).toBe(0);
return out;
};
describe('version (Step 12)', () => {
// Value: protects=FRESH never picks a level itself (unless the policy pins patch), and a chosen level writes VERSION, the manifest and the CHANGELOG heading through gstack-version-bump; fails_when=the bin guesses a level, or hand-writes versions;
// why_new=moved from Step 12 items 1-5; seam=gstack-version-bump, gstack-next-version; no_claim=the queue's live host read
test('FRESH stops for BUMP_LEVEL; --bump micro writes VERSION, package.json and moves [Unreleased]', () => {
const f = fx();
const ask = f.run(RELEASE, ['version', '--base', 'main']);
expect(ask.status).toBe(3);
expect(ask.stdout).toMatch(/^STOP: BUMP_LEVEL needed \(FRESH, /m);
expect(read(f, 'VERSION')).toBe('1.0.0.0\n');
const w = f.run(RELEASE, ['version', '--base', 'main', '--bump', 'micro', '--why', 'one line']);
expect(w.status).toBe(0);
expect(lineOf(w.stdout, 'VERSION')).toMatch(/^VERSION: 1\.0\.0\.0 -> 1\.0\.0\.1 level=micro state=FRESH /);
expect(read(f, 'VERSION')).toBe('1.0.0.1\n');
expect(JSON.parse(read(f, 'package.json')).version).toBe('1.0.0');
expect(read(f, 'CHANGELOG.md')).toContain('1.0.0.1');
expect(w.stdout).toContain('NEXT: Step 13');
});
// Value: protects=ALREADY_BUMPED keeps its version without a write, DRIFT_UNEXPECTED stops, stamp-at-merge skips, and an explicit version must be above base; fails_when=a re-run double-bumps, a manual manifest edit is overwritten, or a stamp-at-merge repo gets a version;
// why_new=moved from Step 12's state dispatch; seam=gstack-version-bump classify, gstack-ship-policy; no_claim=the rebump question's answer
test('ALREADY_BUMPED keeps, DRIFT_UNEXPECTED stops, stamp-at-merge skips, a version at or below base stops', () => {
const bumped = fx({}, { VERSION: '1.0.1.0\n', 'package.json': '{"name":"fx","version":"1.0.1"}\n' });
const kept = bumped.run(RELEASE, ['version', '--base', 'main']);
expect(lineOf(kept.stdout, 'VERSION')).toMatch(/^VERSION: 1\.0\.0\.0 -> 1\.0\.1\.0 level=patch state=ALREADY_BUMPED .*\(kept, no write\)$/);
expect(read(bumped, 'VERSION')).toBe('1.0.1.0\n');
const drift = fx({}, { 'package.json': '{"name":"fx","version":"9.0.0"}\n' });
const d = drift.run(RELEASE, ['version', '--base', 'main', '--bump', 'micro']);
expect(d.status).toBe(3);
expect(d.stdout).toContain('STOP: package.json disagrees with VERSION');
const stamp = fx({ '.gstack/ship-policy.json': '{"queue_mode":"stamp-at-merge"}\n' });
const s = stamp.run(RELEASE, ['version', '--base', 'main', '--bump', 'micro']);
expect(s.status).toBe(0);
expect(lineOf(s.stdout, 'VERSION')).toContain('skipped (policy: queue_mode stamp-at-merge');
expect(read(stamp, 'VERSION')).toBe('1.0.0.0\n');
const low = fx();
expect(low.run(RELEASE, ['version', '--base', 'main', '--bump', 'micro', '--version', '0.9.0.0']).stdout).toContain('STOP: 0.9.0.0 is not above the base version 1.0.0.0');
});
});
describe('version (Step 12): keeping VERSION and waiting for the bump answer (GST-380)', () => {
const UNATTENDED = { GSTACK_SESSION_KIND: 'unattended' };
const untouched = (f: ShipFx) => {
expect(read(f, 'VERSION')).toBe('1.0.0.0\n');
expect(JSON.parse(read(f, 'package.json')).version).toBe('1.0.0');
expect(read(f, 'CHANGELOG.md')).toContain('## [Unreleased]');
expect(f.git('status', '--porcelain').stdout).toBe('');
};
// Value: protects=a repo whose unit PRs never change VERSION can pass Step 12 with --keep-current or --version <current> on a FRESH branch, and nothing is written or claimed; fails_when=FRESH demands a level before it reads those flags (gstack-evals#85: BUMP_LEVEL needed for all three flags);
// why_new=GST-380; seam=gstack-version-bump classify; no_claim=the queue's live host read
test('FRESH under bump ask: --keep-current and --version <current> keep VERSION with no write', () => {
for (const args of [['--keep-current'], ['--version', '1.0.0.0']]) {
const f = fx();
const r = f.run(RELEASE, ['version', '--base', 'main', ...args]);
expect([r.status, lineOf(r.stdout, 'VERSION')]).toEqual([0, expect.stringMatching(/^VERSION: 1\.0\.0\.0 kept \(FRESH, /)]);
expect(r.stdout).toContain('NEXT: Step 13');
untouched(f);
}
const g = fx();
const incomplete = g.run(RELEASE, ['version', '--base', 'main', '--accept-incomplete']);
expect([incomplete.status, incomplete.stdout.includes('--keep-current')]).toEqual([3, true]);
untouched(g);
});
// Value: protects=in a session no person answers (unattended, spawned, OpenClaw, headless) a bump under bump ask waits for the owner's words before anything is written; fails_when=the bin writes VERSION on a --bump the agent took as the recommended option (gstack-evals#89 wrote 0.0.2 before anyone was asked);
// why_new=GST-380; seam=gstack-session-kind's classification, gstack-version-bump write; no_claim=that the words came from the owner
test('an unattended --bump under bump ask stops with BUMP_ANSWER_REQUIRED and writes nothing until --answer', () => {
const sessions: Record<string, string>[] = [UNATTENDED, { GSTACK_SESSION_KIND: 'spawned' }, { OPENCLAW_SESSION: '1' }, { GSTACK_HEADLESS: '1' }];
for (const env of sessions) {
const f = fx();
const r = f.run(RELEASE, ['version', '--base', 'main', '--bump', 'micro', '--why', 'small'], env);
expect([r.status, /^STOP: BUMP_ANSWER_REQUIRED/m.test(r.stdout), r.stdout.includes('--keep-current')]).toEqual([3, true, true]);
untouched(f);
}
const f = fx();
expect(f.run(RELEASE, ['version', '--base', 'main', '--bump', 'micro', '--answer', '--why', 'x'], UNATTENDED).status).toBe(2);
for (const bad of [' ', 'a\nb', 'x'.repeat(201)]) expect(f.run(RELEASE, ['version', '--base', 'main', '--bump', 'micro', '--answer', bad], UNATTENDED).status).toBe(2);
untouched(f);
const ok = f.run(RELEASE, ['version', '--base', 'main', '--bump', 'micro', '--answer', 'micro is fine'], UNATTENDED);
expect([ok.status, read(f, 'VERSION')]).toEqual([0, '1.0.0.1\n']);
const rebump = f.run(RELEASE, ['version', '--base', 'main', '--version', '2.0.0.0'], UNATTENDED);
expect([rebump.status, rebump.stdout.includes('BUMP_ANSWER_REQUIRED'), rebump.stdout.includes('Ask: --version 2.0.0.0 (recommended), keep 1.0.0.1'), read(f, 'VERSION')]).toEqual([3, true, true, '1.0.0.1\n']);
expect(f.run(RELEASE, ['version', '--base', 'main', '--version', '1.0.0.0'], UNATTENDED).stdout).toContain('STOP: 1.0.0.0 is not above the base version 1.0.0.0');
const rewritten = f.run(RELEASE, ['version', '--base', 'main', '--version', '2.0.0.0', '--answer', 'go to 2.0.0.0'], UNATTENDED);
expect([rewritten.status, read(f, 'VERSION')]).toEqual([0, '2.0.0.0\n']);
const both = fx().run(RELEASE, ['version', '--base', 'main', '--bump', 'micro', '--version', '1.0.0.5'], UNATTENDED);
expect([both.status, both.stdout.includes('Ask: --bump micro --version 1.0.0.5 (recommended)')]).toEqual([3, true]);
const patch = fx({ '.gstack/ship-policy.json': '{"bump":"patch"}\n' });
expect([patch.run(RELEASE, ['version', '--base', 'main'], UNATTENDED).status, read(patch, 'VERSION')]).toEqual([0, '1.0.1.0\n']);
});
// Value: protects=bump never keeps VERSION with no flag and no question, refuses a bump or a different --version, and stops on a branch that moved VERSION; fails_when=a never repo gets a write, a question, or a silently kept bump;
// why_new=GST-380 added bump never; seam=gstack-ship-policy show-effective, gstack-version-bump classify; no_claim=the release process's own bump
test('bump never keeps VERSION with no flag, refuses a bump, and stops on a bumped branch', () => {
const policy = { '.gstack/ship-policy.json': JSON.stringify({ bump: 'never', min_gstack_version: BUMP_NEVER_KEY_VERSION }) + '\n' };
const f = fx(policy);
const kept = f.run(RELEASE, ['version', '--base', 'main'], UNATTENDED);
expect([kept.status, lineOf(kept.stdout, 'VERSION')]).toEqual([0, 'VERSION: 1.0.0.0 kept (policy: bump never; no write)']);
for (const args of [['--bump', 'micro', '--answer', 'yes'], ['--version', '1.0.0.1']]) {
const r = f.run(RELEASE, ['version', '--base', 'main', ...args]);
expect([r.status, r.stdout.includes('STOP: policy bump never: unit PRs keep VERSION at 1.0.0.0')]).toEqual([3, true]);
}
untouched(f);
const bumped = fx(policy, { VERSION: '1.0.0.1\n' });
const moved = bumped.run(RELEASE, ['version', '--base', 'main']);
expect([moved.status, moved.stdout.includes('STOP: VERSION moved from 1.0.0.0 to 1.0.0.1 but the policy says bump never')]).toEqual([3, true]);
const stale = fx(policy, { VERSION: '1.0.0.1\n', 'package.json': '{"name":"fx","version":"0.9.0"}\n' });
const drift = stale.run(RELEASE, ['version', '--base', 'main']);
expect([drift.status, drift.stdout.includes('but the policy says bump never'), drift.stdout.includes('REPAIR:')]).toEqual([3, true, false]);
});
// Value: protects=a --bump beside --keep-current or --version <current> is a contradiction the bin names instead of picking one; fails_when=it silently keeps or silently bumps;
// why_new=GST-380 review F8 (flag precedence); seam=none; no_claim=-
test('--bump or --version <other> with --keep-current or --version <current> stops and writes nothing', () => {
const f = fx();
for (const args of [['--keep-current'], ['--version', '1.0.0.0']]) {
const r = f.run(RELEASE, ['version', '--base', 'main', '--bump', 'micro', ...args]);
expect([r.status, r.stdout.includes('STOP: --bump micro moves VERSION and')]).toEqual([3, true]);
}
const keepAndMove = f.run(RELEASE, ['version', '--base', 'main', '--keep-current', '--version', '1.0.0.5']);
expect([keepAndMove.status, keepAndMove.stdout.includes('STOP: --version 1.0.0.5 moves VERSION and --keep-current keeps it')]).toEqual([3, true]);
untouched(f);
});
});
describe('finish (Steps 15-16)', () => {
// Value: protects=finish commits only release metadata with the trailer, then checks the reviewed-tree route, docs freshness, each lane receipt and the pregate; fails_when=it commits feature files, skips a stage, or pushes;
// why_new=moved from Steps 15-16; seam=gstack-wtree, gstack-docs-candidate compare, gstack-evidence, gstack-pregate; no_claim=the stage 5 report
test('commits release metadata with the trailer and clears stages 2-4 and the pregate; feature files stop it', () => {
const f = fx();
const reviewed = wtree(f);
expect(f.run(RELEASE, ['version', '--base', 'main', '--bump', 'micro']).status).toBe(0);
const post = snapshot(f);
f.write({ 'src/c.ts': 'export const c = 3;\n' });
const feature = f.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed, '--docs-post', post, '--lane', LANE]);
expect(feature.status).toBe(3);
expect(feature.stdout).toContain('STOP: uncommitted changes outside release metadata: src/c.ts');
fs.rmSync(path.join(f.work, 'src/c.ts'));
const ok = f.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed, '--docs-post', post, '--lane', LANE, '--trailer', 'Co-Authored-By: Fixture Bot <bot@example.com>']);
expect(ok.stdout).toMatch(/^COMMIT: [0-9a-f]{12} chore: bump version and changelog \(v1\.0\.0\.1\)/m);
expect(f.git('log', '-1', '--format=%B').stdout).toContain('Co-Authored-By: Fixture Bot <bot@example.com>');
expect(lineOf(ok.stdout, 'ROUTE')).toMatch(/behavior=0 docs=0 metadata=\d+/);
expect(lineOf(ok.stdout, 'DOCS')).toContain('fresh');
expect(ok.stdout).toMatch(/^(LANE: tests pass|EVIDENCE: FRESH label=tests)/m);
expect(lineOf(ok.stdout, 'PREGATE')).not.toBe('');
expect(ok.stdout).toContain('NEXT: Step 16 stage 5');
expect(ok.status).toBe(0);
expect(f.git('ls-remote', '--heads', 'origin', 'feat').stdout).toBe('');
});
// Value: protects=finish --plan resolves the brief's Plan line before any stage: require + Plan: required stops with no commit; a resolvable line prints PLAN: first; fails_when=finishPlan runs after the stages, its stop is dropped, or the line is not first; why_new=plan-record.test.ts covers the helper only; seam=none; no_claim=approved-row resolution (exempt line used)
test('finish --plan: under GSTACK_PLAN_RECORD=require "Plan: required" stops before any stage; a resolvable line under warn prints PLAN: first', () => {
const f = fx();
const reviewed = wtree(f);
expect(f.run(RELEASE, ['version', '--base', 'main', '--bump', 'micro']).status).toBe(0);
const post = snapshot(f);
const args = ['finish', '--base', 'main', '--reviewed-tree', reviewed, '--docs-post', post, '--lane', LANE];
const head = f.git('rev-parse', 'HEAD').stdout.trim();
const stop = f.run(RELEASE, [...args, '--plan', 'Plan: required'], { GSTACK_PLAN_RECORD: 'require' });
expect(stop.status).toBe(3);
expect(stop.stdout).toMatch(/^PLAN: refused .*\(PLAN_NOT_APPROVED\)/m);
expect(stop.stdout).toMatch(/^STOP: .*\(PLAN_NOT_APPROVED\); fix: /m);
expect(stop.stdout).not.toContain('COMMIT:');
expect(f.git('rev-parse', 'HEAD').stdout.trim()).toBe(head);
const ok = f.run(RELEASE, [...args, '--plan', 'exempt: bug fix'], { GSTACK_PLAN_RECORD: 'warn' });
expect(ok.stdout.split('\n')[0]).toBe('PLAN: exempt bug fix');
expect(ok.stdout).toMatch(/^COMMIT: /m);
expect(ok.status).toBe(0);
});
// Value: protects=under changelog_fragments the branch's own fragment is release metadata: version writes nothing and names it in NEXT, finish commits it as the release commit and the route counts it as metadata, and another fragment is still a feature file; fails_when=finish stops on changelog.d/<slug>.md or routes it to re-review (/ship #3648 fix-check 4, CRITICAL);
// why_new=releasePaths predates changelog_fragments (#3654); seam=gstack-ship-policy show-effective, gstack-wtree; no_claim=the merge step's fold
test('under changelog_fragments the branch fragment is release metadata and Step 12 names it', () => {
const f = fx({ '.gstack/ship-policy.json': '{"changelog_fragments":{"dir":"changelog.d"},"min_gstack_version":"1.91.336.0"}\n', 'changelog.d/README.md': 'Fragments.\n' });
const reviewed = wtree(f);
const v = f.run(RELEASE, ['version', '--base', 'main']);
expect(v.status).toBe(0);
expect(lineOf(v.stdout, 'VERSION')).toContain('skipped (policy: changelog_fragments');
expect(v.stdout).toContain('NEXT: Step 13: write the entry as changelog.d/feat.md');
expect(read(f, 'VERSION')).toBe('1.0.0.0\n');
const post = snapshot(f);
const args = ['finish', '--base', 'main', '--reviewed-tree', reviewed, '--docs-post', post, '--lane', LANE];
f.write({ 'changelog.d/other.md': 'Not this branch.\n' });
expect(f.run(RELEASE, args).stdout).toContain('STOP: uncommitted changes outside release metadata: changelog.d/other.md');
fs.rmSync(path.join(f.work, 'changelog.d/other.md'));
f.write({ 'changelog.d/feat.md': 'One entry.\n' });
const ok = f.run(RELEASE, args);
expect(ok.stdout).toMatch(/^COMMIT: [0-9a-f]{12} chore: changelog and TODOS \(changelog\.d\/feat\.md\)/m);
expect(lineOf(ok.stdout, 'ROUTE')).toMatch(/behavior=0 docs=0 metadata=1/);
expect(ok.status).toBe(0);
});
// Value: protects=a behavior change after review inserts the re-review route, an authored doc change needs --docs-rechecked, stale docs and failing or absent lanes stop; fails_when=any of these continues to push;
// why_new=moved from Step 16 stages 2-4; seam=same; no_claim=the repair itself
test('behavior after review, unrechecked docs, stale docs, a failing lane and no lanes each stop', () => {
const f = fx();
const reviewed = wtree(f);
const post = snapshot(f);
f.write({ 'src/a.ts': 'export const a = 9;\n' }); f.commit('late fix');
const behavior = f.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed, '--docs-post', post, '--lane', LANE]);
expect(behavior.stdout).toContain('STOP: behavior, tests or build inputs changed since review (src/a.ts): insert 5–11.5 → 12–14 → 16');
const g = fx();
const reviewed2 = wtree(g);
g.write({ 'docs/guide.md': '# guide\n' }); g.commit('docs');
const post2 = snapshot(g, 'post2');
const docs = g.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed2, '--docs-post', post2, '--lane', LANE]);
expect(docs.stdout).toContain('STOP: authored docs changed since review (docs/guide.md)');
g.write({ 'README.md': '# changed after the audit\n' }); g.commit('readme');
const stale = g.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed2, '--docs-post', post2, '--lane', LANE, '--docs-rechecked']);
expect(lineOf(stale.stdout, 'DOCS')).toBe('DOCS: stale (README.md)');
expect(stale.stdout).toContain('STOP: documentation inputs changed since the accepted audit');
const h = fx();
const reviewed3 = wtree(h);
const post3 = snapshot(h, 'post3');
const failing = h.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed3, '--docs-post', post3, '--lane', 'tests=exit 1']);
expect(failing.status).toBe(3);
expect(failing.stdout).toContain('STOP: lane tests failed or ran zero tests');
expect(h.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed3, '--docs-post', post3]).stdout).toContain("STOP: no test lanes: require Step 5's explicit untested-scope approval");
expect(h.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed3]).status).toBe(2);
});
// Value: protects=a dependency or script edit to package.json, and any lockfile edit, after review takes the re-review route; a version-only bump stays release metadata; fails_when=finish treats package.json or bun.lock as metadata whatever changed in them (/ship #3648 pass-1 security, CRITICAL);
// why_new=releasePaths exempted the manifests and lockfiles by name; seam=gstack-wtree, git; no_claim=the re-review itself
test('a script or dependency edit to package.json, or a bun.lock edit, after review stops finish', () => {
for (const [file, text] of [['package.json', '{"name":"fx","version":"1.0.0","scripts":{"test":"exit 0"}}\n'], ['bun.lock', '{"lockfileVersion":1}\n']]) {
const f = fx();
const reviewed = wtree(f);
const post = snapshot(f);
f.write({ [file]: text }); f.commit(`late ${file}`);
const r = f.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed, '--docs-post', post, '--lane', LANE]);
expect([r.status, r.stdout.includes(`STOP: behavior, tests or build inputs changed since review (${file})`)]).toEqual([3, true]);
}
const g = fx();
const reviewed = wtree(g);
const built = g.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed, '--docs-post', snapshot(g), '--lane', LANE, '--build', `printf '{"name":"fx","version":"1.0.0","scripts":{"postinstall":"x"}}\\n' > package.json`]);
expect([built.status, built.stdout.includes('STOP: behavior, tests or build inputs changed since review (package.json)')]).toEqual([3, true]);
});
});
describe('push (Step 17)', () => {
// Value: protects=push refuses a branch other than Step 1's saved one, waits for the one-time guard offer, refuses open gate findings on a ready PR without approval, then pushes once and reports ALREADY_PUSHED after; fails_when=it pushes past an unanswered offer or a ready PR with open gate findings, or pushes twice;
// why_new=moved from Step 17's idempotency block; seam=gstack-config, gh, git ls-remote, gstack-pregate sealed; no_claim=the guard block's hook install
test('an unanswered guard offer stops; open gate findings on a ready PR stop; then it pushes once', () => {
const f = fx();
expect(f.run(RELEASE, ['push']).status).toBe(2);
const moved = f.run(RELEASE, ['push', '--branch', 'other']);
expect(moved.status).toBe(3);
expect(moved.stdout).toContain('STOP: HEAD is on feat, not the branch Step 1 saved (other)');
const unanswered = f.run(RELEASE, ['push', '--branch', 'feat']);
expect(unanswered.status).toBe(3);
expect(unanswered.stdout).toContain("STOP: Step 17's one-time pre-push guard offer is unanswered");
fs.mkdirSync(f.state, { recursive: true });
fs.writeFileSync(path.join(f.state, '.redact-prepush-prompted'), '');
f.gh('echo 12');
const gate = f.run(RELEASE, ['push', '--branch', 'feat', '--gate-open', '2']);
expect(gate.status).toBe(3);
expect(gate.stdout).toContain('STOP: 2 open gate finding(s) and ready PR #12');
const pushed = f.run(RELEASE, ['push', '--branch', 'feat']);
expect(pushed.status).toBe(0);
expect(lineOf(pushed.stdout, 'PUSH')).toMatch(/^PUSH: pushed [0-9a-f]{12} to origin\/feat$/);
expect(lineOf(f.run(RELEASE, ['push', '--branch', 'feat']).stdout, 'PUSH')).toMatch(/^PUSH: ALREADY_PUSHED /);
});
// Value: protects=an unattended push defers the one-time guard offer instead of stopping, and leaves the offer open for the next interactive session (no marker, no hook); fails_when=unattended push stops on the offer (replays 8G #34, 8G #36 and 9G each spent calls on it), writes .redact-prepush-prompted, or installs a hook;
// why_new=the offer stopped every unattended run; seam=gstack-config, git push; no_claim=the interactive guard block
test('unattended, an unanswered guard offer is deferred: it pushes, writes no marker and installs no hook', () => {
const f = fx();
const r = f.run(RELEASE, ['push', '--branch', 'feat'], { GSTACK_SESSION_KIND: 'unattended' });
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(lineOf(r.stdout, 'REDACT_PREPUSH')).toBe('REDACT_PREPUSH: false prompted=no deferred=unattended');
expect(lineOf(r.stdout, 'PUSH')).toMatch(/^PUSH: pushed [0-9a-f]{12} to origin\/feat$/);
expect(fs.existsSync(path.join(f.state, '.redact-prepush-prompted'))).toBe(false);
expect(fs.existsSync(path.join(f.work, '.git', 'hooks', 'pre-push'))).toBe(false);
});
// Value: protects=a rejected push stops with the merge-and-reverify route and never force-pushes; fails_when=the bin retries with force or reports success;
// why_new=moved from Step 17's failure rules; seam=git push; no_claim=the merge
test('a non-fast-forward push stops with the merge route', () => {
const f = fx();
fs.mkdirSync(f.state, { recursive: true });
fs.writeFileSync(path.join(f.state, '.redact-prepush-prompted'), '');
expect(f.run(RELEASE, ['push', '--branch', 'feat']).status).toBe(0);
f.git('reset', '-q', '--hard', 'HEAD~1');
f.write({ 'src/z.ts': 'export const z = 1;\n' }); f.commit('diverge');
const r = f.run(RELEASE, ['push', '--branch', 'feat']);
expect(r.status).toBe(3);
expect(r.stdout).toContain('STOP: non-fast-forward');
expect(r.stdout).toContain('never force-push');
});
// Value: protects=a never-pushed branch whose name breaks the repo's plain-words policy stops before the push, and a plain name pushes; fails_when=a coded or jargon branch name reaches origin, or a plain one is refused;
// why_new=plain public record (docs/ship-policy.md#plain-titles); seam=.gstack/plain-titles.json, git ls-remote; no_claim=branches already on origin
test('a never-pushed branch name with a tracker code or coined term in its front stops; a plain name pushes', () => {
const f = fx();
f.write({ '.gstack/plain-titles.json': JSON.stringify({ codes: ['\\bGST-\\d+\\b'], terms: ['pregate'], allow: [], since: '2026-11-08', exempt_branch_prefixes: ['release/'] }) });
f.commit('policy');
f.git('branch', '-m', 'gst-1825-pregate-fix');
const r = f.run(RELEASE, ['push', '--branch', 'gst-1825-pregate-fix'], { GSTACK_SESSION_KIND: 'unattended' });
expect(r.status, r.stdout + r.stderr).toBe(3);
expect(lineOf(r.stdout, 'BRANCH')).toContain('BRANCH: refused');
expect(r.stdout).toContain('is not plain words');
f.git('branch', '-m', 'faster-checks-gst-1825');
const ok = f.run(RELEASE, ['push', '--branch', 'faster-checks-gst-1825'], { GSTACK_SESSION_KIND: 'unattended' });
expect(ok.status, ok.stdout + ok.stderr).toBe(0);
expect(lineOf(ok.stdout, 'PUSH')).toMatch(/^PUSH: pushed [0-9a-f]{12} to origin\/faster-checks-gst-1825$/);
});
});
describe('gstack-ship-release push: branch names', () => {
// Value: protects=push's open-PR lookup passes the branch to gh as one argv item; fails_when=a branch named with $(...) runs a command before the push guards;
// why_new=GSTA-167 /ship review of #3633 (security, CRITICAL); seam=gh; no_claim=gh's own handling of the name
test('a branch name with shell syntax is never executed by the gate lookup', () => {
const f = fx();
const name = 'x$(touch${IFS}PWNED)';
expect(f.git('checkout', '-qb', name).status).toBe(0);
fs.mkdirSync(f.state, { recursive: true });
fs.writeFileSync(path.join(f.state, '.redact-prepush-prompted'), '');
f.gh('echo 12');
f.run(RELEASE, ['push', '--branch', name, '--gate-open', '1']);
expect(fs.existsSync(path.join(f.work, 'PWNED'))).toBe(false);
});
});
describe('GST-1031: a non-behavioral harness re-pin after review is release metadata', () => {
// Value: protects=a scripts/harness-pins/<path>.json edit after review that only records changed harness files as non-behavioral at the same
// version routes as metadata, so a clean round 2 is not sent back through 5-11.5, while a version bump, a pattern change or an entry
// without its reason stays behavior; fails_when=the re-pin stops finish, or a bump or bare re-pin routes as metadata;
// why_new=GSTA-150 GST-1027 (icm_01M6ERWWBZ3VWXZY07F2W5ZSFB); seam=route; no_claim=that the recorded reason is true
const PIN = 'scripts/harness-pins/test/helpers/x.ts.json';
const pin = (version: number, entry: Record<string, unknown>, patterns = ['test/helpers/x.ts']) => ({
'scripts/harness-version.json': JSON.stringify({ version, patterns }) + '\n',
[PIN]: JSON.stringify(entry) + '\n',
});
const finish = (after: Record<string, string>) => {
const f = fx();
f.write(pin(6, { blob: 'a'.repeat(40), since: 6 })); f.commit('pin');
const reviewed = wtree(f);
const post = snapshot(f);
f.write(after); f.commit('re-pin');
return f.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed, '--docs-post', post, '--lane', LANE]).stdout;
};
test('a non-behavioral re-pin is metadata; a bump, a pattern change or an entry without its reason is behavior', () => {
const repin = finish(pin(6, { blob: 'b'.repeat(40), since: 6, nonBehavioral: 'comment-only edit: no verdict can change' }));
expect(lineOf(repin, 'ROUTE')).toMatch(/behavior=0 docs=0 metadata=1/);
expect(repin).not.toContain('STOP: behavior');
expect(finish(pin(7, { blob: 'b'.repeat(40), since: 7 }))).toContain(`STOP: behavior, tests or build inputs changed since review (${PIN},scripts/harness-version.json)`);
expect(finish(pin(6, { blob: 'b'.repeat(40), since: 6 }))).toContain(`STOP: behavior, tests or build inputs changed since review (${PIN})`);
expect(finish(pin(6, { blob: 'b'.repeat(40), since: 6, nonBehavioral: 'comment-only edit: no verdict can change' }, ['test/helpers/**']))).toContain(`STOP: behavior, tests or build inputs changed since review (${PIN},scripts/harness-version.json)`);
});
test('the manifest regaining a files table is behavior', () => {
const f = { 'scripts/harness-version.json': JSON.stringify({ version: 6, patterns: ['test/helpers/x.ts'], files: {} }) + '\n' };
expect(finish(f)).toContain('STOP: behavior, tests or build inputs changed since review (scripts/harness-version.json)');
});
});
describe('GSTA-167 part 4a: the post-review route stays path-only', () => {
// Value: protects=F1/F2, a data file edited after review sends the ship back through review even when no code names it,
// because the route never applies the data rule; fails_when=a JSON edit after review takes the docs-rechecked route; seam=classifyPath
test('a JSON edited after review is a behavior change, not docs', () => {
const f = fx();
const reviewed = wtree(f);
const post = snapshot(f);
f.write({ 'wiki/post/data.json': '{"a": 1}\n' }); f.commit('late data');
const r = f.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed, '--docs-post', post, '--lane', LANE]);
expect(r.stdout).toContain('STOP: behavior, tests or build inputs changed since review (wiki/post/data.json)');
});
});
describe('push: the pre-push housekeeping guards (GST-1138)', () => {
// Value: protects=push runs gstack-pregate --stage guards before every push and stops on a failing guard with its fix, pushing nothing;
// a pass recorded at HEAD's exact tree is reused instead of rerun; fails_when=a housekeeping red reaches CI, a failed guard still pushes,
// or a guard reruns on a tree it already passed; why_new=gbrain 64 of 93 red heads were housekeeping (FOUN-64), #3908's context-budget red;
// seam=gstack-pregate --stage guards; no_claim=CI's own verdict
const policy = (command: string) => JSON.stringify({ guards: [{ id: 'housekeeping', when: ['src/**'], command, fix: 'regenerate the golden' }] }) + '\n';
const ready = (f: ShipFx) => { fs.mkdirSync(f.state, { recursive: true }); fs.writeFileSync(path.join(f.state, '.redact-prepush-prompted'), ''); };
const pushedHead = (f: ShipFx) => f.git('ls-remote', '--heads', 'origin', 'refs/heads/feat').stdout.trim();
test('a failing guard stops the push with its fix and nothing is pushed', () => {
const f = fx({ '.gstack/pregate.json': policy('echo golden is stale; exit 4') });
ready(f);
const r = f.run(RELEASE, ['push', '--branch', 'feat']);
expect(r.status).toBe(3);
expect(lineOf(r.stdout, 'GUARDS')).toMatch(/^GUARDS: fail .*housekeeping/);
expect(r.stdout).toContain('STOP: the pre-push guards did not pass');
expect(r.stdout).toContain('regenerate the golden');
expect(pushedHead(f)).toBe('');
});
test('a passing guard pushes; the next push at the same tree reuses the pass instead of rerunning it', () => {
const f = fx({ '.gstack/pregate.json': policy('echo ran >> ../guard-runs') });
ready(f);
const first = f.run(RELEASE, ['push', '--branch', 'feat']);
expect(first.status, first.stdout + first.stderr).toBe(0);
expect(lineOf(first.stdout, 'GUARDS')).toMatch(/^GUARDS: pass .*1 guard\(s\) passed: housekeeping/);
expect(lineOf(first.stdout, 'PUSH')).toMatch(/^PUSH: pushed /);
const again = f.run(RELEASE, ['push', '--branch', 'feat']);
expect(lineOf(again.stdout, 'GUARDS')).toMatch(/^GUARDS: pass \(reused from pregate-guards\.json at tree [0-9a-f]{12}: /);
expect(fs.readFileSync(path.join(f.root, 'guard-runs'), 'utf8')).toBe('ran\n');
f.write({ 'src/c.ts': 'export const c = 3;\n' }); f.commit('more');
expect(lineOf(f.run(RELEASE, ['push', '--branch', 'feat']).stdout, 'GUARDS')).not.toContain('reused');
expect(fs.readFileSync(path.join(f.root, 'guard-runs'), 'utf8')).toBe('ran\nran\n');
});
test('an uncommitted or untracked file the guards saw stops the push: the verdict must describe the pushed tree', () => {
const f = fx({ '.gstack/pregate.json': policy('test -f src/needed.ts') });
ready(f);
f.write({ 'src/needed.ts': 'export const n = 1;\n' });
const r = f.run(RELEASE, ['push', '--branch', 'feat']);
expect(r.status).toBe(3);
expect(lineOf(r.stdout, 'GUARDS')).toContain('not certified');
expect(r.stdout).toContain('STOP: the working tree differs from HEAD');
expect(pushedHead(f)).toBe('');
});
test('a base that exists on origin but is not fetched, a mistyped --base, a dangling origin/HEAD and an unreadable policy stop the push', () => {
const f = fx({ '.gstack/pregate.json': policy('exit 4') });
ready(f);
f.git('update-ref', '-d', 'refs/remotes/origin/main');
const unfetched = f.run(RELEASE, ['push', '--branch', 'feat']);
expect(unfetched.status).toBe(3);
expect(lineOf(unfetched.stdout, 'GUARDS')).toMatch(/^GUARDS: refused POLICY_SOURCE_UNAVAILABLE/);
expect(unfetched.stdout).toContain('STOP: the pre-push guards could not run');
f.git('fetch', '-q', 'origin', 'main:refs/remotes/origin/main');
const typo = f.run(RELEASE, ['push', '--branch', 'feat', '--base', 'mian']);
expect(typo.status).toBe(3);
expect(lineOf(typo.stdout, 'GUARDS')).toMatch(/^GUARDS: refused /);
f.git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/retired-default');
const dangling = f.run(RELEASE, ['push', '--branch', 'feat']);
expect(dangling.status).toBe(3);
expect(lineOf(dangling.stdout, 'GUARDS')).toMatch(/^GUARDS: refused /);
expect(pushedHead(f)).toBe('');
const broken = fx({ '.gstack/pregate.json': '{not json\n' });
ready(broken);
const refused = broken.run(RELEASE, ['push', '--branch', 'feat']);
expect(refused.status).toBe(3);
expect(lineOf(refused.stdout, 'GUARDS')).toMatch(/^GUARDS: refused /);
});
test("finish's preflight record is reused only at HEAD's tree with passing regen and guards rows", () => {
const f = fx({ '.gstack/pregate.json': policy('echo ran >> ../guard-runs') });
ready(f);
const tree = f.git('rev-parse', 'HEAD^{tree}').stdout.trim();
const record = (at: string, guards: string) => JSON.stringify({ tree: at, checks: [{ id: 'regen', status: 'pass' }, { id: 'guards', status: guards }] });
const finishRecord = path.join(f.work, '.git', 'gstack', 'pregate-finish-preflight.json');
fs.mkdirSync(path.dirname(finishRecord), { recursive: true });
const runs = () => (fs.existsSync(path.join(f.root, 'guard-runs')) ? fs.readFileSync(path.join(f.root, 'guard-runs'), 'utf8') : '');
for (const [at, guards] of [[tree, 'fail'], ['0'.repeat(40), 'pass']]) {
fs.rmSync(path.join(f.work, '.git', 'gstack', 'pregate-guards.json'), { force: true });
fs.writeFileSync(finishRecord, record(at, guards));
fs.rmSync(path.join(f.root, 'guard-runs'), { force: true });
expect(lineOf(f.run(RELEASE, ['push', '--branch', 'feat']).stdout, 'GUARDS')).not.toContain('reused');
expect(runs()).toBe('ran\n');
f.git('push', '-q', 'origin', '--delete', 'feat');
}
fs.rmSync(path.join(f.work, '.git', 'gstack', 'pregate-guards.json'), { force: true });
fs.writeFileSync(finishRecord, record(tree, 'pass'));
fs.rmSync(path.join(f.root, 'guard-runs'), { force: true });
const reused = f.run(RELEASE, ['push', '--branch', 'feat']);
expect(lineOf(reused.stdout, 'GUARDS')).toMatch(/^GUARDS: pass \(reused from pregate-finish-preflight\.json at tree /);
expect(runs()).toBe('');
expect(lineOf(reused.stdout, 'PUSH')).toMatch(/^PUSH: pushed /);
});
});