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); + }); });