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 <type>: <summary> 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.
This commit is contained in:
Merit Blake 2026-07-24 21:56:00 -04:00
parent a3259400a3
commit 977724b561
8 changed files with 104 additions and 6 deletions

View File

@ -16,7 +16,10 @@
// classify --base <branch> [--version-path <p>]
// Compares VERSION vs origin/<base>: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 <branch>", 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);

View File

@ -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.

View File

@ -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.

View File

@ -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:**

View File

@ -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 <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 `<type>: <summary>` 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 <branch-name>
---
**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<NEW_VERSION> <type>: <summary>`. 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" "<current title>"`. 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 `<type>: <summary>` 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<NEW_VERSION> <type>: <summary>`. 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" "<current title>"`. 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.

View File

@ -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 <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 `<type>: <summary>` 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 <branch-name>
---
**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<NEW_VERSION> <type>: <summary>`. 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" "<current title>"`. 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 `<type>: <summary>` 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<NEW_VERSION> <type>: <summary>`. 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" "<current title>"`. The full create/update procedure (idempotency, redaction scan, self-check) is in the section below.
{{SECTION:pr-body}}

View File

@ -2,6 +2,11 @@
<!-- Regenerate: bun run gen:skill-docs -->
## 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:**

View File

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