mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +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> * 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>
140 lines
8.6 KiB
TypeScript
140 lines
8.6 KiB
TypeScript
/**
|
|
* Stamped train prefixes (lib/ship-queue-train-stamp.ts): S_i = stamp(merge(S_(i-1), pin_i)) keeps every
|
|
* earlier release, folds only slot i's own fragments (stacked pins included), runs the versions in ORDER
|
|
* with each slot's effective bump, and a tampered earlier entry or a product change is refused.
|
|
*/
|
|
import { afterEach, describe, expect, test } from 'bun:test';
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import { mergePolicy } from '../lib/ship-policy';
|
|
import { buildChain } from '../lib/ship-queue-train';
|
|
import { stampChain, stampMismatch, type StampChainOptions } from '../lib/ship-queue-train-stamp';
|
|
import type { TrainSlot } from '../lib/ship-queue-train-record';
|
|
import { BASE_POLICY, cleanup, makeFixtureRepo, type FixtureRepo } from './helpers/restamp-fixture';
|
|
|
|
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()!); });
|
|
|
|
function pin(r: FixtureRepo, name: string, from: string, fragment: string): string {
|
|
r.git('checkout', '-q', '-B', name, from);
|
|
fs.writeFileSync(path.join(r.work, name + '.txt'), name + '\n');
|
|
fs.mkdirSync(path.join(r.work, 'changelog.d'), { recursive: true });
|
|
fs.writeFileSync(path.join(r.work, 'changelog.d', name + '.md'), fragment);
|
|
r.git('add', '-A'); r.git('commit', '-q', '-m', name);
|
|
return r.git('rev-parse', 'HEAD');
|
|
}
|
|
function chain(r: FixtureRepo, heads: Array<[string, string]>): StampChainOptions {
|
|
const baseSha = r.git('rev-parse', 'origin/main');
|
|
const slots = heads.map(([pr, head]) => ({ pr, head, ready_at: '2026-10-29T00:00:00Z', screen: { mode: 'none' } }) as unknown as TrainSlot);
|
|
const policy = mergePolicy(POLICY, 'test').policy;
|
|
const built = buildChain(r.work, baseSha, slots, policy);
|
|
if (!('prefixes' in built)) throw new Error('chain conflicted');
|
|
return { repoRoot: r.work, base: 'main', policy, policyLabel: 'test', baseSha, slots, plain: built.prefixes, allowRepoCommands: false, now: new Date('2026-10-29T12:00:00Z') };
|
|
}
|
|
const show = (r: FixtureRepo, rev: string) => r.git('show', rev);
|
|
|
|
describe('stamped train prefixes', () => {
|
|
test('independent pins: S2 keeps S1\'s release, versions run in order with each slot\'s bump, fragments are gone', () => {
|
|
const r = makeFixtureRepo({ policy: POLICY, prefix: 'train-stamp-' }); repos.push(r);
|
|
const a = pin(r, 'a', 'origin/main', '- a change\n');
|
|
const b = pin(r, 'b', 'origin/main', '---\nbump: minor\n---\n- b change\n');
|
|
const o = chain(r, [['1', a], ['2', b]]);
|
|
const out = stampChain(o);
|
|
if ('code' in out) throw new Error(out.message);
|
|
expect(out.stamped.map(s => s.version)).toEqual(['1.2.4.0', '1.3.0.0']);
|
|
const s2 = out.stamped[1]!.commit;
|
|
expect(show(r, s2 + ':VERSION')).toBe('1.3.0.0');
|
|
const cl = show(r, s2 + ':CHANGELOG.md');
|
|
expect(cl.indexOf('## [1.3.0.0] - 2026-10-29')).toBeLessThan(cl.indexOf('## [1.2.4.0] - 2026-10-29'));
|
|
expect(cl).toContain('- a change');
|
|
expect(cl).toContain('- b change');
|
|
expect(r.git('ls-tree', '-r', '--name-only', s2, '--', 'changelog.d')).toBe('');
|
|
expect(r.git('rev-parse', s2 + '^1')).toBe(out.stamped[0]!.commit);
|
|
expect(r.git('rev-parse', s2 + '^2')).toBe(b);
|
|
}, 60_000);
|
|
|
|
test('stacked pins: the child folds only its own fragment; the parent\'s entry appears once', () => {
|
|
const r = makeFixtureRepo({ policy: POLICY, prefix: 'train-stamp-' }); repos.push(r);
|
|
const p = pin(r, 'p', 'origin/main', '- parent change\n');
|
|
const c = pin(r, 'c', p, '- child change\n');
|
|
const out = stampChain(chain(r, [['1', p], ['2', c]]));
|
|
if ('code' in out) throw new Error(out.message);
|
|
const cl = show(r, out.stamped[1]!.commit + ':CHANGELOG.md');
|
|
expect(cl.split('- parent change').length).toBe(2);
|
|
const top = cl.slice(cl.indexOf('## [1.2.5.0]'), cl.indexOf('## [1.2.4.0]'));
|
|
expect(top).toContain('- child change');
|
|
expect(top).not.toContain('- parent change');
|
|
}, 60_000);
|
|
|
|
test('a base with a four-part package.json listed in stamp_files keeps four parts in every S_i, read from S_(i-1)', () => {
|
|
const policy = { ...POLICY, stamp_files: ['VERSION', 'package.json', 'CHANGELOG.md', 'changelog.d/*.md'], stamp_patterns: ['"version": "{version}"'] };
|
|
const r = makeFixtureRepo({ policy, prefix: 'train-stamp-' }); repos.push(r);
|
|
const pkg = JSON.parse(fs.readFileSync(path.join(r.work, 'package.json'), 'utf8'));
|
|
fs.writeFileSync(path.join(r.work, 'package.json'), JSON.stringify({ ...pkg, version: '1.2.3.0' }, null, 2) + '\n');
|
|
fs.rmSync(path.join(r.work, 'package-lock.json'));
|
|
r.git('add', '-A'); r.git('commit', '-q', '-m', 'four-part manifest'); r.git('push', '-q', 'origin', 'main');
|
|
const a = pin(r, 'a', 'origin/main', '- a change\n');
|
|
const b = pin(r, 'b', 'origin/main', '- b change\n');
|
|
const o = { ...chain(r, [['1', a], ['2', b]]), policy: mergePolicy(policy, 'test').policy };
|
|
const out = stampChain(o);
|
|
if ('code' in out) throw new Error(out.message);
|
|
expect(out.stamped.map(s => JSON.parse(show(r, s.commit + ':package.json')).version)).toEqual(['1.2.4.0', '1.2.5.0']);
|
|
}, 60_000);
|
|
|
|
test('a pin\'s manifest edit beside the version line survives the predecessor\'s bump', () => {
|
|
const r = makeFixtureRepo({ policy: POLICY, prefix: 'train-stamp-' }); repos.push(r);
|
|
const a = pin(r, 'a', 'origin/main', '- a change\n');
|
|
r.git('checkout', '-q', '-B', 'b', 'origin/main');
|
|
const file = path.join(r.work, 'package.json');
|
|
fs.writeFileSync(file, JSON.stringify({ ...JSON.parse(fs.readFileSync(file, 'utf8')), name: 'fixture-b' }, null, 2) + '\n');
|
|
fs.mkdirSync(path.join(r.work, 'changelog.d'), { recursive: true });
|
|
fs.writeFileSync(path.join(r.work, 'changelog.d', 'b.md'), '- b change\n');
|
|
r.git('add', '-A'); r.git('commit', '-q', '-m', 'b');
|
|
const out = stampChain(chain(r, [['1', a], ['2', r.git('rev-parse', 'HEAD')]]));
|
|
if ('code' in out) throw new Error(out.message);
|
|
const s1 = JSON.parse(show(r, out.stamped[0]!.commit + ':package.json'));
|
|
const s2 = JSON.parse(show(r, out.stamped[1]!.commit + ':package.json'));
|
|
expect(s1.version).toBe('1.2.4');
|
|
expect(s2).toMatchObject({ name: 'fixture-b', version: '1.2.5', dependencies: { 'left-pad': '1.2.3' } });
|
|
}, 60_000);
|
|
|
|
test('a pin whose CHANGELOG is a symlink refuses before the stamp writes anything outside', () => {
|
|
const r = makeFixtureRepo({ policy: POLICY, prefix: 'train-stamp-' }); repos.push(r);
|
|
const outside = path.join(r.root, 'outside.md');
|
|
fs.writeFileSync(outside, 'keep\n');
|
|
r.git('checkout', '-q', '-B', 'l', 'origin/main');
|
|
fs.rmSync(path.join(r.work, 'CHANGELOG.md'));
|
|
fs.symlinkSync(outside, path.join(r.work, 'CHANGELOG.md'));
|
|
fs.mkdirSync(path.join(r.work, 'changelog.d'), { recursive: true });
|
|
fs.writeFileSync(path.join(r.work, 'changelog.d', 'l.md'), '- l change\n');
|
|
r.git('add', '-A'); r.git('commit', '-q', '-m', 'l');
|
|
const out = stampChain(chain(r, [['1', r.git('rev-parse', 'HEAD')]]));
|
|
expect('code' in out ? out.message : 'stamped').toMatch(/CHANGELOG\.md is a symlink in the merged tree/);
|
|
expect(fs.readFileSync(outside, 'utf8')).toBe('keep\n');
|
|
}, 60_000);
|
|
|
|
test('a tampered earlier version or a product change in S_i is refused', () => {
|
|
const r = makeFixtureRepo({ policy: POLICY, prefix: 'train-stamp-' }); repos.push(r);
|
|
const a = pin(r, 'a', 'origin/main', '- a change\n');
|
|
const b = pin(r, 'b', 'origin/main', '- b change\n');
|
|
const o = chain(r, [['1', a], ['2', b]]);
|
|
const out = stampChain(o);
|
|
if ('code' in out) throw new Error(out.message);
|
|
const s1 = out.stamped[0]!.commit;
|
|
const s2 = out.stamped[1]!;
|
|
const merged = r.git('commit-tree', r.git('merge-tree', '--write-tree', b, s1).split('\n')[0]!, '-p', s1, '-p', b, '-m', 'm');
|
|
expect(stampMismatch(o, merged, s2.commit, o.plain[2]!.tree)).toBeNull();
|
|
const tamper = (file: string, edit: (t: string) => string) => {
|
|
r.git('checkout', '-q', '-f', '--detach', s2.commit);
|
|
fs.writeFileSync(path.join(r.work, file), edit(fs.readFileSync(path.join(r.work, file), 'utf8')));
|
|
r.git('add', '-A');
|
|
return r.git('commit-tree', r.git('write-tree'), '-p', s1, '-p', b, '-m', 'tampered');
|
|
};
|
|
const older = tamper('CHANGELOG.md', t => t.replace('## [1.2.4.0]', '## [1.2.3.9]'));
|
|
expect(stampMismatch(o, merged, older, o.plain[2]!.tree)).toContain('the stamp rewrote or dropped the earlier release entry [1.2.4.0] in CHANGELOG.md');
|
|
const product = tamper('a.txt', () => 'not a\n');
|
|
expect(stampMismatch(o, merged, product, o.plain[2]!.tree)).toContain('a.txt');
|
|
}, 60_000);
|
|
});
|