From 977724b5614047af09d8540223a85d005fed55ed Mon Sep 17 00:00:00 2001 From: Merit Blake Date: Fri, 24 Jul 2026 21:56:00 -0400 Subject: [PATCH] feat(ship): skip version-bump/CHANGELOG/title-rewrite when the repo has no VERSION file Repos that stamp versions at release/queue time (merge-queue driven repos, release-please/tag-based flows) have no VERSION file, but /ship today manufactures one: gstack-version-bump treats a missing file as 0.0.0.0, classifies FRESH, and `write` creates VERSION at the bumped slot - then writes a CHANGELOG release heading and rewrites the PR title to vX.Y.Z.W. On a queue-managed repo every such PR textually collides with every other (we hit three pairs of open PRs claiming identical versions in one repo before tracing it here). The convention was already keyed off the VERSION file's presence; this makes the absence a first-class state instead of a 0.0.0.0 default: - gstack-version-bump classify: new NO_VERSION_FILE state when the resolved version file does not exist (never invents 0.0.0.0, never creates the file; also covers a branch that deletes VERSION to retire the convention). write/repair are unchanged. - ship Step 12: NO_VERSION_FILE dispatch arm - skip Steps 12-13 and the PR/MR title version invariant; plain : titles; never create a VERSION file. Idempotency note updated for re-runs. - ship Step 13 (CHANGELOG): explicit skip under NO_VERSION_FILE. - ship title invariant: now conditional on Step 12 having produced a version. - land-and-deploy Step 3.4: VERSION drift detection prints n/a and skips when neither the head nor the base has a VERSION file. - Tests: NO_VERSION_FILE for a never-versioned repo (asserts classify stays a pure reader and creates nothing) and for a branch that deleted VERSION while base still has it. bun test on gstack-version-bump, ship-version-sync, gstack-next-version: 59 pass / 0 fail. Generated SKILL.md files regenerated with bun run gen:skill-docs. --- bin/gstack-version-bump | 26 ++++++++++++++++++++++-- land-and-deploy/SKILL.md | 9 +++++++++ land-and-deploy/SKILL.md.tmpl | 9 +++++++++ scripts/resolvers/utility.ts | 5 +++++ ship/SKILL.md | 11 +++++++++-- ship/SKILL.md.tmpl | 11 +++++++++-- ship/sections/changelog.md | 5 +++++ test/gstack-version-bump.test.ts | 34 ++++++++++++++++++++++++++++++++ 8 files changed, 104 insertions(+), 6 deletions(-) diff --git a/bin/gstack-version-bump b/bin/gstack-version-bump index 298fab17d4..673e855275 100755 --- a/bin/gstack-version-bump +++ b/bin/gstack-version-bump @@ -16,7 +16,10 @@ // classify --base [--version-path

] // Compares VERSION vs origin/:VERSION vs package.json.version. // Emits JSON: { state, baseVersion, currentVersion, pkgVersion, pkgExists } -// state ∈ FRESH | ALREADY_BUMPED | DRIFT_STALE_PKG | DRIFT_UNEXPECTED +// state ∈ NO_VERSION_FILE | FRESH | ALREADY_BUMPED | DRIFT_STALE_PKG | DRIFT_UNEXPECTED +// NO_VERSION_FILE = the resolved version file does not exist — the repo +// does not use per-PR version stamping (release-time stamping, or a +// branch that deleted VERSION); /ship skips bump/CHANGELOG/title. // Exit 0 on a decidable state (incl. DRIFT_UNEXPECTED — it's a real state // the caller must handle), exit 2 on bad args / unresolvable base. // @@ -41,7 +44,7 @@ import { join } from "node:path"; const VERSION_RE = /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/; const DEFAULT = "0.0.0.0"; -type State = "FRESH" | "ALREADY_BUMPED" | "DRIFT_STALE_PKG" | "DRIFT_UNEXPECTED"; +type State = "NO_VERSION_FILE" | "FRESH" | "ALREADY_BUMPED" | "DRIFT_STALE_PKG" | "DRIFT_UNEXPECTED"; function fail(msg: string, code = 2): never { process.stderr.write(`gstack-version-bump: ${msg}\n`); @@ -135,6 +138,25 @@ function cmdClassify(args: string[], cwd: string): void { if (!base) fail("classify requires --base ", 2); const versionPath = resolveVersionPath(cwd, argVal(args, "--version-path")); const versionRel = argVal(args, "--version-path") ?? "VERSION"; + // A repo without a version file is not using file-based per-PR versioning + // at all (versions stamped at release/queue time, or a branch that deleted + // VERSION to retire the convention). That is a distinct state, not a + // 0.0.0.0 default: /ship must SKIP the bump/CHANGELOG/title steps, never + // manufacture a VERSION file into such a repo. + if (!existsSync(versionPath)) { + const baseV = baseVersion(cwd, base!, versionRel); + const pkg = readPkgVersion(cwd); + process.stdout.write( + JSON.stringify({ + state: "NO_VERSION_FILE", + baseVersion: baseV === DEFAULT ? null : baseV, + currentVersion: null, + pkgVersion: pkg.version || null, + pkgExists: pkg.exists, + }) + "\n", + ); + return; + } const current = readVersionFile(versionPath); const baseV = baseVersion(cwd, base!, versionRel); const pkg = readPkgVersion(cwd); diff --git a/land-and-deploy/SKILL.md b/land-and-deploy/SKILL.md index 54ebf52c0f..1867d9cdf3 100644 --- a/land-and-deploy/SKILL.md +++ b/land-and-deploy/SKILL.md @@ -1209,10 +1209,19 @@ If timeout (15 min): **STOP.** "CI has been running for over 15 minutes — that Before gathering readiness evidence, verify that the VERSION this PR claims is still the next free slot. A sibling workspace may have shipped and landed since `/ship` ran, leaving this PR's VERSION stale. +**Version-less repos skip this step entirely.** If neither the PR head nor the +base branch has a VERSION file (the repo does not use per-PR version stamping — +versions stamped at release/queue time, matching `gstack-version-bump`'s +`NO_VERSION_FILE` state), print `VERSION drift: n/a (repo has no VERSION file)` +and continue to Step 3.5. + ```bash BRANCH_VERSION=$(git show HEAD:VERSION 2>/dev/null | tr -d '\r\n[:space:]' || echo "") BASE_BRANCH=$(gh pr view --json baseRefName -q .baseRefName 2>/dev/null || echo main) BASE_VERSION=$(git show origin/$BASE_BRANCH:VERSION 2>/dev/null | tr -d '\r\n[:space:]' || echo "") +if [ -z "$BRANCH_VERSION" ] && [ -z "$BASE_VERSION" ]; then + echo "VERSION drift: n/a (repo has no VERSION file)" +fi # Imply bump level by comparing branch VERSION to base (crude but good enough for drift detection) # We don't need the exact original level — we just need "a level" that passes to the util. diff --git a/land-and-deploy/SKILL.md.tmpl b/land-and-deploy/SKILL.md.tmpl index 98976ad020..f4f8ac67fd 100644 --- a/land-and-deploy/SKILL.md.tmpl +++ b/land-and-deploy/SKILL.md.tmpl @@ -332,10 +332,19 @@ If timeout (15 min): **STOP.** "CI has been running for over 15 minutes — that Before gathering readiness evidence, verify that the VERSION this PR claims is still the next free slot. A sibling workspace may have shipped and landed since `/ship` ran, leaving this PR's VERSION stale. +**Version-less repos skip this step entirely.** If neither the PR head nor the +base branch has a VERSION file (the repo does not use per-PR version stamping — +versions stamped at release/queue time, matching `gstack-version-bump`'s +`NO_VERSION_FILE` state), print `VERSION drift: n/a (repo has no VERSION file)` +and continue to Step 3.5. + ```bash BRANCH_VERSION=$(git show HEAD:VERSION 2>/dev/null | tr -d '\r\n[:space:]' || echo "") BASE_BRANCH=$(gh pr view --json baseRefName -q .baseRefName 2>/dev/null || echo main) BASE_VERSION=$(git show origin/$BASE_BRANCH:VERSION 2>/dev/null | tr -d '\r\n[:space:]' || echo "") +if [ -z "$BRANCH_VERSION" ] && [ -z "$BASE_VERSION" ]; then + echo "VERSION drift: n/a (repo has no VERSION file)" +fi # Imply bump level by comparing branch VERSION to base (crude but good enough for drift detection) # We don't need the exact original level — we just need "a level" that passes to the util. diff --git a/scripts/resolvers/utility.ts b/scripts/resolvers/utility.ts index 3d2e368a29..f98d615b6c 100644 --- a/scripts/resolvers/utility.ts +++ b/scripts/resolvers/utility.ts @@ -375,6 +375,11 @@ export function generateCoAuthorTrailer(ctx: TemplateContext): string { export function generateChangelogWorkflow(_ctx: TemplateContext): string { return `## Step 13: CHANGELOG (auto-generate) +**Skip this step entirely when Step 12 classified \`NO_VERSION_FILE\`** — a +repo without per-PR version stamping has no release heading to write (its +release notes are generated at release/queue time). Do not edit CHANGELOG.md +there; continue to Step 14. + 1. Read \`CHANGELOG.md\` header to know the format. 2. **First, enumerate every commit on the branch:** diff --git a/ship/SKILL.md b/ship/SKILL.md index eadffaa8f6..761f51e371 100644 --- a/ship/SKILL.md +++ b/ship/SKILL.md @@ -873,7 +873,7 @@ Re-running `/ship` means "run the whole checklist again." Every verification ste (tests, coverage audit, plan completion, pre-landing review, adversarial review, VERSION/CHANGELOG check, TODOS, document-release) runs on every invocation. Only *actions* are idempotent: -- Step 12: If VERSION already bumped, skip the bump but still read the version +- Step 12: If VERSION already bumped, skip the bump but still read the version; if classify reports `NO_VERSION_FILE`, Steps 12-13 and the title invariant stay skipped on every re-run - Step 17: If already pushed, skip the push command - Step 19: If PR exists, update the body instead of creating a new PR Never skip a verification step because a prior `/ship` run already performed it. @@ -1044,6 +1044,13 @@ stay agent judgment; the slot pick stays `gstack-next-version`. bun run ~/.claude/skills/gstack/bin/gstack-version-bump classify --base ``` Read the JSON `state` and dispatch: + - **NO_VERSION_FILE** → this repo does not use per-PR version stamping (no + version file on this branch — e.g. versions are stamped at release/queue + time by CI, or the repo retired the convention). **Skip the rest of Step + 12, skip Step 13 (CHANGELOG), and skip the PR/MR title version invariant + before Step 18** — use a plain `:

