This commit is contained in:
merit-blake 2026-08-15 18:28:55 -04:00 committed by GitHub
commit b9eff8e6bc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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

@ -1238,10 +1238,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

@ -334,10 +334,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

@ -378,6 +378,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

@ -899,7 +899,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.
@ -1083,6 +1083,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.
@ -1382,7 +1389,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

@ -66,7 +66,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.
@ -180,6 +180,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.
@ -478,7 +485,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);
});
});