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