diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 7ffaf1414..0cf827ca5 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -39,7 +39,7 @@ refuses the file (`POLICY_GSTACK_TOO_OLD`). `validate` ends with | Key | Values | Default | Meaning | |---|---|---|---| -| `bump` | `patch` \| `ask` \| `never` | `ask` | `patch` skips the MINOR/MAJOR prompt in `/ship` Step 12 and `gstack-restamp --next` bumps PATCH; `ask` keeps the prompt, and in a session no person answers any version Step 12 would write (`--bump` on a fresh branch, `--version ` on a bumped one) stops with `BUMP_ANSWER_REQUIRED` until `--answer ""`; `never` is for a repo whose unit PRs never change VERSION: Step 12 keeps it with no question, refuses `--bump`, and `gstack-restamp --next` needs an explicit `--bump` or `--version` (the release process bumps). `never` needs `min_gstack_version` at its introducing release and cannot be combined with `queue_mode: stamp-at-merge`, `train` or `changelog_fragments`, which stamp a version at every merge. | +| `bump` | `patch` \| `ask` \| `never` | `ask` | `patch` skips the MINOR/MAJOR prompt in `/ship` Step 12 and `gstack-restamp --next` bumps PATCH; `ask` keeps the prompt, and in a session no person answers any version Step 12 would write (`--bump` on a fresh branch, `--version ` on a bumped one) stops with `BUMP_ANSWER_REQUIRED` until `--answer ""`; `never` is for a repo whose unit PRs never change VERSION: Step 12 keeps it with no question, refuses `--bump`, and `gstack-restamp --next` needs an explicit `--bump` or `--version` (the release process bumps). CI's Version Gate passes a head that keeps VERSION; a release PR that moves it takes the ordinary bump and collision rules. `never` needs `min_gstack_version` at its introducing release and cannot be combined with `queue_mode: stamp-at-merge`, `train` or `changelog_fragments`, which stamp a version at every merge. | | `queue_mode` | `claim-at-open` \| `stamp-at-merge` | `claim-at-open` | See [stamp-at-merge](#stamp-at-merge). `claim-at-open` is today's queue (`gstack-next-version` claims a slot when the PR opens). | | `release_tool` | command or `null` | `null` | The repo's own release command (`bun run release:restamp`). Runs in an isolated staging worktree under the [release-tool contract](#release-tool-contract). | | `release_outputs` | globs | `[]` | Paths the release tool may write beyond the version files, lockfiles, CHANGELOG and `stamp_paths`. Anything else is `RESTAMP_OUTPUT_OUTSIDE_ALLOWED`. | diff --git a/lib/version-gate-train.ts b/lib/version-gate-train.ts index 15e79caa5..0a95371dc 100644 --- a/lib/version-gate-train.ts +++ b/lib/version-gate-train.ts @@ -101,10 +101,7 @@ export function classifyTrainHead(o: { repoRoot: string; baseRef: string; head: if (!mergeBase) return { kind: 'invalid', reason: `no merge base between ${base} and ${o.head}` }; const npmVersion = (rev: string) => { try { return JSON.parse(show(rev, 'package.json') ?? '{}').version ?? null; } catch { return null; } }; - if (policy.bump === 'never') { - return show(mergeBase, 'VERSION') === show(o.head, 'VERSION') && npmVersion(mergeBase) === npmVersion(o.head) ? { kind: 'unstamped', entry: 'kept' } - : { kind: 'invalid', reason: `the base's ship policy says bump never: leave VERSION and package.json's version at the merge base's values` }; - } + if (policy.bump === 'never') return show(mergeBase, 'VERSION') === show(o.head, 'VERSION') && npmVersion(mergeBase) === npmVersion(o.head) ? { kind: 'unstamped', entry: 'kept' } : { kind: 'off' }; const changed = (git(o.repoRoot, ['diff', '--name-only', mergeBase, o.head]) ?? '').split('\n').filter(Boolean); const releaseOutputs: string[] = Array.isArray(policy.release_outputs) ? policy.release_outputs : []; // touched: what marks the head as stamped (the labels only feed the length check below) diff --git a/test/version-gate-fragments.test.ts b/test/version-gate-fragments.test.ts index 6a9ed9e8b..c2c080a2d 100644 --- a/test/version-gate-fragments.test.ts +++ b/test/version-gate-fragments.test.ts @@ -26,9 +26,9 @@ function run(r: FixtureRepo, head: string, extra: Record = {}, c } describe('version gate: bump never', () => { - // Value: protects=under bump never the Version Gate passes a head that keeps VERSION and fails one that moves it; fails_when=the gate rejects every kept-version PR as "VERSION not bumped", or lets a unit PR bump; - // why_new=GST-380 added bump never (outside-voice review); seam=classifyTrainHead, scripts/compare-pr-version.ts; no_claim=the claim queue - test('a kept VERSION passes and a moved VERSION fails', () => { + // Value: protects=under bump never the Version Gate passes a head that keeps VERSION, and a release head that moves it goes through the ordinary bump and collision rules; fails_when=the gate rejects every kept-version PR as "VERSION not bumped", or rejects every release PR; + // why_new=GST-380 added bump never (outside-voice review, fix-check); seam=classifyTrainHead, scripts/compare-pr-version.ts; no_claim=who may open a release PR + test('a kept VERSION passes; a moved VERSION takes the ordinary rules', () => { const r = makeFixtureRepo({ policy: { ...BASE_POLICY, queue_mode: 'claim-at-open', release_tool: null, release_outputs: [], bump: 'never', min_gstack_version: BUMP_NEVER_KEY_VERSION }, prefix: 'version-gate-never-' }); repos.push(r); r.git('checkout', '-q', '-b', 'k'); @@ -37,7 +37,9 @@ describe('version gate: bump never', () => { expect([kept.code, kept.out.includes('as the base\'s ship policy (bump never) requires')]).toEqual([0, true]); write(r, 'VERSION', '1.2.4.0\n'); const moved = run(r, commit(r, 'bump')); - expect([moved.code, moved.out.includes('the base\'s ship policy says bump never')]).toEqual([1, true]); + expect([moved.code, moved.out.includes('bump never')]).toEqual([0, false]); + const collides = run(r, r.git('rev-parse', 'HEAD').trim(), {}, [{ pr: 7, branch: 'other', version: '1.2.4.0' }]); + expect([collides.code, collides.out.includes('VERSION collision')]).toEqual([1, true]); }); });