Files
gstack/test/version-gate-fragments.test.ts
T
Garry Tanandcapy-ai[bot] 3df7484bc7 v1.91.339.0 ship-queue: stamped merge-train prefixes and train land (GSTA-154 PR B) (#3595)
* 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>

* feat(ship-queue): stamped train prefixes S_i with the self-check (PR B)

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

* feat(ship-queue): train build stamps S_i under fragments; train land publishes S_i exactly; backstop covers exact trees (PR B)

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

* docs: stamped trains and train land; QUEUE_TRAIN_STAMP_MISMATCH anchor

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

* captain templates: fragments instead of [Unreleased]; no slot call for stamped trains

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

* test: timeouts on the train-land gh wrapper's sync spawns

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

* feat(ship-queue): train land checks the depth-checked receipt (#3567); regenerate the doctor anchors

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

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

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

* test(windows): leave test/ship-queue-train-land.test.ts out of the Windows-safe subset (same gh shim as the train tests)

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

* fix(train-land): close pre-landing review and outside-voice findings

- train land checks commit-tree and stamps with a fixed identity and date, journals before the push, and
  adopts its own stamp head on a rerun; a failed commit-tree never pushes a branch deletion
- a symlinked release file in the merged tree refuses before the train stamp writes
- mergeStep merges a conflicted manifest three-way with the predecessor's version (mergeManifest, shared with land)
- train land refuses a PR retargeted off the base and a failed base refresh
- Version Gate skips a released fragment carried in by a stacked pin instead of counting its bump
- bisect of a stamped train tests T0 and S_1..S_k

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

* fix(train-land): a rerun whose head is still the pin re-enters the stamp step after a journaled, unpushed stamp

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

* fix(train-land): prove the record's S_i is on the gated chain before stamping; a merge main does not show yet pends

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

* fix(train-land): pin S_i to the gated head's exact prefix; an out-of-order slot refuses without freezing; stamps never sign

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

* fix(train-land): pin S_i to slot position; a failed push or unreadable PR never reads as a move; reruns of landed slots confirm; T0 in the out-of-order refusal

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

* feat(train-land): verify the whole stamped chain from git before landing; require changelog_fragments.required_checks green on the stamp commit

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

* docs(ship-policy, troubleshooting, harness): train land refusals, resume and required checks; stamped bisect; three-way manifest merges

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 19:23:01 +00:00

148 lines
8.4 KiB
TypeScript

/**
* Version Gate under changelog_fragments (docs/ship-policy.md#changelog-fragments): an unstamped head
* keeps its release-version fields at its merge base and carries a fragment (or a legacy [Unreleased]
* entry, or is a bot PR); other manifest edits pass; a merge-step stamp commit must carry base plus the
* effective bump of the fragments its pin folds.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { BASE_POLICY, cleanup, makeFixtureRepo, ROOT, type FixtureRepo } from './helpers/restamp-fixture';
const SCRIPT = path.join(ROOT, 'scripts', 'compare-pr-version.ts');
const POLICY = { ...BASE_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.92.0.0' };
const repos: FixtureRepo[] = [];
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
const fixture = () => { const r = makeFixtureRepo({ policy: POLICY, prefix: 'version-gate-frag-' }); repos.push(r); return r; };
const write = (r: FixtureRepo, rel: string, text: string) => { fs.mkdirSync(path.dirname(path.join(r.work, rel)), { recursive: true }); fs.writeFileSync(path.join(r.work, rel), text); };
const commit = (r: FixtureRepo, msg: string) => { r.git('add', '-A'); r.git('commit', '-q', '-m', msg); return r.git('rev-parse', 'HEAD'); };
function run(r: FixtureRepo, head: string, extra: Record<string, string> = {}, claimed: unknown[] = []) {
const next = path.join(r.root, 'next.json');
fs.writeFileSync(next, JSON.stringify({ version: '1.2.4.0', base_version: '1.2.3.0', claimed }));
const prVersion = r.git('show', head + ':VERSION').trim();
const out = Bun.spawnSync([process.execPath, 'run', SCRIPT, next, '3600'], { cwd: r.work, env: { ...process.env, ...r.env, PR_VERSION: prVersion, BASE_REF: 'main', HEAD_SHA: head, PR_AUTHOR: 'garrytan', ...extra }, timeout: 30_000, stdout: 'pipe', stderr: 'pipe' });
return { code: out.exitCode, out: out.stdout.toString() + out.stderr.toString() };
}
describe('version gate: changelog fragments', () => {
test('a fragment head passes, a dependency or script edit beside it too', () => {
const r = fixture();
r.git('checkout', '-q', '-b', 'f');
write(r, 'changelog.d/f.md', '- f\n');
const pkg = JSON.parse(fs.readFileSync(path.join(r.work, 'package.json'), 'utf8'));
pkg.dependencies['right-pad'] = '2.0.0'; pkg.scripts.build = 'node build.js';
write(r, 'package.json', JSON.stringify(pkg, null, 2) + '\n');
const res = run(r, commit(r, 'f'));
expect(res.code).toBe(0);
expect(res.out).toContain('its entry is 1 CHANGELOG fragment(s)');
});
test('a premature version change fails; no entry fails for a person and passes for a bot', () => {
const r = fixture();
r.git('checkout', '-q', '-b', 'g');
write(r, 'changelog.d/g.md', '- g\n');
write(r, 'VERSION', '1.2.9.0\n');
const early = run(r, commit(r, 'g'));
expect(early.code).toBe(1);
expect(early.out).toContain('under changelog_fragments only the merge step stamps');
r.git('checkout', '-q', '-B', 'self', 'origin/main');
write(r, 'VERSION', '1.2.4.0\n'); write(r, 'self.txt', 'self\n');
const self = run(r, commit(r, 'a lane stamps the expected version itself, with no fragment'));
expect(self.code).toBe(1);
expect(self.out).toContain('under changelog_fragments only the merge step stamps');
r.git('checkout', '-q', '-B', 'h', 'origin/main');
write(r, 'h.txt', 'h\n');
const head = commit(r, 'h');
const none = run(r, head);
expect(none.code).toBe(1);
expect(none.out).toContain('add a CHANGELOG fragment: changelog.d/<slug>.md');
expect(run(r, head, { PR_AUTHOR: 'dependabot[bot]' }).code).toBe(0);
});
test('an unchanged branch on an advanced base passes; an invalid fragment fails', () => {
const r = fixture();
r.git('checkout', '-q', '-b', 'k');
write(r, 'changelog.d/k.md', '- k\n');
const head = commit(r, 'k');
r.git('checkout', '-q', 'main');
write(r, 'VERSION', '1.2.4.0\n'); write(r, 'other.txt', 'x\n');
commit(r, 'main moves'); r.git('push', '-q', 'origin', 'main');
expect(run(r, head).code).toBe(0);
r.git('checkout', '-q', '-B', 'bad', 'origin/main');
write(r, 'changelog.d/bad.md', '## [9.9.9.9]\n- bad\n');
const bad = run(r, commit(r, 'bad'));
expect(bad.code).toBe(1);
expect(bad.out).toContain('changelog.d/bad.md: a fragment carries no release heading');
});
test('a merge-step stamp commit [pin, base] carries base plus its fragments\' effective bump', () => {
const r = fixture();
r.git('checkout', '-q', '-b', 'm');
write(r, 'changelog.d/m.md', '---\nbump: minor\n---\n- m\n');
const pin = commit(r, 'm');
const stamp = (version: string) => {
r.git('checkout', '-q', '-f', '--detach', pin);
fs.rmSync(path.join(r.work, 'changelog.d', 'm.md'));
write(r, 'VERSION', version + '\n');
write(r, 'CHANGELOG.md', fs.readFileSync(path.join(r.work, 'CHANGELOG.md'), 'utf8').replace('# Changelog\n\n', '# Changelog\n\n## [' + version + '] - 2026-10-29\n\n- m\n\n'));
r.git('add', '-A');
return r.git('commit-tree', r.git('write-tree'), '-p', pin, '-p', r.git('rev-parse', 'origin/main'), '-m', 'v' + version + ' stamp');
};
expect(run(r, stamp('1.3.0.0')).code).toBe(0);
const claimedToo = run(r, stamp('1.3.0.0'), {}, [{ pr: 77, branch: 'legacy', version: '1.3.0.0' }]);
expect(claimedToo.code).toBe(0);
expect(claimedToo.out).toContain('the land lock serializes allocation, so open claims do not apply');
const wrong = run(r, stamp('1.2.4.0'));
expect(wrong.code).toBe(1);
expect(wrong.out).toContain('must carry v1.3.0.0');
});
test('a stacked child\'s stamp counts only its own fragment once its parent has landed', () => {
const r = fixture();
const release = (version: string, line: string) => write(r, 'CHANGELOG.md', fs.readFileSync(path.join(r.work, 'CHANGELOG.md'), 'utf8').replace('# Changelog\n\n', '# Changelog\n\n## [' + version + '] - 2026-10-29\n\n' + line + '\n\n'));
r.git('checkout', '-q', '-b', 'p');
write(r, 'changelog.d/p.md', '---\nbump: minor\n---\n- p\n');
commit(r, 'p');
r.git('checkout', '-q', '-b', 'c');
write(r, 'changelog.d/c.md', '- c\n');
const child = commit(r, 'c');
r.git('checkout', '-q', '-B', 'main', 'origin/main');
write(r, 'VERSION', '1.3.0.0\n');
release('1.3.0.0', '- p');
commit(r, 'v1.3.0.0 (#p)');
r.git('push', '-q', 'origin', 'main');
r.git('checkout', '-q', '-f', '--detach', child);
fs.rmSync(path.join(r.work, 'changelog.d'), { recursive: true });
r.git('checkout', 'origin/main', '--', 'CHANGELOG.md');
write(r, 'VERSION', '1.3.1.0\n');
release('1.3.1.0', '- c');
r.git('add', '-A');
const stamp = r.git('commit-tree', r.git('write-tree'), '-p', child, '-p', r.git('rev-parse', 'origin/main'), '-m', 'v1.3.1.0 stamp');
const res = run(r, stamp);
expect(res.code, res.out).toBe(0);
expect(res.out).toContain('carries v1.3.1.0');
});
test('the workflow runs on every PR (no path filter), so a PR without a release file still proves its fragment', () => {
const workflow = Bun.YAML.parse(fs.readFileSync(path.join(ROOT, '.github', 'workflows', 'version-gate.yml'), 'utf8')) as any;
expect(workflow.on.pull_request.paths).toBeUndefined();
expect(workflow.on.pull_request.branches).toEqual(['main']);
});
});
// Generated by /ship Step 7 coverage audit (GSTA-154 PR A, pass 1).
describe('version gate: legacy entries under changelog fragments', () => {
// Value: protects=a person's PR with a legacy [Unreleased] entry and no fragment still passes once fragments are on;
// fails_when=the [Unreleased] fallback in classifyTrainHead is dropped, so in-flight PRs fail with "add a CHANGELOG fragment";
// why_new=existing gate tests cover fragment, bot and no-entry heads only; seam=none; no_claim=GitHub workflow wiring (script run directly)
test('an [Unreleased] entry without a fragment passes and names that entry', () => {
const r = fixture();
r.git('checkout', '-q', '-b', 'legacy');
write(r, 'legacy.txt', 'l\n');
write(r, 'CHANGELOG.md', fs.readFileSync(path.join(r.work, 'CHANGELOG.md'), 'utf8').replace('# Changelog\n', '# Changelog\n\n## [Unreleased]\n\n- legacy entry\n'));
const res = run(r, commit(r, 'legacy'));
expect(res.code, res.out).toBe(0);
expect(res.out).toContain('the CHANGELOG change is confined to [Unreleased]');
});
});