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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## [1.60.2.0] - 2026-07-23

### Fixed

- `/ship` now preserves repositories that intentionally omit `VERSION`: classification returns an explicit `VERSIONLESS` state, keeps the existing `package.json` version untouched, skips versioned changelog and PR-title rules, and continues through commit, push, and PR creation. Repositories with a real `VERSION` retain the existing package-drift stop.
- Generated Claude, Codex, Factory, and other host ship surfaces now carry the versionless dispatch, with golden fixtures and real-git regression coverage preventing the path from drifting.

## [1.60.1.0] - 2026-07-09

## **The /autoplan dual-voice eval is back on the board, catching real regressions.**
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.60.1.0
1.60.2.0
56 changes: 37 additions & 19 deletions bin/gstack-version-bump
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@
// Subcommands:
// 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
// Emits JSON: { state, baseVersion, currentVersion, pkgVersion, pkgExists,
// baseVersionExists, versionExists }
// state ∈ VERSIONLESS | FRESH | ALREADY_BUMPED | DRIFT_STALE_PKG | DRIFT_UNEXPECTED
// 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 +42,8 @@ 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 = "VERSIONLESS" | "FRESH" | "ALREADY_BUMPED" | "DRIFT_STALE_PKG" | "DRIFT_UNEXPECTED";
type VersionFile = { exists: boolean; version: string };

