Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions bin/gstack-version-bump
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand All @@ -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`);
Expand Down Expand Up @@ -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);
Expand Down
9 changes: 9 additions & 0 deletions land-and-deploy/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions land-and-deploy/SKILL.md.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions scripts/resolvers/utility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand Down
11 changes: 9 additions & 2 deletions ship/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
11 changes: 9 additions & 2 deletions ship/SKILL.md.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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}}

Expand Down
5 changes: 5 additions & 0 deletions ship/sections/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand Down
34 changes: 34 additions & 0 deletions test/gstack-version-bump.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});