diff --git a/.gstack/ship-policy.json b/.gstack/ship-policy.json index d50acbc38..ad1b381fd 100644 --- a/.gstack/ship-policy.json +++ b/.gstack/ship-policy.json @@ -14,5 +14,6 @@ "pr_count": { "default_pr_count": 1, "split_requires_owner": true }, "maintainers": ["garrytan"], "train": { "gate_workflow": "evals.yml", "free_workflow": "free-tests.yml", "full_gate_check": "gstack/full-gate", "record_artifact": "gstack-train-record", "max_size": 3, "fill_hours": 1, "screen_selection": "gstack-paid", "trial_reservation_usd": 1.5 }, - "min_gstack_version": "1.91.299.0" + "changelog_fragments": { "dir": "changelog.d", "required_checks": ["free-tests", "windows-free-tests", "Check VERSION is not stale vs queue", "quality", "check-freshness", "actionlint"] }, + "min_gstack_version": "1.91.340.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index e4c856b03..1ef5cfc26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ ## [Unreleased] +**gstack now versions its own PRs at merge time: a PR adds `changelog.d/.md` instead of a version, and trains merge through `train land` instead of slot calls.** `.gstack/ship-policy.json` turns `changelog_fragments` on with the checks every PR runs as `required_checks`, and `min_gstack_version` moves to 1.91.340.0, the first release that reads `required_checks` and counts a stamped slot as merged only when GitHub reports it MERGED, so an older gstack refuses the file by name. + +### Changed +- **`changelog_fragments` is on for gstack** (`.gstack/ship-policy.json`, `changelog.d/README.md`): `required_checks` lists `free-tests`, `windows-free-tests`, `Check VERSION is not stale vs queue`, `quality`, `check-freshness` and `actionlint`. Path-filtered workflows stay out of the list, because a stamp commit of a PR outside their paths never gets those checks. +- **`CHANGELOG_FRAGMENTS_KEY_VERSION` is 1.91.336.0**, the version #3594 landed at, in place of the placeholder 1.92.0.0. +- **/captain teaches `train land`**: under `changelog_fragments` there is no slot call; the controller runs `gstack-ship-queue train land` per slot in order (`captain/SKILL.md.tmpl`, `docs/skills.md`). + +### Fixed +- **A stacked PR whose only own line sits inside its released parent's list merges its CHANGELOG instead of refusing** (`lib/changelog-check.ts`): `mergeChangelogs` filed the line as an addition to the parent's released entry and returned null, so #3613's slot needed a hand resolve. When that would leave the child nothing of its own, the inserted lines are now the child's entry; on #3613's real CHANGELOGs the result equals the resolve that shipped. + +### Tests +- `test/changelog-merge-damage.test.ts`: a child whose only own bullet sits at the top of the released parent's `### Changed` list keeps it as its entry and passes `ownEntryAboveMain`; a child with no line of its own, a reordered parent bullet and a duplicated one stay null. +- `test/gstack-ship-policy.test.ts`: every `required_checks` name is a check a pull_request workflow without a path filter reports (a job's `name:`, or its id when it has none), and `min_gstack_version` is at least the key's introducing version. + +## [Unreleased] + **gstack no longer leaves Ubicloud VMs running after a lane dies (GST-118).** A runner killed mid-run, a copy of the runner without a TTL, or a VM with no owner could keep billing until someone noticed. Now every VM names its owner and its lifetime, the next `up` anywhere destroys anything past its lifetime, and every run says in one line whether its VM is gone. ### Fixed diff --git a/captain/SKILL.md b/captain/SKILL.md index 2655c6345..9984953c0 100644 --- a/captain/SKILL.md +++ b/captain/SKILL.md @@ -261,6 +261,13 @@ Never merge out of order, never merge anything else while a train is merging, an skip the second verify: a mismatch there is what writes the freeze. When every slot has merged, `train prune --train "$NONCE"` deletes the train branch. +**Under `changelog_fragments`** (a stamped train: `train build` printed `S1:`..`Sk:` lines) +there is no slot call and no lane stamp. Per slot, in `ORDER`, run +`gstack-ship-queue train land --train "$NONCE" --pr "$PR"` until it exits 0. It pushes the +stamped prefix S_i as a commit on [pin, `BASE`], waits for the policy's `required_checks`, +merges with `--match-head-commit` and confirms `BASE` is S_i exactly. A rerun resumes from +its journal, and any other `BASE` freezes the queue as above (docs/ship-policy.md#train-land). + **The tree rule.** A gate covers a tree, not a commit. A head whose diff from the gated prefix touches only `STAMP_FILES` reuses the gate; that is what lets the lane stamp after the gate ran. Anything else is a new tree and needs a new gate. diff --git a/captain/SKILL.md.tmpl b/captain/SKILL.md.tmpl index 2664b3d3c..7cc9f5873 100644 --- a/captain/SKILL.md.tmpl +++ b/captain/SKILL.md.tmpl @@ -189,6 +189,13 @@ Never merge out of order, never merge anything else while a train is merging, an skip the second verify: a mismatch there is what writes the freeze. When every slot has merged, `train prune --train "$NONCE"` deletes the train branch. +**Under `changelog_fragments`** (a stamped train: `train build` printed `S1:`..`Sk:` lines) +there is no slot call and no lane stamp. Per slot, in `ORDER`, run +`gstack-ship-queue train land --train "$NONCE" --pr "$PR"` until it exits 0. It pushes the +stamped prefix S_i as a commit on [pin, `BASE`], waits for the policy's `required_checks`, +merges with `--match-head-commit` and confirms `BASE` is S_i exactly. A rerun resumes from +its journal, and any other `BASE` freezes the queue as above (docs/ship-policy.md#train-land). + **The tree rule.** A gate covers a tree, not a commit. A head whose diff from the gated prefix touches only `STAMP_FILES` reuses the gate; that is what lets the lane stamp after the gate ran. Anything else is a new tree and needs a new gate. diff --git a/changelog.d/README.md b/changelog.d/README.md new file mode 100644 index 000000000..f3d1bedbd --- /dev/null +++ b/changelog.d/README.md @@ -0,0 +1,3 @@ +# CHANGELOG fragments + +A PR adds its CHANGELOG entry here as `changelog.d/.md`: the entry text only, with no `## [x]` heading, and `bump: minor` or `bump: major` front matter only when the change warrants more than a patch. It never edits VERSION, `package.json`'s version or a released CHANGELOG section. The merge step (`gstack-ship-queue land`, or `train land` in a stamped train) assigns the version, folds every fragment into one CHANGELOG section and deletes the files. This README is skipped. See docs/ship-policy.md#changelog-fragments. diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 99c2a5dac..c08f4e986 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -90,10 +90,24 @@ hard-code: "stamp_patterns": ["_KEY_VERSION = '{version}'"], "changelog": "CHANGELOG.md", "history": "squash-ok", - "stamp_sensitive_gates": ["tests"] + "stamp_sensitive_gates": ["tests"], + "changelog_fragments": { "dir": "changelog.d", "required_checks": ["free-tests", "windows-free-tests", "Check VERSION is not stale vs queue", "quality", "check-freshness", "actionlint"] }, + "min_gstack_version": "1.91.340.0" } ``` +gstack itself runs on [changelog fragments](#changelog-fragments): a PR adds +`changelog.d/.md` and never a version, and trains merge through +[`train land`](#stamped-trains-under-changelog_fragments-train-land) instead +of slot calls. `required_checks` names the pull_request checks that run on +every PR (branch protection enforces only `free-tests`); a workflow with a path +filter (Windows Setup E2E, make-pdf gate, Native ARM Setup Smoke, Dependency +Review) is left out, because a stamp commit of a PR outside its paths never +gets that check and `train land` would hold on it. `min_gstack_version` is +1.91.340.0, the first release that reads `required_checks` and counts a +stamped slot as merged only when GitHub reports it MERGED, so an older gstack +refuses the file by name (`POLICY_GSTACK_TOO_OLD`). + `.gstack/ship-policy.example.json` shows every key set the way a serial-merge repo with a release script, docs mentions and a mirror would set them. diff --git a/docs/skills.md b/docs/skills.md index 03f4ef9b2..405d690e8 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -45,7 +45,7 @@ Detailed guides for every gstack skill — philosophy, workflow, and examples. | [`/write-skill`](#write-skill) | **Skill Editor** | Write or improve an agent skill without bloat: edit ladder, size per level, skill-to-bin drift check, full diets with a before/after test. | | [`/test-audit`](#test-audit) | **Test Auditor** | Sweep existing tests for low-value, implementation-coupled or duplicate tests. Report-only unless you approve a batch. | | [`/landing-report`](#landing-report) | **Ship Queue Dashboard** | Read-only snapshot of the workspace-aware ship queue. Which version slots are claimed, which sibling workspaces have WIP. | -| [`/captain`](#captain) | **Crew Controller** | Run many coding agents from one controller. Lanes build and send a READY block; the controller validates it, merges through trains (stamp on call, verify, merge with the expected head, verify again), keeps the digest, relays approvals in three fixed shapes and runs a daily flaky-fix pass. Also answers to `/crew`. | +| [`/captain`](#captain) | **Crew Controller** | Run many coding agents from one controller. Lanes build and send a READY block; the controller validates it, merges through trains (stamp on call, verify, merge with the expected head, verify again; `train land` per slot under `changelog_fragments`), keeps the digest, relays approvals in three fixed shapes and runs a daily flaky-fix pass. Also answers to `/crew`. | | [`/benchmark-models`](#benchmark-models) | **Model Benchmark** | Side-by-side cross-model benchmark for skills (Claude vs GPT vs Gemini). Latency, tokens, cost, optional LLM-judged quality. | | [`/eval-plan`](#eval-plan) | **Eval Planner** | Preregister a paid eval (bars, arms, decoys, stop rules), dry-run it for $0 with the stub model, pilot three priced items, then approve the spend from the extrapolation table. | | | | | @@ -1467,7 +1467,9 @@ receipt at the pin and the logged flaky cases); a green PR page or a cheap test never merge evidence. The controller batches READY pins into a **merge train**, reads one full gate on the train head (an in-run flaky rerun once; a case that failed twice is real), then merges slot by slot: slot call, lane stamps on call with a commit that holds -only tool output, `verify --train`, `gh pr merge --match-head-commit`, verify again. A red +only tool output, `verify --train`, `gh pr merge --match-head-commit`, verify again. Under +`changelog_fragments` there is no slot call: `train land` publishes each slot's stamped +prefix and merges it. A red train is bisected on its red cases and rebuilt at most once; a backstop gate on every base push freezes the queue when a tree no train covered lands. Approvals have three shapes (the per-thread record, the plan ask with before → after and a "do it" reply, the diff --git a/lib/changelog-check.ts b/lib/changelog-check.ts index 98cdc72ae..e91fb227c 100644 --- a/lib/changelog-check.ts +++ b/lib/changelog-check.ts @@ -254,11 +254,21 @@ function fragments(removed: string, added: string): { old: string; new: string } * the child changed inside them becomes a correction bullet under the child's * own `### Gate integrity` (the convention #3453's entry used for its parent), * so no wording the gate carried is lost and the released entry below stays - * byte for byte main's. Null when the shape is something else: no single - * `[Unreleased]` on our side, no released entry of theirs the base lacks whose - * text our section holds, or nothing of the child's own left. + * byte for byte main's. A line the child inserted between two of the parent's + * lines counts as an addition to the parent's entry, unless that would leave + * the child nothing of its own: then the inserted lines are the child's entry + * (#3613 put its only bullet at the top of #3595's `### Changed` list), and a + * line meant as an amendment to the parent lands there too. Null when the + * shape is something else: no single `[Unreleased]` on our side, no released + * entry of theirs the base lacks whose text our section holds, nothing of the + * child's own left either way, or a child entry that repeats a released + * parent line (a reordered or duplicated bullet is a hand resolve). */ export function carryReleasedParent(ours: string, theirs: string, base: string): { text: string; notes: string[] } | null { + return carryWith(ours, theirs, base, true) ?? carryWith(ours, theirs, base, false); +} + +function carryWith(ours: string, theirs: string, base: string, insertsJoinParent: boolean): { text: string; notes: string[] } | null { const a = parseChangelog(ours); const unreleased = a.sections.filter(s => s.unreleased); if (unreleased.length !== 1) return null; @@ -268,11 +278,13 @@ export function carryReleasedParent(ours: string, theirs: string, base: string): const notes: string[] = []; const bullets: string[] = []; let carried = false; + const parentLines = new Set(); for (const parent of parseChangelog(theirs).sections.filter(s => !s.unreleased && !baseKeys.has(s.version!))) { const pl = parent.lines.slice(1); const ownBlocks = entryBlocks({ ...unreleased[0]!, lines: ['', ...lines] }); if (!entryBlocks(parent).some(b => ownBlocks.includes(b))) continue; carried = true; + for (const l of pl) if (content(l)) parentLines.add(l); // LCS over content lines only: blanks and sub-headings recur in both entries and would anchor wrongly. const lcs = Array.from({ length: pl.length + 1 }, () => new Uint32Array(lines.length + 1)); for (let i = pl.length - 1; i >= 0; i--) for (let j = lines.length - 1; j >= 0; j--) { @@ -302,7 +314,7 @@ export function carryReleasedParent(ours: string, theirs: string, base: string): } } // Lines added between two of the parent's own lines, or after its last beside an edit there, belong to its entry; the rest are the child's. - if (k > 0 && (k < hunks.length - 1 || h.removed.length)) for (const j of h.added) { + if (insertsJoinParent && k > 0 && (k < hunks.length - 1 || h.removed.length)) for (const j of h.added) { parentAt.add(j); bullets.push(`- Addition to the ${v} entry below: ${strip(lines[j]!)}`); notes.push(`${v}: added "${strip(lines[j]!).slice(0, 60)}"`); @@ -320,6 +332,7 @@ export function carryReleasedParent(ours: string, theirs: string, base: string): }); while (kept.length && !kept.at(-1)!.trim()) kept.pop(); if (!kept.some(content)) return null; + if (kept.some(l => content(l) && parentLines.has(l))) return null; if (bullets.length) { const at = kept.findIndex(l => GATE_INTEGRITY_RE.test(l)); if (at < 0) kept.push('', '### Gate integrity', ...bullets); diff --git a/lib/ship-policy.ts b/lib/ship-policy.ts index 424321cd7..acd3f423c 100644 --- a/lib/ship-policy.ts +++ b/lib/ship-policy.ts @@ -180,7 +180,7 @@ export const TRAIN_KEYS_VERSION = '1.91.146.0'; // its key, so a value a stamp did not move fails there. export const REVIEW_SCOPE_KEY_VERSION = '1.91.256.0'; export const DOCS_CAPS_KEY_VERSION = '1.91.270.0'; -export const CHANGELOG_FRAGMENTS_KEY_VERSION = '1.92.0.0'; +export const CHANGELOG_FRAGMENTS_KEY_VERSION = '1.91.336.0'; export const POLICY_KEY_INTRODUCED: Record = { bump: '1.91.73.0', queue_mode: '1.91.73.0', release_tool: '1.91.73.0', release_outputs: '1.91.73.0', stamp_paths: '1.91.73.0', changelog: '1.91.73.0', mirror: '1.91.73.0', history: '1.91.73.0', preregistration_shas: '1.91.73.0', stamp_sensitive_gates: '1.91.73.0', diff --git a/test/changelog-fragments.test.ts b/test/changelog-fragments.test.ts index f02c72f7e..e2c0b992f 100644 --- a/test/changelog-fragments.test.ts +++ b/test/changelog-fragments.test.ts @@ -12,7 +12,7 @@ import { mergePolicy, validatePolicy } from '../lib/ship-policy'; import { buildReceipt } from '../lib/tree-receipt'; import { BASE_POLICY, cleanup, makeFixtureRepo, readFile, restamp, 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 POLICY = { ...BASE_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.91.336.0' }; const repos: FixtureRepo[] = []; const fixture = () => { const r = makeFixtureRepo({ policy: POLICY }); repos.push(r); return r; }; afterEach(() => { while (repos.length) cleanup(repos.pop()!); }); diff --git a/test/changelog-merge-damage.test.ts b/test/changelog-merge-damage.test.ts index 7a8451c64..ec884a0b7 100644 --- a/test/changelog-merge-damage.test.ts +++ b/test/changelog-merge-damage.test.ts @@ -139,6 +139,24 @@ describe("a stacked child whose parent was released since the fork (GSTA-96, tra 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=a stacked child whose only own line sits inside the released parent's list merges instead of refusing; fails_when=the inserted line is filed as an addition to the parent and the merge returns null (#3613 slot 2, train 57, needed a hand resolve); why_new=every shape above has an own entry outside the parent's lines; seam=none; no_claim=where a line inserted beside the child's own entry goes + test("#3613's shape: a child whose only own line sits inside the released parent's list keeps that line as its entry", () => { + const released = '## [1.0.1] - d\n\n**Parent paragraph.**\n\n### Added\n- new thing\n\n### Changed\n- one\n- two\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### Added\n- new thing\n\n### Changed\n- one\n- two\n\n## [1.0.0] - d\n\n- base\n'; + const child = '# C\n\n## [Unreleased]\n\n**Parent paragraph.**\n\n### Added\n- new thing\n\n### Changed\n- mine\n- one\n- two\n\n## [1.0.0] - d\n\n- base\n'; + const r = carryReleasedParent(child, theirs, base)!; + expect(r.notes).toEqual([]); + expect(r.text).toBe('# C\n\n## [Unreleased]\n\n### Changed\n- mine\n\n## [1.0.0] - d\n\n- base\n'); + const merged = mergeChangelogs(child, theirs, base)!; + expect(merged).toBe(`# C\n\n## [Unreleased]\n\n### Changed\n- mine\n\n${released}\n## [1.0.0] - d\n\n- base\n`); + expect(ownEntryAboveMain(merged, theirs, new Set(['### Changed', '- mine', '- one', '- two', '- new thing', '### Added', '**Parent paragraph.**']))).toBeNull(); + expect(carryReleasedParent('# C\n\n## [Unreleased]\n\n**Parent paragraph.**\n\n### Added\n- new thing\n\n### Changed\n- one\n- two\n\n## [1.0.0] - d\n\n- base\n', theirs, base)).toBeNull(); + // A reordered or duplicated parent bullet is not the child's entry: it stays a hand resolve. + expect(mergeChangelogs('# C\n\n## [Unreleased]\n\n**Parent paragraph.**\n\n### Added\n- new thing\n\n### Changed\n- two\n- one\n\n## [1.0.0] - d\n\n- base\n', theirs, base)).toBeNull(); + expect(mergeChangelogs('# C\n\n## [Unreleased]\n\n**Parent paragraph.**\n\n### Added\n- new thing\n\n### Changed\n- one\n- one\n- two\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'; diff --git a/test/gstack-ship-policy.test.ts b/test/gstack-ship-policy.test.ts index 20c0e3343..9463d8980 100644 --- a/test/gstack-ship-policy.test.ts +++ b/test/gstack-ship-policy.test.ts @@ -8,8 +8,9 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { spawnSync } from 'node:child_process'; import { EXIT } from '../lib/headless-artifacts'; -import { DEFAULT_POLICY, POLICY_KEYS, POLICY_KEY_INTRODUCED, REVIEW_SCOPE_KEY_VERSION, globToRegExp, matchesAny, validatePolicy } from '../lib/ship-policy'; +import { CHANGELOG_FRAGMENTS_KEY_VERSION, DEFAULT_POLICY, POLICY_KEYS, POLICY_KEY_INTRODUCED, REVIEW_SCOPE_KEY_VERSION, globToRegExp, matchesAny, validatePolicy } from '../lib/ship-policy'; import { fillDue } from '../lib/ship-queue-train'; +import { cmpVersion, parseVersion } from '../lib/version-source'; import { BASE_POLICY, cleanup, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/restamp-fixture'; const repos: FixtureRepo[] = []; @@ -126,6 +127,27 @@ describe("gstack's own .gstack/ship-policy.json", () => { for (const wf of [raw.train.gate_workflow, raw.train.free_workflow]) expect(fs.existsSync(path.join(root, '.github/workflows', wf))).toBe(true); }); + // Value: protects=train land on gstack itself: every changelog_fragments.required_checks name is a job of a pull_request workflow without a path filter, so a stamp commit always gets it; fails_when=a name is misspelled, names a path-filtered workflow's job, or names a job id GitHub reports under its `name:` instead, and train land holds every slot whose PR skips those paths; why_new=the key was off on gstack; seam=the policy file and .github/workflows; no_claim=that the checks pass + test('turns changelog_fragments on with required checks every PR runs', () => { + const root = path.join(import.meta.dir, '..'); + const raw = JSON.parse(fs.readFileSync(path.join(root, '.gstack', 'ship-policy.json'), 'utf8')); + expect(raw.changelog_fragments.dir).toBe('changelog.d'); + expect(fs.existsSync(path.join(root, 'changelog.d', 'README.md'))).toBe(true); + expect(cmpVersion(parseVersion(raw.min_gstack_version)!, parseVersion(CHANGELOG_FRAGMENTS_KEY_VERSION)!)).toBeGreaterThanOrEqual(0); + const jobs = new Set(); + for (const f of fs.readdirSync(path.join(root, '.github/workflows')).filter(f => f.endsWith('.yml'))) { + const text = fs.readFileSync(path.join(root, '.github/workflows', f), 'utf8'); + const on = text.slice(text.search(/^on:/m), text.search(/^jobs:/m)); + if (!/^\s+pull_request:/m.test(on) || /^\s+paths(-ignore)?:/m.test(on)) continue; + const body = text.slice(text.search(/^jobs:/m)); + for (const m of body.matchAll(/^ ([A-Za-z0-9_-]+):\n((?: .*\n|\s*\n)*)/gm)) { + const name = /^ name:\s*(.+)$/m.exec(m[2]!)?.[1]?.trim(); + jobs.add(name ?? m[1]!); + } + } + expect(raw.changelog_fragments.required_checks.filter((n: string) => !jobs.has(n))).toEqual([]); + }); + // Value: protects=the fill rule the coordinator relies on: a train dispatches at 3 READY PRs or one hour after the first READY, whichever comes first // (2026-10-26: 5 PRs / 3 hours left READY PRs waiting most of a day); fails_when=the live block drifts back to a larger or slower fill; why_new=the values were unpinned; seam=the policy file; no_claim=fillDue's arithmetic (ship-queue-train-logic) test('fills a train at 3 READY PRs or one hour after the first READY, whichever comes first', () => { @@ -161,7 +183,7 @@ describe('gstack-ship-policy (bin)', () => { expect(r.out).toMatch(/^ship: release_tool: `node gen\.js` runs only inside gstack-restamp/m); expect(r.out).not.toMatch(/^ship: history/m); expect(r.out).not.toMatch(/^ship: changelog_fragments/m); - const frag = fixture({ policy: { ...BASE_POLICY, changelog_fragments: { dir: 'changelog.d/' }, min_gstack_version: '1.92.0.0' } }); + const frag = fixture({ policy: { ...BASE_POLICY, changelog_fragments: { dir: 'changelog.d/' }, min_gstack_version: '1.91.336.0' } }); expect(runBin(frag, 'gstack-ship-policy', ['show-effective', '--base', 'main']).out).toMatch(/^ship: changelog_fragments changelog\.d: Step 12 writes no version and Step 13 writes the entry as changelog\.d\/\.md .*never an \[Unreleased\] section; lint it with `gstack-prose-lint changelog\.d\/\.md --gate`/m); const strict = fixture({ policy: { ...BASE_POLICY, history: 'merge-only', queue_mode: 'claim-at-open', bump: 'ask', release_tool: null, preregistration_shas: ['0123456789abcdef0123456789abcdef01234567'] } }); const lines = runBin(strict, 'gstack-ship-policy', ['show-effective', '--base', 'main']).out.split('\n').filter(l => l.startsWith('ship: ')); diff --git a/test/pregate-remote-stamp-only.test.ts b/test/pregate-remote-stamp-only.test.ts index 1a50a49b0..2ec278d83 100644 --- a/test/pregate-remote-stamp-only.test.ts +++ b/test/pregate-remote-stamp-only.test.ts @@ -297,7 +297,7 @@ describe.skipIf(process.platform === 'win32')('clear --receipt under changelog_f test('the fold of a deleted fragment is a stamp; a fold that changed the text is not', () => { const repo = makeFixtureRepo({ pregate: { ...BASE_PREGATE, remote_gate: GATE }, - baseFiles: { '.gstack/ship-policy.json': JSON.stringify({ ...POLICY, changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.92.0.0' }, null, 2) + '\n', VERSION: '1.0.0\n', 'CHANGELOG.md': '# Changelog\n\n## [1.0.0] - 2026-10-01\n\n- base\n' }, + baseFiles: { '.gstack/ship-policy.json': JSON.stringify({ ...POLICY, changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.91.336.0' }, null, 2) + '\n', VERSION: '1.0.0\n', 'CHANGELOG.md': '# Changelog\n\n## [1.0.0] - 2026-10-01\n\n- base\n' }, }); repos.push(repo); repo.git('checkout', '-q', 'feature'); diff --git a/test/ship-queue-land.test.ts b/test/ship-queue-land.test.ts index 4d5cc9b71..4b8fabfb8 100644 --- a/test/ship-queue-land.test.ts +++ b/test/ship-queue-land.test.ts @@ -17,7 +17,7 @@ import { resolveReleaseConflicts } from '../lib/ship-queue-land'; import { mergePolicy } from '../lib/ship-policy'; import { persist, repoIdentity } from '../lib/ship-queue-train-record'; -const POLICY = { ...BASE_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.92.0.0' }; +const POLICY = { ...BASE_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.91.336.0' }; const repos: FixtureRepo[] = []; afterEach(() => { while (repos.length) cleanup(repos.pop()!); }); diff --git a/test/ship-queue-train-land.test.ts b/test/ship-queue-train-land.test.ts index 262d82b64..64a046ee7 100644 --- a/test/ship-queue-train-land.test.ts +++ b/test/ship-queue-train-land.test.ts @@ -13,7 +13,7 @@ import { GREEN_GATE, RED_GATE, TRAIN_POLICY, line, nonceOf, openPr, queue, readyReceipt, shippedBody, trainFixture, trainJson, type TrainFixture, } from './helpers/ship-queue-train-fixture'; -const POLICY = { ...TRAIN_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.92.0.0' }; +const POLICY = { ...TRAIN_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.91.336.0' }; const fixtures: TrainFixture[] = []; afterEach(() => { while (fixtures.length) cleanup(fixtures.pop()!.repo); }); diff --git a/test/ship-queue-train-stamp.test.ts b/test/ship-queue-train-stamp.test.ts index 37caacac4..8ae814052 100644 --- a/test/ship-queue-train-stamp.test.ts +++ b/test/ship-queue-train-stamp.test.ts @@ -12,7 +12,7 @@ import { stampChain, stampMismatch, type StampChainOptions } from '../lib/ship-q 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 POLICY = { ...BASE_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.91.336.0' }; const repos: FixtureRepo[] = []; afterEach(() => { while (repos.length) cleanup(repos.pop()!); }); diff --git a/test/ship-queue-train.test.ts b/test/ship-queue-train.test.ts index 0f47abd68..969b3596d 100644 --- a/test/ship-queue-train.test.ts +++ b/test/ship-queue-train.test.ts @@ -111,7 +111,7 @@ describe('the policy gate', () => { // Value: protects=under changelog_fragments ready records the READY receipt land checks, only after every ready check passed; fails_when=land has no proof of the pregate; why_new=the case above covers the key off; seam=none; no_claim=the notes push to a real GitHub remote test('under changelog_fragments ready records a READY receipt; a failed pregate records none', () => { - const fx = make({ ...BASE_POLICY, changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.92.0.0' }); + const fx = make({ ...BASE_POLICY, changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.91.336.0' }); const head = openPr(fx, '1'); fx.write(d => { d.ci[head] = [{ status: 'completed', conclusion: 'success' }]; }); const tree = runBin(fx.repo, 'gstack-wtree', []).out.trim(); diff --git a/test/version-gate-fragments.test.ts b/test/version-gate-fragments.test.ts index 76eb21081..5431b0559 100644 --- a/test/version-gate-fragments.test.ts +++ b/test/version-gate-fragments.test.ts @@ -10,7 +10,7 @@ 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 POLICY = { ...BASE_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.91.336.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; };