mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
* 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>
220 lines
14 KiB
TypeScript
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=.');
|
|
});
|
|
});
|