Files
gstack/test/restamp-command-boundary.test.ts
Garry Tanandcapy-ai[bot] e474a4fb53 v1.91.336.0 ship-queue: assign VERSION/CHANGELOG at merge time (changelog fragments + gstack-ship-queue land) (#3594)
* feat(ship-policy): changelog_fragments key; restamp folds fragments; tree receipt accepts the fold

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

* feat(ship-queue): land, the per-PR merge step; Version Gate enforces fragments

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

* feat(ship-queue): land --repair after drift; hook base sha; docs and CHANGELOG

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

* test: timeout on the land gh shim's git spawn

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

* feat(ship): /ship writes a CHANGELOG fragment under changelog_fragments; land checks the depth-checked receipt (#3567)

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

* test(land): keeps the base's four-part package.json (#3557)

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

* fix(ship-queue): land waits for CI through runScriptSync (script-invocation tripwire)

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

* test: land, fragment and Version Gate cases from /ship's coverage audit

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

* test(windows): leave test/ship-queue-land.test.ts out of the Windows-safe subset (gh shim cannot launch there)

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

* docs(ship-policy): rollback lands open fragment PRs first; restamp folds only with the key on

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

* fix(land): close pre-landing review findings

land without --no-wait now compares the pushed branch head and polls the
stamp commit's checks, refuses a PR whose base is not the stamped base,
releases the lock on a pre-push failure, keeps --break-lock off drift
locks, resumes from a lost journal via the stamp commit's pin, honours
history merge-only, and refuses a fragment whose text is already released
on the base. Version Gate stops applying open claims to a fragments stamp.

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

* fix(land): close adversarial and outside-voice findings

land reconciles a merge GitHub completed behind an error reply, refuses
--break-lock against a holder that just entered a push or a merge, keeps
a repair takeover on the lock itself (any checkout can land the repair
PR), resumes --repair after a failed PR create, releases the lock on
QUEUE_LAND_BASE_BUSY, never writes through a symlinked release file,
keeps a package.json version source's other edits, and refuses an empty
object id instead of pushing a deletion. A carried-back fragment is
matched on whole released lines. Under changelog_fragments Version Gate
accepts a stamped head only as a [pin, base] merge-step commit with valid
fragments.

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

* feat(land): refuse a pin without a READY receipt; symlinked release files refuse before restamp

Under changelog_fragments, gstack-ship-queue ready records a READY receipt
(refs/notes/gstack-trains) once its pregate, full_e2e and /ship receipt
checks pass, and land refuses a pin without one (fix: run
gstack-ship-queue ready --pr N), --dry-run included (GStack Minder ruling,
2026-10-29). The merged tree's release files may not be symlinks, so
restamp never writes outside the scratch worktree. --repair adopts an
existing branch only when it holds land's repair commit.

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

* fix(pregate): clear's stamp-only substitution applies the changelog_fragments fold; docs match land and ready

gstack-pregate clear and gstack-tree-receipt now classify through one
classifyFiles, so under changelog_fragments a receipt for the fragment head
clears the stamped head when the stamp is exactly the fold. The ship-policy
reference names ready's receipt push, land's own check polling, the key-off
refusal and the /ship changelog_fragments line.

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

* docs(pregate, ship-policy): the fragment fold's two receipt rules, and clear applies them too

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

* ship: write the Steps 12-16 range with an ASCII hyphen so the skeleton fits maxSkeletonBytes

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-30 16:54:58 +00:00

220 lines
14 KiB
TypeScript

/**
* C1/C2 trust boundary: a policy file never authorizes execution. Pins the
* refusal (exit 3, REPO_COMMANDS_NOT_ALLOWED, `nothing written`, commands
* listed), the unreviewed bootstrap path, the release-tool contract (allowed
* outputs, failure), and that every refusal leaves the tree and the journal
* untouched.
*/
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 { BASE_POLICY, cleanup, makeFixtureRepo, makePr, runBin, type FixtureRepo } from './helpers/restamp-fixture';
const repos: FixtureRepo[] = [];
const fixture = (opts?: Parameters<typeof makeFixtureRepo>[0]) => { const r = makeFixtureRepo(opts); repos.push(r); return r; };
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
const treeOf = (repo: FixtureRepo) => repo.git('status', '--porcelain') + '|' + repo.git('rev-parse', 'HEAD');
const journal = (repo: FixtureRepo) => path.join(repo.work, '.gstack', 'tmp', 'restamp-journal.json');
describe('repo commands run only with --allow-repo-commands', () => {
test('standalone: the declared commands are listed, exit 3, nothing written (no file, no journal, no worktree)', () => {
const repo = fixture();
makePr(repo, 'feat-a');
const before = treeOf(repo);
const r = runBin(repo, 'gstack-restamp', ['--next', '--base', 'main']);
expect(r.status).toBe(EXIT.refused);
expect(r.err).toMatch(/repo commands not executed \(pass --allow-repo-commands\).*nothing written.*\(REPO_COMMANDS_NOT_ALLOWED\)/);
expect(r.out).toContain('COMMAND: release_tool="node gen.js" cwd=.');
expect(r.out).toMatch(/GSTACK_RESULT: skill=restamp status=refused run=/);
expect(treeOf(repo)).toBe(before);
expect(fs.existsSync(journal(repo))).toBe(false);
expect(repo.git('worktree', 'list').split('\n')).toHaveLength(1);
const json = JSON.parse(runBin(repo, 'gstack-restamp', ['--next', '--base', 'main', '--json']).out);
expect(json).toMatchObject({ status: 'refused', code: 'REPO_COMMANDS_NOT_ALLOWED', result: 'refused' });
expect(json.commands).toEqual([{ key: 'release_tool', command: 'node gen.js', cwd: '.' }]);
});
test('a policy without commands needs no flag; --dry-run still writes nothing', () => {
const repo = fixture({ policy: { ...BASE_POLICY, release_tool: null, release_outputs: [] } });
makePr(repo, 'feat-a');
const before = treeOf(repo);
const r = runBin(repo, 'gstack-restamp', ['--next', '--base', 'main', '--dry-run']);
expect(r.status).toBe(EXIT.ok);
expect(r.out).toContain('STAMP_VERSION: 1.2.4.0');
expect(r.out).toContain('stage release_tool: skipped (no release_tool in policy)');
expect(r.out).toMatch(/^\+1\.2\.4\.0$/m);
expect(treeOf(repo)).toBe(before);
expect(fs.existsSync(journal(repo))).toBe(false);
});
test('--policy-from head is the unreviewed bootstrap path: refused without the flag, labeled head (unreviewed) with it', () => {
const repo = fixture({ policy: null });
makePr(repo, 'feat-a');
fs.writeFileSync(path.join(repo.work, '.gstack', 'ship-policy.json'), JSON.stringify({ ...BASE_POLICY, release_tool: null }) + '\n');
const refused = runBin(repo, 'gstack-restamp', ['--next', '--base', 'main', '--policy-from', 'head']);
expect(refused.status).toBe(EXIT.refused);
expect(refused.err).toMatch(/--policy-from head.*needs --allow-repo-commands.*nothing written.*\(REPO_COMMANDS_NOT_ALLOWED\)/);
const base = runBin(repo, 'gstack-restamp', ['--next', '--base', 'main', '--dry-run']);
expect(base.out).toContain('policy=none (defaults;');
expect(base.out).toContain('queue_mode=claim-at-open');
const head = runBin(repo, 'gstack-restamp', ['--next', '--base', 'main', '--policy-from', 'head', '--allow-repo-commands', '--dry-run']);
expect(head.status).toBe(EXIT.ok);
expect(head.out).toContain('policy=head (unreviewed)');
expect(head.out).toContain('queue_mode=stamp-at-merge');
});
test('a PR cannot smuggle a command: the committed base copy wins over the branch copy', () => {
const repo = fixture({ policy: { ...BASE_POLICY, release_tool: null } });
makePr(repo, 'feat-a');
fs.writeFileSync(path.join(repo.work, '.gstack', 'ship-policy.json'), JSON.stringify({ ...BASE_POLICY, release_tool: 'touch PWNED' }) + '\n');
repo.git('add', '-A'); repo.git('commit', '-q', '-m', 'smuggle');
const r = runBin(repo, 'gstack-restamp', ['--next', '--base', 'main', '--allow-repo-commands', '--dry-run']);
expect(r.status).toBe(EXIT.ok);
expect(r.out).toContain('stage release_tool: skipped (no release_tool in policy)');
expect(fs.existsSync(path.join(repo.work, 'PWNED'))).toBe(false);
});
test('POLICY_SOURCE_UNAVAILABLE (exit 3) when origin/<base> is unresolvable; the working-tree policy is not consulted', () => {
const repo = fixture();
makePr(repo, 'feat-a');
const r = runBin(repo, 'gstack-restamp', ['--next', '--base', 'develop', '--allow-repo-commands']);
expect(r.status).toBe(EXIT.refused);
expect(r.err).toMatch(/policy source unavailable.*nothing written.*fix: git fetch origin develop --depth=1 \(POLICY_SOURCE_UNAVAILABLE\)/);
expect(fs.existsSync(journal(repo))).toBe(false);
});
});
describe('release-tool contract', () => {
test('an output outside the allowed set refuses before any write (RESTAMP_OUTPUT_OUTSIDE_ALLOWED)', () => {
const repo = fixture({ policy: { ...BASE_POLICY, release_tool: 'node gen.js && echo x > stray.txt', release_outputs: ['generated/**'] } });
makePr(repo, 'feat-a');
const before = treeOf(repo);
const r = runBin(repo, 'gstack-restamp', ['--next', '--base', 'main', '--allow-repo-commands']);
expect(r.status).toBe(EXIT.refused);
expect(r.err).toMatch(/release_tool wrote outside the allowed set: stray\.txt.*nothing written.*\(RESTAMP_OUTPUT_OUTSIDE_ALLOWED\)/);
expect(treeOf(repo)).toBe(before);
expect(fs.existsSync(path.join(repo.work, 'stray.txt'))).toBe(false);
expect(fs.readFileSync(path.join(repo.work, 'VERSION'), 'utf8')).toBe('1.2.3.0\n');
expect(repo.git('worktree', 'list').split('\n')).toHaveLength(1);
});
test('a path declared in release_outputs is allowed; the same path undeclared is not', () => {
const declared = fixture({ policy: { ...BASE_POLICY, release_tool: 'node gen.js && echo x > generated/extra.txt' } });
makePr(declared, 'feat-a');
expect(runBin(declared, 'gstack-restamp', ['--next', '--base', 'main', '--allow-repo-commands']).status).toBe(EXIT.ok);
expect(fs.existsSync(path.join(declared.work, 'generated', 'extra.txt'))).toBe(true);
const undeclared = fixture({ policy: { ...BASE_POLICY, release_outputs: [] } });
makePr(undeclared, 'feat-a');
const r = runBin(undeclared, 'gstack-restamp', ['--next', '--base', 'main', '--allow-repo-commands']);
expect(r.status).toBe(EXIT.refused);
expect(r.err).toMatch(/generated\/digest\.md.*\(RESTAMP_OUTPUT_OUTSIDE_ALLOWED\)/);
});
test('a failing release tool is RESTAMP_RELEASE_TOOL_FAILED with nothing written to the branch', () => {
const repo = fixture({ policy: { ...BASE_POLICY, release_tool: 'node -e "process.exit(7)"' } });
makePr(repo, 'feat-a');
const before = treeOf(repo);
const r = runBin(repo, 'gstack-restamp', ['--next', '--base', 'main', '--allow-repo-commands']);
expect(r.status).toBe(EXIT.refused);
expect(r.err).toMatch(/release_tool exited 7.*nothing written to the branch.*\(RESTAMP_RELEASE_TOOL_FAILED\)/);
expect(treeOf(repo)).toBe(before);
});
test('the staging worktree is isolated: the release tool cannot see the branch checkout and runs in the staged content', () => {
const repo = fixture({ policy: { ...BASE_POLICY, release_tool: 'node -e "require(\'fs\').writeFileSync(\'generated/cwd.txt\', process.cwd())"' } });
makePr(repo, 'feat-a');
const r = runBin(repo, 'gstack-restamp', ['--next', '--base', 'main', '--allow-repo-commands']);
expect(r.status).toBe(EXIT.ok);
const cwd = fs.readFileSync(path.join(repo.work, 'generated', 'cwd.txt'), 'utf8');
expect(cwd).not.toBe(repo.work);
expect(cwd).toContain('gstack-restamp-staging-');
expect(fs.existsSync(cwd)).toBe(false);
expect(repo.git('worktree', 'list').split('\n')).toHaveLength(1);
});
});
describe('restamp_command (field wave CD3): the repo owns the stamp inside the staging worktree', () => {
const RESTAMP_JS = [
"const fs = require('fs'); const { execSync } = require('child_process');",
"if (execSync('git status --porcelain', { encoding: 'utf8', timeout: 30000 }).trim()) { console.error('FAIL: the working tree is not clean'); process.exit(1); }",
"const v = process.argv[2];",
"if (!v || v !== process.env.GSTACK_RESTAMP_VERSION) { console.error('version argument and GSTACK_RESTAMP_VERSION disagree'); process.exit(2); }",
"if (process.env.FIXTURE_FAIL) process.exit(7);",
"fs.writeFileSync('VERSION', (process.env.FIXTURE_WRONG_VERSION ? '9.9.9.9' : v) + '\\n');",
"const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); pkg.version = v.split('.').slice(0, 3).join('.'); fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\\n');",
"fs.writeFileSync('generated/digest.md', '# digest v' + v + '\\nbody\\n');",
"if (process.env.FIXTURE_SAVE) { const prev = Number(execSync('git show ' + process.env.GSTACK_RESTAMP_BASE_SHA + ':generated/save.txt', { encoding: 'utf8', timeout: 30000 })); fs.writeFileSync('generated/save.txt', String(prev + 1) + '\\n'); }",
"if (process.env.FIXTURE_STRAY) fs.writeFileSync('stray.txt', 'x\\n');",
"execSync('git add -A', { timeout: 30000 }); execSync('git -c user.name=r -c user.email=r@example.com -c commit.gpgsign=false commit -q -m restamp', { timeout: 30000 });",
].join('\n') + '\n';
const POLICY = { ...BASE_POLICY, release_tool: null, restamp_command: 'node restamp.js' };
function delegated(): FixtureRepo {
const repo = fixture({ policy: POLICY });
fs.writeFileSync(path.join(repo.work, 'restamp.js'), RESTAMP_JS);
repo.git('add', 'restamp.js'); repo.git('commit', '-q', '-m', 'repo restamp tool'); repo.git('push', '-q', 'origin', 'main');
makePr(repo, 'feat-a');
return repo;
}
test('it runs first, on a clean checkout of HEAD, with the allocated version as argument and GSTACK_RESTAMP_VERSION; gstack\'s version writer is skipped', () => {
const repo = delegated();
const r = runBin(repo, 'gstack-restamp', ['--next', '--base', 'main', '--allow-repo-commands']);
expect(r.status).toBe(EXIT.ok);
expect(r.out).toContain('STAMP_VERSION: 1.2.4.0');
expect(r.out).toContain('stage restamp_command: done (node restamp.js 1.2.4.0)');
expect(r.out).toContain('stage version: skipped (restamp_command owns the version)');
expect(r.out.indexOf('stage restamp_command:')).toBeLessThan(r.out.indexOf('stage version:'));
expect(fs.readFileSync(path.join(repo.work, 'VERSION'), 'utf8')).toBe('1.2.4.0\n');
expect(fs.readFileSync(path.join(repo.work, 'generated', 'digest.md'), 'utf8')).toBe('# digest v1.2.4.0\nbody\n');
expect(repo.git('worktree', 'list').split('\n')).toHaveLength(1);
});
test('a failure, a wrong version, an output outside the allowed set or a dirty caller tree writes nothing to the branch', () => {
const cases: Array<[Record<string, string>, RegExp, number]> = [
[{ FIXTURE_FAIL: '1' }, /restamp_command exited 7.*nothing written to the branch.*\(RESTAMP_RELEASE_TOOL_FAILED\)/, EXIT.refused],
[{ FIXTURE_WRONG_VERSION: '1' }, /restamp_command left VERSION at 9\.9\.9\.9, expected 1\.2\.4\.0.*nothing written.*\(RESTAMP_VERSION_SOURCE\)/, EXIT.refused],
[{ FIXTURE_STRAY: '1' }, /restamp_command wrote outside the allowed set: stray\.txt.*\(RESTAMP_OUTPUT_OUTSIDE_ALLOWED\)/, EXIT.refused],
];
for (const [env, err, status] of cases) {
const repo = delegated();
const before = treeOf(repo);
const r = runBin(repo, 'gstack-restamp', ['--next', '--base', 'main', '--allow-repo-commands'], env);
expect(r.status).toBe(status);
expect(r.err).toMatch(err);
expect(treeOf(repo)).toBe(before);
expect(fs.readFileSync(path.join(repo.work, 'VERSION'), 'utf8')).toBe('1.2.3.0\n');
}
const dirty = delegated();
fs.writeFileSync(path.join(dirty.work, 'notes.txt'), 'uncommitted\n');
const before = treeOf(dirty);
const d = runBin(dirty, 'gstack-restamp', ['--next', '--base', 'main', '--allow-repo-commands']);
expect(d.status).toBe(EXIT.fail);
expect(d.err).toMatch(/restamp_command needs a clean HEAD, index and worktree.*notes\.txt.*nothing written.*\(RESTAMP_CONFLICT\)/);
expect(treeOf(dirty)).toBe(before);
expect(fs.existsSync(journal(dirty))).toBe(false);
});
test('it gets the release predecessor in GSTACK_RESTAMP_BASE_SHA, so a repo counter (a SAVE_VERSION) computes predecessor + 1', () => {
const repo = fixture({ policy: POLICY });
fs.writeFileSync(path.join(repo.work, 'restamp.js'), RESTAMP_JS);
fs.writeFileSync(path.join(repo.work, 'generated', 'save.txt'), '41\n');
repo.git('add', '-A'); repo.git('commit', '-q', '-m', 'repo restamp tool and counter'); repo.git('push', '-q', 'origin', 'main');
makePr(repo, 'feat-save');
fs.writeFileSync(path.join(repo.work, 'generated', 'save.txt'), '99\n');
repo.git('commit', '-q', '-am', 'a branch-side counter edit the hook must not read');
const r = runBin(repo, 'gstack-restamp', ['--next', '--base', 'main', '--allow-repo-commands'], { FIXTURE_SAVE: '1' });
expect(r.status).toBe(EXIT.ok);
expect(fs.readFileSync(path.join(repo.work, 'generated', 'save.txt'), 'utf8')).toBe('42\n');
});
test('without --allow-repo-commands it is listed and refused like release_tool', () => {
const repo = delegated();
const r = runBin(repo, 'gstack-restamp', ['--next', '--base', 'main']);
expect(r.status).toBe(EXIT.refused);
expect(r.out).toContain('COMMAND: restamp_command="node restamp.js" cwd=.');
});
});