` title, and NEVER + create a VERSION file or a CHANGELOG release heading in such a repo. + Continue at Step 14. - **FRESH** → do the bump (steps 2-4). - **ALREADY_BUMPED** → skip the bump, but run the queue-drift check (step 3) with the reported `currentVersion`. If the queue moved (next free version differs), **AskUserQuestion**: rebump to the new version (rewrites CHANGELOG header + PR title) or keep current (CI version-gate will reject until resolved). - **DRIFT_STALE_PKG** → run `gstack-version-bump repair` (syncs package.json to VERSION). No re-bump; reuse `currentVersion` for CHANGELOG + PR. @@ -1337,7 +1344,7 @@ git push -u origin --- -**PR/MR title invariant (always applies — do not skip even if you don't open the section below):** Any PR or MR you create OR update in the next step MUST have a title that starts with `v$NEW_VERSION` (the version bumped in Step 12), in the format `v : `. Never create or edit a PR/MR title without this prefix. Compute the correct title with the single source of truth helper: `~/.claude/skills/gstack/bin/gstack-pr-title-rewrite.sh "$NEW_VERSION" ""`. The full create/update procedure (idempotency, redaction scan, self-check) is in the section below. +**PR/MR title invariant (applies whenever Step 12 produced a version — do not skip even if you don't open the section below; if Step 12 classified `NO_VERSION_FILE`, titles stay plain `: ` and this invariant does not apply):** Any PR or MR you create OR update in the next step MUST have a title that starts with `v$NEW_VERSION` (the version bumped in Step 12), in the format `v : `. Never create or edit a PR/MR title without this prefix. Compute the correct title with the single source of truth helper: `~/.claude/skills/gstack/bin/gstack-pr-title-rewrite.sh "$NEW_VERSION" ""`. The full create/update procedure (idempotency, redaction scan, self-check) is in the section below. > **STOP.** Before syncing docs and creating or updating the PR/MR (Steps 18-19), Read `~/.claude/skills/gstack/ship/sections/pr-body.md` and execute it > in full. Do not work from memory — that section is the source of truth for this step. diff --git a/ship/SKILL.md.tmpl b/ship/SKILL.md.tmpl index 068ac4fe54..a675f1d6e6 100644 --- a/ship/SKILL.md.tmpl +++ b/ship/SKILL.md.tmpl @@ -64,7 +64,7 @@ Re-running `/ship` means "run the whole checklist again." Every verification ste (tests, coverage audit, plan completion, pre-landing review, adversarial review, VERSION/CHANGELOG check, TODOS, document-release) runs on every invocation. Only *actions* are idempotent: -- Step 12: If VERSION already bumped, skip the bump but still read the version +- Step 12: If VERSION already bumped, skip the bump but still read the version; if classify reports `NO_VERSION_FILE`, Steps 12-13 and the title invariant stay skipped on every re-run - Step 17: If already pushed, skip the push command - Step 19: If PR exists, update the body instead of creating a new PR Never skip a verification step because a prior `/ship` run already performed it. @@ -166,6 +166,13 @@ stay agent judgment; the slot pick stays `gstack-next-version`. bun run ~/.claude/skills/gstack/bin/gstack-version-bump classify --base ``` Read the JSON `state` and dispatch: + - **NO_VERSION_FILE** → this repo does not use per-PR version stamping (no + version file on this branch — e.g. versions are stamped at release/queue + time by CI, or the repo retired the convention). **Skip the rest of Step + 12, skip Step 13 (CHANGELOG), and skip the PR/MR title version invariant + before Step 18** — use a plain `: ` title, and NEVER + create a VERSION file or a CHANGELOG release heading in such a repo. + Continue at Step 14. - **FRESH** → do the bump (steps 2-4). - **ALREADY_BUMPED** → skip the bump, but run the queue-drift check (step 3) with the reported `currentVersion`. If the queue moved (next free version differs), **AskUserQuestion**: rebump to the new version (rewrites CHANGELOG header + PR title) or keep current (CI version-gate will reject until resolved). - **DRIFT_STALE_PKG** → run `gstack-version-bump repair` (syncs package.json to VERSION). No re-bump; reuse `currentVersion` for CHANGELOG + PR. @@ -458,7 +465,7 @@ git push -u origin --- -**PR/MR title invariant (always applies — do not skip even if you don't open the section below):** Any PR or MR you create OR update in the next step MUST have a title that starts with `v$NEW_VERSION` (the version bumped in Step 12), in the format `v : `. Never create or edit a PR/MR title without this prefix. Compute the correct title with the single source of truth helper: `~/.claude/skills/gstack/bin/gstack-pr-title-rewrite.sh "$NEW_VERSION" ""`. The full create/update procedure (idempotency, redaction scan, self-check) is in the section below. +**PR/MR title invariant (applies whenever Step 12 produced a version — do not skip even if you don't open the section below; if Step 12 classified `NO_VERSION_FILE`, titles stay plain `: ` and this invariant does not apply):** Any PR or MR you create OR update in the next step MUST have a title that starts with `v$NEW_VERSION` (the version bumped in Step 12), in the format `v : `. Never create or edit a PR/MR title without this prefix. Compute the correct title with the single source of truth helper: `~/.claude/skills/gstack/bin/gstack-pr-title-rewrite.sh "$NEW_VERSION" ""`. The full create/update procedure (idempotency, redaction scan, self-check) is in the section below. {{SECTION:pr-body}} diff --git a/ship/sections/changelog.md b/ship/sections/changelog.md index 1c0834618c..b509eab203 100644 --- a/ship/sections/changelog.md +++ b/ship/sections/changelog.md @@ -2,6 +2,11 @@ ## Step 13: CHANGELOG (auto-generate) +**Skip this step entirely when Step 12 classified `NO_VERSION_FILE`** — a +repo without per-PR version stamping has no release heading to write (its +release notes are generated at release/queue time). Do not edit CHANGELOG.md +there; continue to Step 14. + 1. Read `CHANGELOG.md` header to know the format. 2. **First, enumerate every commit on the branch:** diff --git a/test/gstack-version-bump.test.ts b/test/gstack-version-bump.test.ts index ffcecd1a7f..6ee328d76b 100644 --- a/test/gstack-version-bump.test.ts +++ b/test/gstack-version-bump.test.ts @@ -130,4 +130,38 @@ describe('classify (idempotency over a real git base)', () => { expect(parsed.baseVersion).toBe('1.0.0.0'); expect(parsed.currentVersion).toBe('1.1.0.0'); }); + + test('reports NO_VERSION_FILE when the branch deleted VERSION (base still has one)', () => { + fs.rmSync(path.join(dir, 'VERSION')); + const out = execFileSync('bun', [BIN, 'classify', '--base', 'main'], { cwd: dir }).toString(); + const parsed = JSON.parse(out); + expect(parsed.state).toBe('NO_VERSION_FILE'); + expect(parsed.currentVersion).toBeNull(); + expect(parsed.baseVersion).toBe('1.0.0.0'); + }); +}); + +describe('classify (version-less repo)', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-noversion-')); + afterAll(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* noop */ } }); + + // A repo that never had a VERSION file — release-time stamping. + const git = (...a: string[]) => execFileSync('git', a, { cwd: dir, stdio: 'pipe' }); + fs.writeFileSync(path.join(dir, 'README.md'), 'no versions here\n'); + git('init', '-q', '-b', 'main'); + git('config', 'user.email', 't@t'); git('config', 'user.name', 't'); + git('add', '-A'); git('commit', '-q', '-m', 'base'); + const head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: dir }).toString().trim(); + fs.mkdirSync(path.join(dir, '.git', 'refs', 'remotes', 'origin'), { recursive: true }); + fs.writeFileSync(path.join(dir, '.git', 'refs', 'remotes', 'origin', 'main'), head + '\n'); + + test('reports NO_VERSION_FILE with null versions and never invents 0.0.0.0', () => { + const out = execFileSync('bun', [BIN, 'classify', '--base', 'main'], { cwd: dir }).toString(); + const parsed = JSON.parse(out); + expect(parsed.state).toBe('NO_VERSION_FILE'); + expect(parsed.currentVersion).toBeNull(); + expect(parsed.baseVersion).toBeNull(); + // classify is a pure reader: it must not have created a VERSION file. + expect(fs.existsSync(path.join(dir, 'VERSION'))).toBe(false); + }); });