mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 08:23:59 +01:00
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>
This commit is contained in:
1 parent
2114d7ab33
commit
a2077ff70e
12 files changed
+127
-19
No files matched your search
+5
-5
@@ -5,15 +5,15 @@
|
||||
**A merge the pre-merge verify accepted no longer freezes the queue, and a slot stamp touches only the stamp files.** Train 24 (2026-10-27) merged #3434 and #3453, then froze on #3453's post-merge check and paid a $31 backstop: the pre-merge verify judged the lane's CHANGELOG as main's plus the pin's own entry and said MERGE_CONDITION, the post-merge check compared main to T2 section by section, and T2's one `[Unreleased]` (both stacked entries) had become two released sections. In both slots `gstack-restamp` also listed `lib/ship-policy.ts` as a fifth stamp file, because the lane's uncommitted base merge made #3511's `DOCS_CAPS_KEY_VERSION` look like the branch's own tentative key. And `mergeChangelogs` refused the stacked child outright, so the lane's hand resolve dropped #3453's two wording fixes to the released 1.91.276.0 entry.
|
||||
|
||||
### Fixed
|
||||
- **`gstack-ship-queue verify --train`** (`lib/ship-queue-train-verify.ts`): one CHANGELOG rule before and after the merge. Post-merge, main confirms when its tree is T<i> modulo stamps, or differs from T<i> in the CHANGELOG alone while being its own first parent's CHANGELOG (the main the pre-merge verify saw) plus the PR's own entry, the rule `mergedIn` applied pre-merge; the `main:` line says "except CHANGELOG.md, main <sha12>'s CHANGELOG.md plus #<n>'s own entry". The own lines are the pin's authored CHANGELOG lines or the lines `mergeChangelogs` writes for that pin against main, so a tool-written correction bullet passes. A freeze lists a slot GitHub reports MERGED as merged (its reason says "merged on GitHub without a verified tree"), never under `unmerged`, so the rebuild after the repair does not seat it again.
|
||||
- **`gstack-restamp` stamp_paths** (`lib/restamp.ts`): a line the base added since the pin's fork point is the base's, whether the base merge is committed or still staged; `branchAddedLines` leaves it out and the stage detail says "N line(s) the base added since the fork point are the base's, not this branch's, and stay (<file>: N)". #3514's receipt bound (`stampOnlyLine`) refuses such a move after the fact only while the moved version is older than the base's at the build; a key at the base version passes it, so the restamp is the guard.
|
||||
- **`mergeChangelogs`** (`lib/changelog-check.ts`): a stacked child whose parent squash-merged and was released since the merge base (`carryReleasedParent`) is a merge, not a hand resolve. The parent's lines leave the child's `[Unreleased]`; each line the child changed inside them becomes `- Correction to the <version> entry below: "<old>" should read "<new>".` (a dropped line "no longer applies", a line added between the parent's "Addition to the <version> entry below") under the child's own `### Gate integrity`, appended to one it already has. The released entry stays main's byte for byte. Without the merge base the shape is GSTA-67's dropped heading and stays null. `mergeChangelogsWithNotes` returns the carried corrections; the slot one-liner in docs/ship-policy.md prints them. `mergeStep` (train build) passes the merge base, so a child of an already-released parent seats.
|
||||
- **`gstack-ship-queue verify --train`** (`lib/ship-queue-train-verify.ts`): one CHANGELOG rule before and after the merge. Post-merge, main confirms when its tree is T<i> modulo stamps, or differs from T<i> in the CHANGELOG alone while being its own first parent's CHANGELOG (the main the pre-merge verify saw) plus the PR's own entry, the rule `mergedIn` applied pre-merge; the `main:` line says "except CHANGELOG.md, main <sha12>'s CHANGELOG.md plus #<n>'s own entry". The own lines are the pin's authored CHANGELOG lines or the lines `mergeChangelogs` writes for that pin against main, so a tool-written correction bullet passes. A freeze lists a slot GitHub reports MERGED as merged (its reason says "merged on GitHub without a verified tree"), never under `unmerged`, so the rebuild after the repair does not seat it again; and a first slot GitHub reports MERGED whose squash fails the post-merge check freezes the queue instead of answering "nothing of the train merged" while main already holds the unverified squash. A main whose CHANGELOG equals its parent's is not "the parent's plus the PR's own entry" (`ownEntryAboveMain`: "no entry of the PR's own above it"), so a squash that lost the entry the gate carried is refused.
|
||||
- **`gstack-restamp` stamp_paths** (`lib/restamp.ts`): a line the base added since the pin's fork point is the base's, whether the base merge is committed or still staged; `branchAddedLines` leaves it out and the stage detail says "N line(s) the base added since the fork point are the base's, not this branch's, and stay (<file>: N)". #3514's receipt bound (`stampOnlyLine`) refuses such a move after the fact only while the moved version is older than the base's at the build; a key at the base version passes it, so the restamp is the guard. A base diff git cannot run is a refusal ("cannot diff <since>..<base> for the stamp_paths stage"), never an empty "nothing added" set that would move the base's lines again.
|
||||
- **`mergeChangelogs`** (`lib/changelog-check.ts`): a stacked child whose parent squash-merged and was released since the merge base (`carryReleasedParent`) is a merge, not a hand resolve. The parent's lines leave the child's `[Unreleased]`; each line the child changed inside them becomes `- Correction to the <version> entry below: "<old>" should read "<new>".` (a dropped line "no longer applies", a line added between the parent's "Addition to the <version> entry below") under the child's own `### Gate integrity`, appended to one it already has. The released entry stays main's byte for byte. Without the merge base the shape is GSTA-67's dropped heading and stays null. `mergeChangelogsWithNotes` returns the carried corrections; the slot one-liner in docs/ship-policy.md prints them. `mergeStep` (train build) passes the merge base, so a child of an already-released parent seats; from slot 2 on, where the prefix already holds an earlier slot's `[Unreleased]` and the sections are unioned, the union takes the carried child, so the gated tree never repeats the released parent's text under the child's heading.
|
||||
|
||||
### Tests
|
||||
- `test/ship-queue-train.test.ts`: a stacked child's merged-in head the pre-merge verify accepted confirms after its squash merge (froze before), with the correction bullet accepted pre-merge; a post-merge CHANGELOG holding a line the pin never wrote still freezes, and the freeze lists the slot GitHub reports MERGED under `merged`. `test/restamp-base-merge.test.ts` (new): train 24's shape (pin forked before the base's key, base merge staged), for a key naming an older release and for one at the base version; the dry run lists the four stamp files plus the branch's own key file only, committed or staged, and the receipt bound is asserted for both keys. `test/changelog-merge-damage.test.ts`: the real a78ce08a/3e4240a7/d87eec95 CHANGELOGs (`test/fixtures/merge-train-3453/`) merge to main's sections unchanged plus #3453's entry with the two corrections; without the base they stay null; shape controls for an unedited, a dropped, an added and an empty child.
|
||||
- `test/ship-queue-train.test.ts`: a stacked child's merged-in head the pre-merge verify accepted confirms after its squash merge (froze before), with the correction bullet accepted pre-merge; a post-merge CHANGELOG holding a line the pin never wrote still freezes, and the freeze lists the slot GitHub reports MERGED under `merged`; a slot-1 squash GitHub reports MERGED that fails the post-merge check freezes; a stacked child behind another slot is gated on the carried CHANGELOG (the released parent's text appears once, under its own heading, with the child's corrections in the child's entry). `test/restamp-base-merge.test.ts` (new): train 24's shape (pin forked before the base's key, base merge staged), for a key naming an older release and for one at the base version; the dry run lists the four stamp files plus the branch's own key file only, committed or staged, and the receipt bound is asserted for both keys. `test/changelog-merge-damage.test.ts`: the real a78ce08a/3e4240a7/d87eec95 CHANGELOGs (`test/fixtures/merge-train-3453/`) merge to main's sections unchanged plus #3453's entry with the two corrections; without the base they stay null; shape controls for an unedited, a dropped, an added and an empty child.
|
||||
|
||||
### Docs
|
||||
- docs/ship-policy.md § Run a merge train: the one CHANGELOG rule, the stamp-files rule (never a fifth), the stacked-child-after-squash resolution, and that an unstamped head changes a release output by regenerating it at its own VERSION (the Version Gate has accepted that since 1.91.237.0; #3434 reverted its digest change against the older gate). docs/captain.md § Merge trains and captain/templates/slot-call.md carry the stamp-files and stacked-child rules.
|
||||
- docs/ship-policy.md § Run a merge train: the one CHANGELOG rule, the stamp-files rule (never a fifth), the stacked-child-after-squash resolution, and that an unstamped head changes a release output by regenerating it at its own VERSION (the Version Gate has accepted that since 1.91.237.0; #3434 reverted its digest change against the older gate). docs/captain.md § Merge trains and captain/templates/slot-call.md carry the stamp-files rule (the files the slot call names: `STAMP_FILES`, plus the file carrying a `*_KEY_VERSION` the PR introduced when the call names it; a file the restamp lists that the call did not name is left out and reported) and the stacked-child rule.
|
||||
|
||||
### Gate integrity
|
||||
- Correction to the 1.91.276.0 entry below: "copies of shared sections twice until a follow-up dedupes them." should read "versions of shared sections beside the shared file (office-hours' copies are deduped in the entry above).".
|
||||
|
||||
@@ -10,8 +10,10 @@ Run: gstack-restamp --next --base <BASE> --pr <n> --expect-base <sha> --expect-o
|
||||
Slot 2 and later (and slot 1 when BASE moved): merge BASE into your pin first (expect a
|
||||
CHANGELOG-only conflict; resolve it with mergeChangelogs from lib/changelog-check.ts, the
|
||||
one-liner in docs/ship-policy.md's merge-train section, never by hand; grep for conflict
|
||||
markers before you stamp). Commit only the BASE merge and the stamp files: <STAMP_FILES>,
|
||||
never a fifth: a file the restamp lists beyond them is not a stamp, leave it out and say so.
|
||||
markers before you stamp). Commit only the BASE merge and the stamp files this call names:
|
||||
<STAMP_FILES> (plus the file carrying a `*_KEY_VERSION` your PR introduced, on your own
|
||||
line only, when this call names it), never a fifth: a file the restamp lists that this call
|
||||
does not name is not a stamp, leave it out and say so.
|
||||
A pin stacked on a PR that merged earlier conflicts beyond the CHANGELOG where the parent's
|
||||
squash touched your lines: take your pin's bytes there (verify checks the tree against
|
||||
T<i>); mergeChangelogs carries your edits to the parent's released entry as Gate integrity
|
||||
|
||||
+6
-4
@@ -207,10 +207,12 @@ files. In practice slot 1 needs the same merge whenever the base moved after the
|
||||
taken. A stamp commit with one extra byte freezes the queue for every
|
||||
lane behind it.
|
||||
|
||||
The slot stamp is the base merge, `mergeChangelogs`, and the restamp's stamp files
|
||||
(`STAMP_FILES` in CREW.md; gstack's are `CHANGELOG.md`, `VERSION`, `package.json` and
|
||||
`agents-digest/gstack-AGENTS.md`), never a fifth: a file the restamp lists beyond them is
|
||||
not a stamp, so the lane leaves it out and says so in its READY. The restamp itself never
|
||||
The slot stamp is the base merge, `mergeChangelogs`, and the stamp files the slot call
|
||||
names (`STAMP_FILES` in CREW.md; gstack's are `CHANGELOG.md`, `VERSION`, `package.json` and
|
||||
`agents-digest/gstack-AGENTS.md`, plus the file carrying a `*_KEY_VERSION` the PR
|
||||
introduced, which the restamp moves on the PR's own line, when the call names it), never a
|
||||
fifth: a file the restamp lists that the call did not name is not a stamp, so the lane
|
||||
leaves it out and says so in its READY. The restamp itself never
|
||||
moves a line the base added since the pin's fork point, base merge committed or staged. A
|
||||
pin stacked on a PR that squash-merged earlier conflicts beyond the CHANGELOG where the
|
||||
parent's squash touched the pin's lines: the lane takes its pin's bytes for those files
|
||||
|
||||
@@ -140,8 +140,9 @@ export function normalizeChangelog(text: string): string {
|
||||
/**
|
||||
* A merged-in head's CHANGELOG, however its conflict was resolved: why it is
|
||||
* not main's CHANGELOG with the PR's own entry above it, or null. Main's
|
||||
* header and every main section must follow unchanged and in order, and the
|
||||
* sections above them may hold only lines the PR itself added (`authored`,
|
||||
* header and every main section must follow unchanged and in order, at least
|
||||
* one section must sit above them (a CHANGELOG equal to main's has lost the
|
||||
* entry the gate carried), and those sections may hold only lines the PR itself added (`authored`,
|
||||
* the + lines of its pin's CHANGELOG diff), so a hand resolve can drop
|
||||
* damage but never add text the train gate did not carry.
|
||||
*/
|
||||
@@ -154,6 +155,7 @@ export function ownEntryAboveMain(head: string, main: string, authored: Readonly
|
||||
if (own < 0) return `it has ${h.sections.length} sections, main has ${m.sections.length}`;
|
||||
const moved = m.sections.findIndex((s, i) => trim(s.lines) !== trim(h.sections[own + i]!.lines));
|
||||
if (moved >= 0) return `main's ${m.sections[moved]!.heading.trim()} is not carried unchanged below the PR's entry`;
|
||||
if (own === 0) return "it is main's CHANGELOG unchanged, with no entry of the PR's own above it";
|
||||
const mainKeys = new Set(m.sections.map(s => s.version ?? 'Unreleased'));
|
||||
const clash = h.sections.slice(0, own).find(s => mainKeys.has(s.version ?? 'Unreleased'));
|
||||
if (clash) return `${clash.heading.trim()} is headed twice`;
|
||||
|
||||
@@ -283,6 +283,8 @@ export function branchAddedLines(cwd: string, since: string, files: string[], to
|
||||
const out = new Map<string, AddedLines>();
|
||||
if (files.length === 0) return out;
|
||||
const diff = git(cwd, ['diff', '--unified=0', '--inter-hunk-context=0', '--no-color', '--no-ext-diff', since, ...(to ? [to] : []), '--', ...files]);
|
||||
// A failed diff must not read as "the branch added nothing" (or "the base added nothing"), which would move the base's lines again.
|
||||
if (!diff.ok) throw new Refusal('POLICY_SOURCE_UNAVAILABLE', `cannot diff ${since.slice(0, 12)}..${to ? to.slice(0, 12) : 'the worktree'} for the stamp_paths stage (${(diff.err || diff.out).split('\n')[0]}); nothing written`);
|
||||
for (const f of parseUnifiedDiff(diff.out)) {
|
||||
const own = out.get(f.path) ?? { text: new Set<string>(), at: new Set<number>(), notOwn: 0 };
|
||||
const base = notOwn?.get(f.path)?.text;
|
||||
|
||||
@@ -139,10 +139,16 @@ async function freezeQueue(env: TrainEnv, t: TrainRecord, reason: string, mainSh
|
||||
return f;
|
||||
}
|
||||
|
||||
/** Interrupted publication: freeze when earlier slots merged; otherwise a plain refusal (nothing was published). */
|
||||
/**
|
||||
* Interrupted publication: freeze when a slot merged, verified or merged on
|
||||
* GitHub without a verified tree (slot 1's own squash failing the post-merge
|
||||
* check leaves main unverified with nothing in `merged` yet); otherwise a
|
||||
* plain refusal (nothing was published).
|
||||
*/
|
||||
async function stopped(env: TrainEnv, t: TrainRecord, code: 'QUEUE_TRAIN_TREE_MISMATCH' | 'QUEUE_TRAIN_MOVED', message: string, mainSha: string, lines: string[]): Promise<TrainOut> {
|
||||
const run = trainFile(t.nonce);
|
||||
if (!t.merged.length) return outFailed({ code, message: `${message}; nothing of train ${t.nonce} merged, so the queue is not frozen` }, lines, `${Q} train build ${prsArgs(t.slots, t.explicit_order)} (a new train on current main)`, run);
|
||||
const landed = t.merged.length > 0 || t.slots.some(s => prState(env.ctx, s.pr) === 'MERGED');
|
||||
if (!landed) return outFailed({ code, message: `${message}; nothing of train ${t.nonce} merged, so the queue is not frozen` }, lines, `${Q} train build ${prsArgs(t.slots, t.explicit_order)} (a new train on current main)`, run);
|
||||
const f = activeFreeze(env.repo.keys) ?? await freezeQueue(env, t, message, mainSha);
|
||||
return outFailed({ code, message: `${message}; the queue is frozen` }, [...lines, ...frozenLines(f)], `${Q} train build --repair --prs <repair-pr>@<sha>`, run);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
*/
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { DISPATCH_INPUTS, awaitNonceRun, dispatchWorkflow, listDispatchRuns, makeNonce, runForNonce, type WorkflowDispatch } from '../scripts/eval-bg';
|
||||
import { changelogDamage, mergeChangelogs, parseChangelog, type ChangelogSection } from './changelog-check';
|
||||
import { carryReleasedParent, changelogDamage, mergeChangelogs, parseChangelog, type ChangelogSection } from './changelog-check';
|
||||
import type { ResultStatus } from './headless-artifacts';
|
||||
import { baseOverlapLine } from './pr-wait';
|
||||
import { versionAtRef, versionFiles } from './restamp';
|
||||
@@ -146,7 +146,9 @@ function unionChangelog(ours: string, theirs: string): string {
|
||||
* Version files take the prefix's side; the CHANGELOG merges by section (the
|
||||
* gate-ahead rule with the merge base, which also carries a stacked child
|
||||
* past its released parent, else a union of sections, since each heading is
|
||||
* restamped at merge), unless a side damaged released history
|
||||
* restamped at merge; the union takes the carried side too, so a prefix that
|
||||
* already holds an earlier slot's [Unreleased] never duplicates the released
|
||||
* parent's text under the child's heading), unless a side damaged released history
|
||||
* (changelogDamage), which is a conflict the lane resolves. Any other
|
||||
* conflict is reported with every conflicting path.
|
||||
*/
|
||||
@@ -179,7 +181,8 @@ export function mergeStep(repoRoot: string, pred: string, head: string, policy:
|
||||
const merged = mergeChangelogs(text(ours), text(theirs), base ? text(base) : undefined);
|
||||
const damage = merged ? null : changelogDamage(text(ours), text(theirs), base ? text(base) : undefined);
|
||||
if (damage) { conflicts.push(`${file} (${damage})`); continue; }
|
||||
const blob = git(repoRoot, ['hash-object', '-w', '--stdin'], undefined, merged ?? unionChangelog(text(ours), text(theirs)));
|
||||
const own = (base && carryReleasedParent(text(ours), text(theirs), text(base))?.text) || text(ours);
|
||||
const blob = git(repoRoot, ['hash-object', '-w', '--stdin'], undefined, merged ?? unionChangelog(own, text(theirs)));
|
||||
if (blob.ok) resolved.push({ path: file, blob: blob.out }); else conflicts.push(file);
|
||||
} else if (stampFiles.includes(file)) resolved.push({ path: file, blob: theirs });
|
||||
else conflicts.push(file);
|
||||
|
||||
@@ -235,6 +235,7 @@ export const FREE_ONLY_PR_FILES = [
|
||||
'scripts/paid-test-durations.json',
|
||||
'scripts/ubicloud/**',
|
||||
'capy/skill/**', // The vendored Capy skill (Drive bootstrapper): run by Capy agents, read by no paid case; test/capy-skill-wrapper.test.ts covers it.
|
||||
'captain/templates/**', // Crew templates (CREW.md, slot-call.md, READY.md, ...): read by Captains and by free test/captain-skill.test.ts; no paid case reads them.
|
||||
'tsconfig.test.json', // Read only by `tsc -p tsconfig.test.json` (typecheck:test); Bun's runtime reads tsconfig.json.
|
||||
'.gitignore', // Changes which untracked files git reports, never tracked content a case reads.
|
||||
'.gstack/generated.json', // gstack's own regen registry: gstack-regen/gstack-pregate read it from origin/<base> of the repo they run in; paid cases run in fixture repos.
|
||||
|
||||
@@ -78,6 +78,8 @@ describe("ownEntryAboveMain: a merged-in head's CHANGELOG however it was resolve
|
||||
expect(ownEntryAboveMain(stamped.replace('## [1.91.178.0]', '## [1.91.178.0]\n\n- smuggled'), main, authored)).toBe("main's ## [1.91.178.0] - 2026-10-22 is not carried unchanged below the PR's entry");
|
||||
expect(ownEntryAboveMain(stamped.replace('# Changelog', '# Change log'), main, authored)).toBe('the header differs from main');
|
||||
expect(ownEntryAboveMain(stamped.replace('## [1.91.182.0]', '## [1.91.181.0]'), main, authored)).toMatch(/is headed twice$/);
|
||||
// Main's CHANGELOG unchanged is not main's plus the PR's entry: the entry the gate carried is missing (state-machines review of GSTA-129).
|
||||
expect(ownEntryAboveMain(main, main, authored)).toBe("it is main's CHANGELOG unchanged, with no entry of the PR's own above it");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -136,4 +138,15 @@ describe("a stacked child whose parent was released since the fork (GSTA-96, tra
|
||||
expect(carryReleasedParent(`# C\n\n## [Unreleased]\n\n**Parent paragraph.**\n\n### Changed\n- one\n- two\n- three\n\n## [1.0.0] - d\n\n- base\n`, theirs, base)).toBeNull();
|
||||
expect(carryReleasedParent(`# C\n\n## [Unreleased]\n\n- a\n\n## [Unreleased]\n\n**Parent paragraph.**\n\n### Changed\n- one\n\n## [1.0.0] - d\n\n- base\n`, theirs, base)).toBeNull();
|
||||
});
|
||||
|
||||
// Value: protects=carryReleasedParent rewrites only a child that still holds the released parent's entry and keeps an existing Gate integrity section's neighbours in place; fails_when=an unrelated [Unreleased] or an already-stamped head is rewritten or the correction lands outside Gate integrity; why_new=the shapes test above never has a child without the parent's entry, a head without [Unreleased], or a Gate integrity section followed by another heading; seam=none; no_claim=the wording of corrections beyond one edited line
|
||||
test('shapes: a child that does not hold the released parent, or a head already stamped, is null; a correction joins the child\'s existing Gate integrity section without crossing the next heading', () => {
|
||||
const released = '## [1.0.1] - d\n\n**Parent paragraph.**\n\n### Changed\n- one\n- two\n- three\n';
|
||||
const theirs = `# C\n\n${released}\n## [1.0.0] - d\n\n- base\n`;
|
||||
const base = '# C\n\n## [Unreleased]\n\n**Parent paragraph.**\n\n### Changed\n- one\n- two\n- three\n\n## [1.0.0] - d\n\n- base\n';
|
||||
expect(carryReleasedParent('# C\n\n## [Unreleased]\n\n- totally other\n\n## [1.0.0] - d\n\n- base\n', theirs, base)).toBeNull();
|
||||
expect(carryReleasedParent('# C\n\n## [1.0.2] - d\n\n- mine\n\n## [1.0.0] - d\n\n- base\n', theirs, base)).toBeNull();
|
||||
const child = '# C\n\n## [Unreleased]\n\n**Child.**\n\n### Changed\n- mine\n\n### Gate integrity\n- child gate\n\n### Tests\n- child test\n\n**Parent paragraph.**\n\n### Changed\n- one\n- two edited\n- three\n\n## [1.0.0] - d\n\n- base\n';
|
||||
expect(carryReleasedParent(child, theirs, base)!.text).toBe('# C\n\n## [Unreleased]\n\n**Child.**\n\n### Changed\n- mine\n\n### Gate integrity\n- child gate\n- Correction to the 1.0.1 entry below: "two" should read "two edited".\n\n### Tests\n- child test\n\n## [1.0.0] - d\n\n- base\n');
|
||||
});
|
||||
});
|
||||
@@ -147,6 +147,7 @@ export const FREE_FIXTURES: Readonly<Record<string, readonly string[]>> = {
|
||||
'test/fixtures/judge-panel-replay-2026-10-05.json': ['test/judge-panel-median.test.ts'],
|
||||
'test/fixtures/launch-env-echo/**': ['test/launch-env-echo-ratchet.test.ts'],
|
||||
'test/fixtures/merge-train-3345/**': ['test/changelog-merge-damage.test.ts', 'test/compare-pr-version.test.ts'],
|
||||
'test/fixtures/merge-train-3453/**': ['test/changelog-merge-damage.test.ts'],
|
||||
'test/fixtures/module-size-ratchet.json': ['test/helpers/module-size.ts', 'test/module-size-ratchet.test.ts'],
|
||||
'test/fixtures/module-size/**': ['test/helpers/module-size.ts', 'test/module-size-ratchet.test.ts'],
|
||||
'test/fixtures/multi-agent-wave/**': ['test/headless-artifacts.test.ts', 'test/helpers/autoplan-run-fixture.ts', 'test/plan-reality.test.ts'],
|
||||
|
||||
@@ -17,7 +17,7 @@ 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 { stampOnlyLine } from '../lib/restamp';
|
||||
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';
|
||||
|
||||
@@ -106,4 +106,11 @@ describe('stamp_paths: a key the base introduced after the fork stays, base merg
|
||||
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$/);
|
||||
});
|
||||
});
|
||||
@@ -404,6 +404,33 @@ describe('train build', () => {
|
||||
expect(dispatches(fx)).toEqual([]);
|
||||
}, 120_000);
|
||||
|
||||
// Value: protects=a stacked child in a slot after the first is gated on the carried CHANGELOG, its released parent's text never duplicated under a second [Unreleased]; fails_when=mergeStep unions the uncarried child when the prefix already holds an earlier slot's [Unreleased] (the two-[Unreleased] guard returns null, so the carry is lost); why_new=the train 24 test below releases the parent after the train is built and the slot-1 chain never reaches the union; seam=none; no_claim=how the post-merge verify reads a two-[Unreleased] prefix
|
||||
test("a stacked child behind another slot: the gated tree carries the child past its released parent instead of repeating the parent's text under its own [Unreleased]", () => {
|
||||
const fx = make();
|
||||
const { repo } = fx;
|
||||
const h1 = openPr(fx, '1', { changelog: cl => cl.replace('# Changelog\n', '# Changelog\n\n## [Unreleased]\n\n**Parent entry of #1.**\n\n### Changed\n- #1 change, the first wording\n- #1 second bullet, unchanged\n') });
|
||||
const h2 = openPr(fx, '2', { from: 'pr-1', changelog: cl => cl.replace('## [Unreleased]\n\n**Parent entry of #1.**', '## [Unreleased]\n\n**Child entry of #2.**\n\n### Changed\n- #2 change\n\n**Parent entry of #1.**').replace('- #1 change, the first wording', '- #1 change, the better wording') });
|
||||
// #1 is released as 1.2.4.0 before the train; #3 forks from that main and takes the first slot, #2 the second.
|
||||
const main1 = publishMain(fx, h1, '1.2.4.0', { file: 'CHANGELOG.md', text: repo.git('show', `${h1}:CHANGELOG.md`).replace('## [Unreleased]', '## [1.2.4.0] - 2026-10-18') + '\n' });
|
||||
const h3 = openPr(fx, '3');
|
||||
readyReceipt(fx, '3', h3);
|
||||
readyReceipt(fx, '2', h2);
|
||||
fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; });
|
||||
const r = queue(fx, ['train', 'build', '--prs', `3@${h3},2@${h2}`, '--order', '3,2']);
|
||||
expect([r.status, r.err]).toEqual([EXIT.ok, '']);
|
||||
expect(line(r.out, 'ORDER:')).toBe('ORDER: 1:#3(unscreened) 2:#2(unscreened) (explicit --order)');
|
||||
const t = trainJson(fx, nonceOf(r.out));
|
||||
const gated = parseChangelog(repo.git('show', `${t.prefixes[2].commit}:CHANGELOG.md`) + '\n');
|
||||
expect(gated.sections.map(s => s.version ?? 'Unreleased')).toEqual(['Unreleased', 'Unreleased', '1.2.4.0', '1.2.3.0', '1.2.2.0']);
|
||||
const own = gated.sections[0]!.lines.join('\n');
|
||||
expect(own).toContain('**Child entry of #2.**');
|
||||
expect(own).toContain('- Correction to the 1.2.4.0 entry below: "first" should read "better".');
|
||||
expect(own).not.toContain('Parent entry of #1');
|
||||
expect(gated.sections[1]!.lines.join('\n')).toContain('- #3 change');
|
||||
const released = parseChangelog(repo.git('show', `${main1}:CHANGELOG.md`) + '\n').sections[0]!;
|
||||
expect(gated.sections[2]!.lines.join('\n').replace(/\n+$/, '')).toBe(released.lines.join('\n').replace(/\n+$/, ''));
|
||||
}, 120_000);
|
||||
|
||||
test("GSTA-67: READY receipts under any URL form of the repository admit (#3331's receipt was keyed on git.capy.ai, the coordinator's origin on github.com)", () => {
|
||||
const fx = make();
|
||||
githubOrigin(fx, 'https://github.com/fixture/repo.git');
|
||||
@@ -994,4 +1021,46 @@ describe('verify, freeze, repair and clear', () => {
|
||||
const freezeFile = path.join(fx.state, 'ship-queue', 'freeze', `${repoKey(fx)}.json`);
|
||||
expect(JSON.parse(fs.readFileSync(freezeFile, 'utf8'))).toMatchObject({ merged: ['1', '2'], unmerged: [] });
|
||||
}, 300_000);
|
||||
|
||||
// Value: protects=a first slot GitHub reports MERGED whose squash fails the post-merge check freezes the queue; fails_when=stopped() reads an empty merged list as "nothing merged" while main already holds the unverified squash (red-team review of GSTA-129); why_new=the test above freezes on slot 2 with slot 1 verified; seam=the fixture's gh pr view state; no_claim=the backstop's outcome
|
||||
test("slot 1 merged on GitHub but not confirmed: the queue freezes with #1 merged and #2 unmerged instead of saying nothing merged", () => {
|
||||
const fx = make();
|
||||
const { h2, nonce } = twoPrTrain(fx);
|
||||
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
|
||||
const t = trainJson(fx, nonce);
|
||||
const coord = coordinatorClone(fx.repo);
|
||||
const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord);
|
||||
const smuggled = fx.repo.git('show', `${t.prefixes[0].commit}:CHANGELOG.md`).replace('# Changelog\n', '# Changelog\n\n## [1.2.4.0] - 2026-10-18\n\n- #1 change\n- added after the gate\n') + '\n';
|
||||
const broken = publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: smuggled });
|
||||
fx.write(d => { (d.prs['1'] as any).state = 'MERGED'; });
|
||||
const frozen = v('1');
|
||||
expect(frozen.status).toBe(EXIT.fail);
|
||||
expect(frozen.err).toContain(`main ${broken.slice(0, 12)} is neither T0 nor T1 modulo stamps`);
|
||||
expect(frozen.err).toContain('"- added after the gate"); the queue is frozen;');
|
||||
expect(line(frozen.out, 'QUEUE_TRAIN_FROZEN:')).toMatch(/^QUEUE_TRAIN_FROZEN: train \S+ merged #1; main /);
|
||||
expect(line(frozen.out, 'freeze:')).toMatch(/; #1 merged on GitHub without a verified tree; unmerged #2; repair none admitted$/);
|
||||
expect(JSON.parse(fs.readFileSync(path.join(fx.state, 'ship-queue', 'freeze', `${repoKey(fx)}.json`), 'utf8'))).toMatchObject({ merged: ['1'], unmerged: [{ pr: '2', head: h2 }] });
|
||||
}, 300_000);
|
||||
|
||||
// Value: protects=a post-merge CHANGELOG difference is excused only when main's parent is T<i-1> modulo stamps, so a foreign commit between the verified head and the squash freezes; fails_when=published() skips or inverts the parent check; why_new=the two tests above reach the CHANGELOG rule only with a parent that is T1 modulo stamps; seam=none; no_claim=a parent differing from T1 in the CHANGELOG alone
|
||||
test("a post-merge CHANGELOG difference is not excused when main's parent is not the verified prefix modulo stamps: the queue freezes naming the parent", () => {
|
||||
const fx = make();
|
||||
const { nonce } = twoPrTrain(fx);
|
||||
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
|
||||
const t = trainJson(fx, nonce);
|
||||
const coord = coordinatorClone(fx.repo);
|
||||
const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord);
|
||||
const released = fx.repo.git('show', `${t.prefixes[1].commit}:CHANGELOG.md`).replace('## [Unreleased]', '## [1.2.4.0] - 2026-10-18') + '\n';
|
||||
const main1 = publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: released });
|
||||
expect(v('1').status).toBe(EXIT.ok);
|
||||
const foreign = publishMain(fx, t.prefixes[1].commit, '1.2.4.1', { file: 'stray.txt', text: 'landed between the two slots\n' });
|
||||
const text = fx.repo.git('show', `${main1}:CHANGELOG.md`).replace('# Changelog\n', '# Changelog\n\n## [1.2.5.0] - 2026-10-18\n\n- #2 change\n- added after the gate\n') + '\n';
|
||||
const main2 = publishMain(fx, t.prefixes[2].commit, '1.2.5.0', { file: 'CHANGELOG.md', text });
|
||||
expect(fx.repo.git('rev-parse', `${main2}^`)).toBe(foreign);
|
||||
const frozen = v('2');
|
||||
expect(frozen.status).toBe(EXIT.fail);
|
||||
expect(frozen.err).toContain(`main ${main2.slice(0, 12)} is neither T1 nor T2 modulo stamps`);
|
||||
expect(frozen.err).toContain(`main's parent ${foreign.slice(0, 12)} is not T1 modulo stamps`);
|
||||
expect(line(frozen.out, 'QUEUE_TRAIN_FROZEN:')).toMatch(/^QUEUE_TRAIN_FROZEN: train \S+ merged #1; main /);
|
||||
}, 300_000);
|
||||
});
|
||||
Reference in new issue
Block a user