Files
gstack/test/ship-release.test.ts
T
Garry Tanandcapy-ai[bot] b6cb70aea1 v1.91.355.0 ship: poll readers in-program, 32 KB returns, gated fix program, unattended push defers the guard offer, calls meter (GSTA-167 calls PR 1) (#3662)
* ship: poll readers in-program, never wait on wakes; 32 KB returns; gated fix program; unattended push defers the guard offer; calls meter (GSTA-167 calls PR 1)

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

* fix: pre-landing review fixes (inventory lib/ship-release.ts as a GSTACK_SESSION_KIND reader)

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

* docs(ship-budget): document calls and dotted --step notes

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

* ship: judge treats a missing severity as invalid; Capy passes rendered briefs via cat (replay C1-34 breaks)

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

* fix: keep the Capy skill under its context budget after the main merge

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-10-31 07:22:56 +00:00

323 lines
24 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=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');
});
});
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('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)');
});
});