Files
gstack/test/restamp-base-merge.test.ts
Garry Tanandcapy-ai[bot] 9ccb5233f3 v1.91.282.0 fix(ship-queue): one CHANGELOG rule before and after the merge, base keys stay in slot stamps, a stacked child carries past its released parent (#3518)
* Ship-queue train tooling: one CHANGELOG rule, base keys stay, stacked-child carry (GSTA-129)

Four train-24 (GSTA-96) defects in one change.

verify --train applies the same CHANGELOG rule before and after the merge:
main's CHANGELOG plus the PR's own lines (its authored diff plus what
mergeChangelogs writes for it). A head the pre-merge verify accepted no longer
freezes the queue after its squash merge, and a freeze record lists a slot
GitHub reports MERGED as merged.

restamp's stamp_paths stage leaves a *_KEY_VERSION line the base introduced
after the fork point alone, base merge committed or staged, and says so.

mergeChangelogs with a base carries a stacked child's edits to its released
parent entry as "Correction to the <version> entry below" bullets under the
child's own Gate integrity section instead of returning null; the slot
one-liner prints each carried correction. #3453's two 1.91.276.0 corrections
are restored that way here.

The digest-freshness defect is already closed by #3451 (regeneratedAtBase);
docs/ship-policy.md now says how an unstamped generator change ships.

Docs: ship-policy § Run a merge train, captain.md § Merge trains and the
slot-call template carry the stamp-files and stacked-child rules.

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

* Review fixes: carried union from slot 2, GitHub-merged slot-1 freeze, no-own-entry refusal, fail-closed base diff (GSTA-129)

Pre-landing review pass 1 (six specialists and the Red Team, all INFORMATIONAL):
- mergeStep unions the carried child from slot 2 on, so a stacked child behind
  another slot never repeats its released parent's text under a second
  [Unreleased] (Red Team, lib/ship-queue-train.ts).
- stopped() freezes the queue when any slot is MERGED on GitHub, so a slot-1
  squash that fails the post-merge check no longer answers "nothing merged"
  (Red Team, lib/ship-queue-train-verify.ts).
- ownEntryAboveMain refuses a CHANGELOG equal to main's: no entry of the PR's own
  (state-machines, lib/changelog-check.ts).
- branchAddedLines refuses when the base diff cannot run instead of reading it as
  "nothing added" (Red Team, lib/restamp.ts).
- slot-call.md and docs/captain.md name the stamp files as the ones the slot
  call names, STAMP_FILES plus a *_KEY_VERSION file the PR introduced, matching
  docs/ship-policy.md (maintainability).

Also: the coverage tests Step 7 added (post-merge parent check, carry shapes),
and two dependency-map rows (test/helpers/free-fixtures-data.ts for the
merge-train-3453 fixtures, scripts/test-pr-profile.ts for captain/templates/**)
so the paid-gate selector and the FREE_FIXTURES inventory accept the new files.

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

* Review pass 2: an unreadable GitHub state at a failed slot refuses, never "nothing merged" (GSTA-129)

stopped() read prState's 'unreadable' as "not merged": with nothing in
t.merged, a slot-1 squash already on main while gh pr view failed answered
"nothing of the train merged, so the queue is not frozen". It now refuses with
QUEUE_PROOF_MISSING ("whether #<n> merged on GitHub could not be read, so
whether the queue must freeze is unknown") and asks for the verify to be rerun;
no freeze, no backstop. Raised independently by five pass-2 reviewers.

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

* Docs audit: troubleshooting codes and ship-policy sections match the shipped train rules (GSTA-129)

The /document-release audit (edit mode) corrected two docs the PR had not
reached: docs/troubleshooting.md's QUEUE_TRAIN_TREE_MISMATCH, QUEUE_PROOF_MISSING,
QUEUE_TRAIN_FROZEN and POLICY_SOURCE_UNAVAILABLE entries, and docs/ship-policy.md's
train build conflict rule (the carried stacked child is the exception), the
verify freeze paragraph (slot-1 GitHub-MERGED freeze, unreadable-state refusal)
and the restamp stamp_paths stage (base-added lines stay; an unrunnable base diff
refuses). Five mid-sentence colons in the PR's own paragraphs are rewritten for
the prose lint, and the CHANGELOG Docs bullet names docs/troubleshooting.md.

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

* HARNESS_VERSION: scripts/test-pr-profile.ts row recorded non-behavioral at version 6 (GSTA-129)

The remote free tier's only red was the HARNESS_VERSION guard: scripts/test-pr-profile.ts is a pinned shared-harness file and gained the captain/templates/** FREE_ONLY_PR_FILES row. Recorded with bump-harness-version.ts --non-behavioral: no paid case reads the crew templates, so no selection, input or verdict changes and no pass-rate series resets.

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

* test: type the saved fixture PR row for the test-code ratchet (GSTA-129)

CI's typecheck:test ratchet refused one new TS2698 (spread of a loosely typed fixture row) in the slot-1 freeze test; the row is read as any, as the neighbouring state writes already do.

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

* ship-queue verify: build the freeze record from the one GitHub read that decided to freeze

freezeQueue took a second prState read per unverified slot and filed anything
but an exact MERGED under unmerged, so a slot that read MERGED at stopped()
and was unreadable a moment later was recorded as unmerged (the record half of
train 24's #3453 misfile). stopped() now reads each unverified slot once and
passes those states in; a MERGED slot stays merged, and a slot unreadable
beside a merged one is kept out of unmerged and named in the reason. The
fixture's gh shim gains a per-PR vanish count so a test can change GitHub's
answer between two reads.

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

* ship-queue verify: say what happens to a slot unreadable beside a merged one

The freeze reason, docs and CHANGELOG said the slot was kept out of unmerged
'until read', but nothing reads it again: the NEXT after the repair is built
from unmerged alone. Say so (the operator seats it in a train by hand once gh
answers) and pin the record shape with a test.

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

* v1.91.282.0 stamp

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-27 21:21:50 +00:00

117 lines
7.9 KiB
TypeScript

/**
* Train 24 (2026-10-27, gate.train-6d8c948d5409): in both slots `gstack-restamp
* --next` listed lib/ship-policy.ts as a fifth stamp file and moved main's own
* `DOCS_CAPS_KEY_VERSION = '1.91.270.0'` (#3511, released at 1.91.275.0) to the
* slot's version. The pin (234227fc4) had forked from 1.91.274.0 and the lane had
* merged base f8d0ab03 into it uncommitted, so HEAD still forked before #3511 and
* the stamp_paths stage, which counts the branch's own lines from
* merge-base(base, HEAD), read the base's key line as the branch's tentative
* mention. The stage now leaves out every line the base added since that fork
* point. #3514's receipt bound (`stampOnlyLine`: a moved version not older than
* the gated tree's VERSION) refuses that move after the fact only while the key
* names a release older than the base at the build; a key at the base version
* passes it, so the restamp itself must not make the move.
*/
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 { mergeChangelogs } from '../lib/changelog-check';
import { branchAddedLines, stampOnlyLine } from '../lib/restamp';
import { gitArgvIn } from './helpers/scratch-repo';
import { BASE_POLICY, cleanup, makeFixtureRepo, makePr, readFile, restamp, type FixtureRepo } from './helpers/restamp-fixture';
const repos: FixtureRepo[] = [];
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
const TEMPLATES = ["_KEY_VERSION = '{version}'"];
const POLICY = { ...BASE_POLICY, release_tool: null, release_outputs: [], stamp_paths: ['docs/**/*.md', 'src/**/*.ts'], stamp_patterns: TEMPLATES };
const stageDetail = (out: string, name: string) => new RegExp(`^stage ${name}: (\\w+)(?: \\((.*)\\))?$`, 'm').exec(out);
const key = (name: string, v: string) => `export const ${name}_KEY_VERSION = '${v}';\n`;
function write(repo: FixtureRepo, rel: string, text: string): void {
fs.mkdirSync(path.dirname(path.join(repo.work, rel)), { recursive: true });
fs.writeFileSync(path.join(repo.work, rel), text);
}
/** A release landed on main by hand (what a slot stamp leaves behind): VERSION, the manifest, a CHANGELOG heading, plus any files. */
function releaseOnMain(repo: FixtureRepo, v: string, files: Record<string, string> = {}): string {
repo.git('checkout', '-q', 'main');
write(repo, 'VERSION', v + '\n');
const pkg = JSON.parse(readFile(repo, 'package.json'));
pkg.version = v.split('.').slice(0, 3).join('.');
write(repo, 'package.json', JSON.stringify(pkg, null, 2) + '\n');
write(repo, 'CHANGELOG.md', readFile(repo, 'CHANGELOG.md').replace('# Changelog\n', `# Changelog\n\n## [${v}] - 2026-10-27\n\n- release ${v}\n`));
for (const [rel, text] of Object.entries(files)) write(repo, rel, text);
repo.git('add', '-A');
repo.git('commit', '-qm', `v${v}`);
repo.git('push', '-q', 'origin', 'main');
return repo.git('rev-parse', 'HEAD');
}
/** The lane's slot state: origin/main merged into the pin and left uncommitted, the CHANGELOG resolved with mergeChangelogs. */
function mergeMainUncommitted(repo: FixtureRepo): void {
const r = gitArgvIn(repo.work, ['merge', '--no-ff', '--no-commit', 'origin/main'], 30_000, { ...process.env, ...repo.env });
const conflicts = repo.git('diff', '--name-only', '--diff-filter=U').split('\n').filter(Boolean);
expect([r.status === 0 ? [] : conflicts]).toEqual([r.status === 0 ? [] : ['CHANGELOG.md']]);
if (conflicts.length) {
const merged = mergeChangelogs(repo.git('show', ':2:CHANGELOG.md') + '\n', repo.git('show', ':3:CHANGELOG.md') + '\n', repo.git('show', ':1:CHANGELOG.md') + '\n');
expect(merged).not.toBeNull();
write(repo, 'CHANGELOG.md', merged!);
repo.git('add', 'CHANGELOG.md');
}
expect(fs.existsSync(path.join(repo.work, '.git', 'MERGE_HEAD'))).toBe(true);
}
describe('stamp_paths: a key the base introduced after the fork stays, base merge committed or not (train 24)', () => {
// fails_when=the stamp_paths stage moves a *_KEY_VERSION line that came in with the uncommitted base merge, or lists its file; why_new=every earlier fixture committed the base merge or never merged it, and both read the branch's own lines correctly; seam=none (real git, real restamp); no_claim=what the receipt says about a stamp that was already committed
test.each([
['a key naming an older release (train 24: 1.91.270.0 under base 1.91.275.0), which #3514\'s receipt bound also refuses after the fact', '1.2.4.0', false],
['a key at the base version itself, which the receipt bound alone would accept as a stamp', '1.2.5.0', true],
])('%s', (_, keyVersion, receiptAccepts) => {
const repo = makeFixtureRepo({ prefix: 'restamp-train24-', policy: POLICY }); repos.push(repo);
// The pin: forked at 1.2.3.0, with its own tentative key (newer than its fork) that the stamp must move.
makePr(repo, 'pr-mine');
write(repo, 'src/mine.ts', key('MINE', '1.2.9.0'));
repo.git('add', '-A'); repo.git('commit', '-qm', 'my key');
// Main moves on: 1.2.4.0, then 1.2.5.0 carrying the base's own key (#3511's shape).
releaseOnMain(repo, '1.2.4.0');
releaseOnMain(repo, '1.2.5.0', { 'src/policy.ts': key('DOCS_CAPS', keyVersion) });
repo.git('checkout', '-q', 'pr-mine');
mergeMainUncommitted(repo);
expect(readFile(repo, 'src/policy.ts')).toBe(key('DOCS_CAPS', keyVersion));
const dry = restamp(repo, ['--next', '--dry-run']);
expect([dry.status, dry.err]).toEqual([EXIT.ok, '']);
expect(dry.out).toContain('RESTAMP: 1.2.5.0 -> 1.2.6.0 (dry-run;');
expect(dry.out).toMatch(/^FILES: CHANGELOG.md VERSION package-lock.json package.json src\/mine.ts$/m);
expect(dry.out).not.toMatch(/^[-+].*DOCS_CAPS_KEY_VERSION/m);
expect(dry.out).toMatch(/^-export const MINE_KEY_VERSION = '1\.2\.9\.0';$/m);
expect(dry.out).toMatch(/^\+export const MINE_KEY_VERSION = '1\.2\.6\.0';$/m);
const sp = stageDetail(dry.out, 'stamp_paths')!;
expect(sp[1]).toBe('done');
expect(sp[2]).toContain('1 mention(s) in 1 file(s) (1.2.9.0 -> 1.2.6.0)');
expect(sp[2]).toContain("1 line(s) the base added since the fork point are the base's, not this branch's, and stay (src/policy.ts: 1)");
// The same merge committed (the documented slot sequence) reads the same way.
repo.git('commit', '-qm', 'merge main');
const committed = restamp(repo, ['--next', '--dry-run']);
expect([committed.status, committed.err]).toEqual([EXIT.ok, '']);
expect(committed.out).toMatch(/^FILES: CHANGELOG.md VERSION package-lock.json package.json src\/mine.ts$/m);
expect(committed.out).toMatch(/^\+export const MINE_KEY_VERSION = '1\.2\.6\.0';$/m);
expect(stageDetail(committed.out, 'stamp_paths')![2]).not.toContain('the base added since the fork point');
// #3514's claim, checked against this shape: the receipt (gated tree at the base version 1.2.5.0) refuses the
// moved older key, but a key at the base version passes its bound, so only the restamp fix keeps it in place.
const moved = stampOnlyLine(key('DOCS_CAPS', keyVersion).trim(), key('DOCS_CAPS', '1.2.6.0').trim(), '1.2.5.0', '1.2.6.0', TEMPLATES);
expect(moved).toBe(receiptAccepts);
}, 120_000);
// Value: protects=the base-added read fails closed; fails_when=a diff git cannot run reads as "nothing added" and the base's key line moves again (red-team review of GSTA-129); why_new=the test above only runs the stage with a readable base; seam=none; no_claim=which git errors occur in practice
test('a base diff git cannot run is a refusal, never an empty "nothing added" set', () => {
const repo = makeFixtureRepo({ prefix: 'restamp-train24-', policy: POLICY }); repos.push(repo);
expect(branchAddedLines(repo.work, 'HEAD', ['VERSION'], 'HEAD').size).toBe(0);
expect(() => branchAddedLines(repo.work, 'HEAD', ['VERSION'], 'f'.repeat(40))).toThrow(/^cannot diff HEAD\.\.ffffffffffff for the stamp_paths stage \(.+\); nothing written$/);
});
});