function fail(msg: string, code = 2): never {
process.stderr.write(`gstack-version-bump: ${msg}\n`);
Expand All @@ -64,12 +66,12 @@ function resolveVersionPath(cwd: string, explicit?: string): string {
return join(cwd, "VERSION");
}

function readVersionFile(p: string): string {
function readVersionFile(p: string): VersionFile {
try {
const v = readFileSync(p, "utf-8").replace(/[\r\n\s]/g, "");
return v || DEFAULT;
return { exists: true, version: v || DEFAULT };
} catch {
return DEFAULT;
return { exists: false, version: DEFAULT };
}
}

Expand Down Expand Up @@ -101,24 +103,31 @@ function writePkgVersion(cwd: string, version: string): void {
writeFileSync(pkgPath, JSON.stringify(parsed, null, 2) + "\n");
}

function baseVersion(cwd: string, base: string, versionRel: string): string {
function baseVersion(cwd: string, base: string, versionRel: string): VersionFile {
// Verify the base ref resolves, mirroring the Step 12 guard.
try {
execFileSync("git", ["rev-parse", "--verify", `origin/${base}`], { cwd, stdio: "ignore" });
} catch {
fail(`Unable to resolve origin/${base}. Run 'git fetch origin' or verify the base branch exists.`, 2);
}
try {
const out = execFileSync("git", ["show", `origin/${base}:${versionRel}`], { cwd }).toString();
const out = execFileSync("git", ["show", `origin/${base}:${versionRel}`], { cwd, stdio: "pipe" }).toString();
const v = out.replace(/[\r\n\s]/g, "");
return v || DEFAULT;
return { exists: true, version: v || DEFAULT };
} catch {
// VERSION absent on base (new repo / new file) → treat as 0.0.0.0.
return DEFAULT;
return { exists: false, version: DEFAULT };
}
}

function classifyState(current: string, base: string, pkgExists: boolean, pkgVersion: string): State {
function classifyState(
current: string,
base: string,
pkgExists: boolean,
pkgVersion: string,
versionExists: boolean,
baseVersionExists: boolean,
): State {
if (!versionExists && !baseVersionExists) return "VERSIONLESS";
if (current === base) {
// VERSION unchanged vs base. A diverging package.json means someone hand-edited
// package.json bypassing /ship — unsafe to guess which is authoritative.
Expand All @@ -138,14 +147,23 @@ function cmdClassify(args: string[], cwd: string): void {
const current = readVersionFile(versionPath);
const baseV = baseVersion(cwd, base!, versionRel);
const pkg = readPkgVersion(cwd);
const state = classifyState(current, baseV, pkg.exists, pkg.version);
const state = classifyState(
current.version,
baseV.version,
pkg.exists,
pkg.version,
current.exists,
baseV.exists,
);
process.stdout.write(
JSON.stringify({
state,
baseVersion: baseV,
currentVersion: current,
baseVersion: baseV.exists ? baseV.version : null,
currentVersion: current.exists ? current.version : null,
pkgVersion: pkg.version || null,
pkgExists: pkg.exists,
baseVersionExists: baseV.exists,
versionExists: current.exists,
}) + "\n",
);
// DRIFT_UNEXPECTED is a real, decidable state — the caller stops on it, but the
Expand Down Expand Up @@ -178,9 +196,9 @@ function cmdWrite(args: string[], cwd: string): void {
function cmdRepair(args: string[], cwd: string): void {
const versionPath = resolveVersionPath(cwd, argVal(args, "--version-path"));
const current = readVersionFile(versionPath);
if (!VERSION_RE.test(current)) {
if (!current.exists || !VERSION_RE.test(current.version)) {
fail(
`VERSION file contents (${current}) do not match MAJOR.MINOR.PATCH.MICRO. ` +
`VERSION file contents (${current.version}) do not match MAJOR.MINOR.PATCH.MICRO. ` +
"Refusing to propagate invalid semver into package.json. Fix VERSION, then re-run /ship.",
2,
);
Expand All @@ -189,11 +207,11 @@ function cmdRepair(args: string[], cwd: string): void {
fail("repair: no package.json to sync.", 2);
}
try {
writePkgVersion(cwd, current);
writePkgVersion(cwd, current.version);
} catch {
fail("drift repair failed — could not update package.json.", 3);
}
process.stdout.write(JSON.stringify({ repaired: current }) + "\n");
process.stdout.write(JSON.stringify({ repaired: current.version }) + "\n");
}

// Exported for unit tests (pure logic, no I/O).
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.60.1.0",
"version": "1.60.2.0",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
Expand Down
1 change: 1 addition & 0 deletions scripts/resolvers/utility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,7 @@ export function generateChangelogWorkflow(_ctx: TemplateContext): string {
- Refactoring

5. **Write the CHANGELOG entry** covering ALL groups:
- If \`VERSIONED_SHIP=false\`, preserve the repository's existing unversioned CHANGELOG convention and skip the versioned-header rules below. Do not invent a version header; if no CHANGELOG exists, skip this step silently.
- If existing CHANGELOG entries on the branch already cover some commits, replace them with one unified entry for the new version
- Categorize changes into applicable sections:
- \`### Added\` — new features
Expand Down
11 changes: 6 additions & 5 deletions ship/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -1044,6 +1044,7 @@ 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:
- **VERSIONLESS** → preserve the repository's non-versioned convention: set `VERSIONED_SHIP=false`, skip steps 2-5, and continue to Step 13 without creating `VERSION` or changing `package.json.version`.
- **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 @@ -1208,7 +1209,7 @@ user via AskUserQuestion rather than destroying non-WIP commits.
- **Infrastructure:** migrations, config changes, route additions
- **Models & services:** new models, services, concerns (with their tests)
- **Controllers & views:** controllers, views, JS/React components (with their tests)
- **VERSION + CHANGELOG + TODOS.md:** always in the final commit
- **Release metadata (VERSION when present) + CHANGELOG + TODOS.md:** always in the final commit

3. **Rules for splitting:**
- A model and its test file go in the same commit
Expand All @@ -1223,7 +1224,7 @@ user via AskUserQuestion rather than destroying non-WIP commits.
5. Compose each commit message:
- First line: `<type>: <summary>` (type = feat/fix/chore/refactor/docs)
- Body: brief description of what this commit contains
- Only the **final commit** (VERSION + CHANGELOG) gets the version tag and co-author trailer:
- Only the **final commit** (release metadata + CHANGELOG) gets the version tag when `VERSIONED_SHIP` is not `false` and the co-author trailer:

```bash
git commit -m "$(cat <<'EOF'
Expand Down Expand Up @@ -1337,7 +1338,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:** When `VERSIONED_SHIP` is not `false`, 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>`, computed with `gstack-pr-title-rewrite`. When `VERSIONED_SHIP=false`, preserve the repository's established unversioned PR title convention. The full create/update procedure 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 All @@ -1361,7 +1362,7 @@ Substitute from earlier steps:
- **PLAN_TOTAL**: total plan items extracted in Step 8 (0 if no plan file)
- **PLAN_DONE**: count of DONE + CHANGED items from Step 8 (0 if no plan file)
- **VERIFY_RESULT**: "pass", "fail", or "skipped" from Step 8.1
- **VERSION**: from the VERSION file
- **VERSION**: from the VERSION file, or `"none"` when `VERSIONED_SHIP=false`
- **BRANCH**: current branch name

This step is automatic — never skip it, never ask for confirmation.
Expand Down Expand Up @@ -1407,7 +1408,7 @@ through `gstack-version-bump`; never hand-roll the VERSION/package.json write.
- **Never skip the pre-landing review.** If checklist.md is unreadable, stop.
- **Never force push.** Use regular `git push` only.
- **Never ask for trivial confirmations** (e.g., "ready to push?", "create PR?"). DO stop for: version bumps (MINOR/MAJOR), pre-landing review findings (ASK items), and Codex structured review [P1] findings (large diffs only).
- **Always use the 4-digit version format** from the VERSION file.
- **For versioned repositories, always use the 4-digit version format** from the VERSION file.
- **Date format in CHANGELOG:** `YYYY-MM-DD`
- **Split commits for bisectability** — each commit = one logical change.
- **TODOS.md completion detection must be conservative.** Only mark items as completed when the diff clearly shows the work is done.
Expand Down
11 changes: 6 additions & 5 deletions ship/SKILL.md.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ 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:
- **VERSIONLESS** → preserve the repository's non-versioned convention: set `VERSIONED_SHIP=false`, skip steps 2-5, and continue to Step 13 without creating `VERSION` or changing `package.json.version`.
- **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 @@ -329,7 +330,7 @@ user via AskUserQuestion rather than destroying non-WIP commits.
- **Infrastructure:** migrations, config changes, route additions
- **Models & services:** new models, services, concerns (with their tests)
- **Controllers & views:** controllers, views, JS/React components (with their tests)
- **VERSION + CHANGELOG + TODOS.md:** always in the final commit
- **Release metadata (VERSION when present) + CHANGELOG + TODOS.md:** always in the final commit

3. **Rules for splitting:**
- A model and its test file go in the same commit
Expand All @@ -344,7 +345,7 @@ user via AskUserQuestion rather than destroying non-WIP commits.
5. Compose each commit message:
- First line: `<type>: <summary>` (type = feat/fix/chore/refactor/docs)
- Body: brief description of what this commit contains
- Only the **final commit** (VERSION + CHANGELOG) gets the version tag and co-author trailer:
- Only the **final commit** (release metadata + CHANGELOG) gets the version tag when `VERSIONED_SHIP` is not `false` and the co-author trailer:

```bash
git commit -m "$(cat <<'EOF'
Expand Down Expand Up @@ -458,7 +459,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:** When `VERSIONED_SHIP` is not `false`, 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>`, computed with `gstack-pr-title-rewrite`. When `VERSIONED_SHIP=false`, preserve the repository's established unversioned PR title convention. The full create/update procedure is in the section below.

{{SECTION:pr-body}}

Expand All @@ -481,7 +482,7 @@ Substitute from earlier steps:
- **PLAN_TOTAL**: total plan items extracted in Step 8 (0 if no plan file)
- **PLAN_DONE**: count of DONE + CHANGED items from Step 8 (0 if no plan file)
- **VERIFY_RESULT**: "pass", "fail", or "skipped" from Step 8.1
- **VERSION**: from the VERSION file
- **VERSION**: from the VERSION file, or `"none"` when `VERSIONED_SHIP=false`
- **BRANCH**: current branch name

This step is automatic — never skip it, never ask for confirmation.
Expand Down Expand Up @@ -527,7 +528,7 @@ through `gstack-version-bump`; never hand-roll the VERSION/package.json write.
- **Never skip the pre-landing review.** If checklist.md is unreadable, stop.
- **Never force push.** Use regular `git push` only.
- **Never ask for trivial confirmations** (e.g., "ready to push?", "create PR?"). DO stop for: version bumps (MINOR/MAJOR), pre-landing review findings (ASK items), and Codex structured review [P1] findings (large diffs only).
- **Always use the 4-digit version format** from the VERSION file.
- **For versioned repositories, always use the 4-digit version format** from the VERSION file.
- **Date format in CHANGELOG:** `YYYY-MM-DD`
- **Split commits for bisectability** — each commit = one logical change.
- **TODOS.md completion detection must be conservative.** Only mark items as completed when the diff clearly shows the work is done.
Expand Down
1 change: 1 addition & 0 deletions ship/sections/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
- Refactoring

5. **Write the CHANGELOG entry** covering ALL groups:
- If `VERSIONED_SHIP=false`, preserve the repository's existing unversioned CHANGELOG convention and skip the versioned-header rules below. Do not invent a version header; if no CHANGELOG exists, skip this step silently.
- If existing CHANGELOG entries on the branch already cover some commits, replace them with one unified entry for the new version
- Categorize changes into applicable sections:
- `### Added` — new features
Expand Down
Loading
Loading