diff --git a/CHANGELOG.md b/CHANGELOG.md index 351e7cabde..7f3e1b3ed9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.** diff --git a/VERSION b/VERSION index c4190e0048..762f175545 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.60.1.0 +1.60.2.0 diff --git a/bin/gstack-version-bump b/bin/gstack-version-bump index 298fab17d4..95a28b49c4 100755 --- a/bin/gstack-version-bump +++ b/bin/gstack-version-bump @@ -15,8 +15,9 @@ // Subcommands: // 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 +// 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. // @@ -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`); @@ -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 }; } } @@ -101,7 +103,7 @@ 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" }); @@ -109,16 +111,23 @@ function baseVersion(cwd: string, base: string, versionRel: string): string { 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. @@ -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 @@ -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, ); @@ -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). diff --git a/package.json b/package.json index 846438a603..78b98a86f1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/resolvers/utility.ts b/scripts/resolvers/utility.ts index 3d2e368a29..486204c5c1 100644 --- a/scripts/resolvers/utility.ts +++ b/scripts/resolvers/utility.ts @@ -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 diff --git a/ship/SKILL.md b/ship/SKILL.md index eadffaa8f6..d6e6f34418 100644 --- a/ship/SKILL.md +++ b/ship/SKILL.md @@ -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 ``` 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. @@ -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 @@ -1223,7 +1224,7 @@ user via AskUserQuestion rather than destroying non-WIP commits. 5. Compose each commit message: - First line: `:

` (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' @@ -1337,7 +1338,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:** 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 : `, 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. @@ -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. @@ -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. diff --git a/ship/SKILL.md.tmpl b/ship/SKILL.md.tmpl index 068ac4fe54..3bfa151dd9 100644 --- a/ship/SKILL.md.tmpl +++ b/ship/SKILL.md.tmpl @@ -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 ``` 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. @@ -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 @@ -344,7 +345,7 @@ user via AskUserQuestion rather than destroying non-WIP commits. 5. Compose each commit message: - First line: `: ` (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' @@ -458,7 +459,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:** 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 : `, 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}} @@ -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. @@ -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. diff --git a/ship/sections/changelog.md b/ship/sections/changelog.md index 1c0834618c..61b583d4c1 100644 --- a/ship/sections/changelog.md +++ b/ship/sections/changelog.md @@ -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 diff --git a/ship/sections/pr-body.md b/ship/sections/pr-body.md index 90888b7a90..d19fe7adc4 100644 --- a/ship/sections/pr-body.md +++ b/ship/sections/pr-body.md @@ -43,14 +43,16 @@ glab mr view -F json 2>/dev/null | jq -r 'if .state == "opened" then "MR_EXISTS" If an **open** PR/MR already exists: **update** the PR body using `gh pr edit --body-file "$PR_BODY_FILE"` (GitHub) or `glab mr update -d ...` (GitLab). Always regenerate the PR body from scratch using this run's fresh results (test output, coverage audit, review findings, adversarial review, TODOS summary, documentation_section from Step 18). Never reuse stale PR body content from a prior run. **Run the same redaction scan-at-sink (PR body + title) as the create path (Step 19) before editing — scan the temp file, then `gh pr edit --body-file` from it.** -**Always update the PR title to start with `v$NEW_VERSION`.** PR titles use the workspace-aware format `v : ` — version ALWAYS first, no exceptions, no "custom title kept intentionally" escape hatch. The shared helper `bin/gstack-pr-title-rewrite.sh` is the single source of truth for the rule. +**When `VERSIONED_SHIP` is not `false`, update the PR title to start with `v$NEW_VERSION`.** PR titles use the workspace-aware format `v : `. When `VERSIONED_SHIP=false`, do not rewrite the title; keep the repository's established unversioned title convention. + +If `VERSIONED_SHIP=false`, skip steps 1-4 below. Otherwise: 1. Read the current title: `CURRENT=$(gh pr view --json title -q .title)` (or `glab mr view -F json | jq -r .title`). 2. Compute the corrected title: `NEW_TITLE=$(~/.claude/skills/gstack/bin/gstack-pr-title-rewrite.sh "$NEW_VERSION" "$CURRENT")`. The helper handles three cases: title already correct (no-op), title has a different `v` prefix (replace it), or title has no version prefix (prepend one). 3. If `NEW_TITLE` differs from `CURRENT`, run `gh pr edit --title "$NEW_TITLE"` (or `glab mr update -t "$NEW_TITLE"`). 4. **Self-check:** re-fetch the title and assert it starts with `v$NEW_VERSION `. If it does not, retry the edit once. If still wrong, surface the failure to the user. -This keeps the title truthful when Step 12's queue-drift detection rebumps a stale version, and forces the format on PRs that were created without it. +This keeps versioned titles truthful when Step 12's queue-drift detection rebumps a stale version without inventing a release convention for versionless repositories. Print the existing URL and continue to Step 20. @@ -172,8 +174,10 @@ case $? in 3) echo "BLOCKED — credential in PR body. Rotate + redact, do not create the PR."; exit 1 ;; 2) echo "MEDIUM findings — confirm per finding (sterner on public) before proceeding." ;; esac -# Also scan the title (short, single-line): -printf '%s' "v$NEW_VERSION : " | ~/.claude/skills/gstack/bin/gstack-redact --repo-visibility "$REDACT_VIS" --json +# Also scan the title (short, single-line). Prefix it only for versioned ships: +PR_TITLE=": " +[ "$VERSIONED_SHIP" = "false" ] || PR_TITLE="v$NEW_VERSION $PR_TITLE" +printf '%s' "$PR_TITLE" | ~/.claude/skills/gstack/bin/gstack-redact --repo-visibility "$REDACT_VIS" --json ``` HIGH blocks (exit 3, no skip). MEDIUM → AskUserQuestion (PII subset offers @@ -182,18 +186,14 @@ HIGH blocks (exit 3, no skip). MEDIUM → AskUserQuestion (PII subset offers **If GitHub:** create from the SCANNED file (exact bytes scanned = bytes sent): ```bash -# PR title MUST start with v$NEW_VERSION — enforced on every run, no exceptions. -# (See Step 19 idempotency block + bin/gstack-pr-title-rewrite.sh for the rule.) -gh pr create --base --title "v$NEW_VERSION : " --body-file "$PR_BODY_FILE" +gh pr create --base --title "$PR_TITLE" --body-file "$PR_BODY_FILE" rm -f "$PR_BODY_FILE" ``` **If GitLab:** ```bash -# MR title MUST start with v$NEW_VERSION — enforced on every run, no exceptions. -# (See Step 19 idempotency block + bin/gstack-pr-title-rewrite.sh for the rule.) -glab mr create -b -t "v$NEW_VERSION : " -d "$(cat <<'EOF' +glab mr create -b -t "$PR_TITLE" -d "$(cat <<'EOF' EOF )" diff --git a/ship/sections/pr-body.md.tmpl b/ship/sections/pr-body.md.tmpl index 22ac0d668b..b1bbf62939 100644 --- a/ship/sections/pr-body.md.tmpl +++ b/ship/sections/pr-body.md.tmpl @@ -41,14 +41,16 @@ glab mr view -F json 2>/dev/null | jq -r 'if .state == "opened" then "MR_EXISTS" If an **open** PR/MR already exists: **update** the PR body using `gh pr edit --body-file "$PR_BODY_FILE"` (GitHub) or `glab mr update -d ...` (GitLab). Always regenerate the PR body from scratch using this run's fresh results (test output, coverage audit, review findings, adversarial review, TODOS summary, documentation_section from Step 18). Never reuse stale PR body content from a prior run. **Run the same redaction scan-at-sink (PR body + title) as the create path (Step 19) before editing — scan the temp file, then `gh pr edit --body-file` from it.** -**Always update the PR title to start with `v$NEW_VERSION`.** PR titles use the workspace-aware format `v : ` — version ALWAYS first, no exceptions, no "custom title kept intentionally" escape hatch. The shared helper `bin/gstack-pr-title-rewrite.sh` is the single source of truth for the rule. +**When `VERSIONED_SHIP` is not `false`, update the PR title to start with `v$NEW_VERSION`.** PR titles use the workspace-aware format `v : `. When `VERSIONED_SHIP=false`, do not rewrite the title; keep the repository's established unversioned title convention. + +If `VERSIONED_SHIP=false`, skip steps 1-4 below. Otherwise: 1. Read the current title: `CURRENT=$(gh pr view --json title -q .title)` (or `glab mr view -F json | jq -r .title`). 2. Compute the corrected title: `NEW_TITLE=$(~/.claude/skills/gstack/bin/gstack-pr-title-rewrite.sh "$NEW_VERSION" "$CURRENT")`. The helper handles three cases: title already correct (no-op), title has a different `v` prefix (replace it), or title has no version prefix (prepend one). 3. If `NEW_TITLE` differs from `CURRENT`, run `gh pr edit --title "$NEW_TITLE"` (or `glab mr update -t "$NEW_TITLE"`). 4. **Self-check:** re-fetch the title and assert it starts with `v$NEW_VERSION `. If it does not, retry the edit once. If still wrong, surface the failure to the user. -This keeps the title truthful when Step 12's queue-drift detection rebumps a stale version, and forces the format on PRs that were created without it. +This keeps versioned titles truthful when Step 12's queue-drift detection rebumps a stale version without inventing a release convention for versionless repositories. Print the existing URL and continue to Step 20. @@ -170,8 +172,10 @@ case $? in 3) echo "BLOCKED — credential in PR body. Rotate + redact, do not create the PR."; exit 1 ;; 2) echo "MEDIUM findings — confirm per finding (sterner on public) before proceeding." ;; esac -# Also scan the title (short, single-line): -printf '%s' "v$NEW_VERSION : " | ~/.claude/skills/gstack/bin/gstack-redact --repo-visibility "$REDACT_VIS" --json +# Also scan the title (short, single-line). Prefix it only for versioned ships: +PR_TITLE=": " +[ "$VERSIONED_SHIP" = "false" ] || PR_TITLE="v$NEW_VERSION $PR_TITLE" +printf '%s' "$PR_TITLE" | ~/.claude/skills/gstack/bin/gstack-redact --repo-visibility "$REDACT_VIS" --json ``` HIGH blocks (exit 3, no skip). MEDIUM → AskUserQuestion (PII subset offers @@ -180,18 +184,14 @@ HIGH blocks (exit 3, no skip). MEDIUM → AskUserQuestion (PII subset offers **If GitHub:** create from the SCANNED file (exact bytes scanned = bytes sent): ```bash -# PR title MUST start with v$NEW_VERSION — enforced on every run, no exceptions. -# (See Step 19 idempotency block + bin/gstack-pr-title-rewrite.sh for the rule.) -gh pr create --base --title "v$NEW_VERSION : " --body-file "$PR_BODY_FILE" +gh pr create --base --title "$PR_TITLE" --body-file "$PR_BODY_FILE" rm -f "$PR_BODY_FILE" ``` **If GitLab:** ```bash -# MR title MUST start with v$NEW_VERSION — enforced on every run, no exceptions. -# (See Step 19 idempotency block + bin/gstack-pr-title-rewrite.sh for the rule.) -glab mr create -b -t "v$NEW_VERSION : " -d "$(cat <<'EOF' +glab mr create -b -t "$PR_TITLE" -d "$(cat <<'EOF' EOF )" diff --git a/test/fixtures/golden/claude-ship-SKILL.md b/test/fixtures/golden/claude-ship-SKILL.md index eadffaa8f6..d6e6f34418 100644 --- a/test/fixtures/golden/claude-ship-SKILL.md +++ b/test/fixtures/golden/claude-ship-SKILL.md @@ -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 ``` 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. @@ -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 @@ -1223,7 +1224,7 @@ user via AskUserQuestion rather than destroying non-WIP commits. 5. Compose each commit message: - First line: `: ` (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' @@ -1337,7 +1338,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:** 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 : `, 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. @@ -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. @@ -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. diff --git a/test/fixtures/golden/codex-ship-SKILL.md b/test/fixtures/golden/codex-ship-SKILL.md index d99630c4b3..6a8e109285 100644 --- a/test/fixtures/golden/codex-ship-SKILL.md +++ b/test/fixtures/golden/codex-ship-SKILL.md @@ -2186,6 +2186,7 @@ stay agent judgment; the slot pick stays `gstack-next-version`. bun run $GSTACK_ROOT/bin/gstack-version-bump classify --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. @@ -2239,6 +2240,7 @@ stay agent judgment; the slot pick stays `gstack-next-version`. - 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 @@ -2391,7 +2393,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 @@ -2406,7 +2408,7 @@ user via AskUserQuestion rather than destroying non-WIP commits. 5. Compose each commit message: - First line: `: ` (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' @@ -2520,7 +2522,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: `$GSTACK_ROOT/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:** 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 : `, 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. ## Step 18: Documentation sync (via subagent, before PR creation) @@ -2565,14 +2567,16 @@ glab mr view -F json 2>/dev/null | jq -r 'if .state == "opened" then "MR_EXISTS" If an **open** PR/MR already exists: **update** the PR body using `gh pr edit --body-file "$PR_BODY_FILE"` (GitHub) or `glab mr update -d ...` (GitLab). Always regenerate the PR body from scratch using this run's fresh results (test output, coverage audit, review findings, adversarial review, TODOS summary, documentation_section from Step 18). Never reuse stale PR body content from a prior run. **Run the same redaction scan-at-sink (PR body + title) as the create path (Step 19) before editing — scan the temp file, then `gh pr edit --body-file` from it.** -**Always update the PR title to start with `v$NEW_VERSION`.** PR titles use the workspace-aware format `v : ` — version ALWAYS first, no exceptions, no "custom title kept intentionally" escape hatch. The shared helper `bin/gstack-pr-title-rewrite.sh` is the single source of truth for the rule. +**When `VERSIONED_SHIP` is not `false`, update the PR title to start with `v$NEW_VERSION`.** PR titles use the workspace-aware format `v : `. When `VERSIONED_SHIP=false`, do not rewrite the title; keep the repository's established unversioned title convention. + +If `VERSIONED_SHIP=false`, skip steps 1-4 below. Otherwise: 1. Read the current title: `CURRENT=$(gh pr view --json title -q .title)` (or `glab mr view -F json | jq -r .title`). 2. Compute the corrected title: `NEW_TITLE=$($GSTACK_ROOT/bin/gstack-pr-title-rewrite.sh "$NEW_VERSION" "$CURRENT")`. The helper handles three cases: title already correct (no-op), title has a different `v` prefix (replace it), or title has no version prefix (prepend one). 3. If `NEW_TITLE` differs from `CURRENT`, run `gh pr edit --title "$NEW_TITLE"` (or `glab mr update -t "$NEW_TITLE"`). 4. **Self-check:** re-fetch the title and assert it starts with `v$NEW_VERSION `. If it does not, retry the edit once. If still wrong, surface the failure to the user. -This keeps the title truthful when Step 12's queue-drift detection rebumps a stale version, and forces the format on PRs that were created without it. +This keeps versioned titles truthful when Step 12's queue-drift detection rebumps a stale version without inventing a release convention for versionless repositories. Print the existing URL and continue to Step 20. @@ -2694,8 +2698,10 @@ case $? in 3) echo "BLOCKED — credential in PR body. Rotate + redact, do not create the PR."; exit 1 ;; 2) echo "MEDIUM findings — confirm per finding (sterner on public) before proceeding." ;; esac -# Also scan the title (short, single-line): -printf '%s' "v$NEW_VERSION : " | $GSTACK_ROOT/bin/gstack-redact --repo-visibility "$REDACT_VIS" --json +# Also scan the title (short, single-line). Prefix it only for versioned ships: +PR_TITLE=": " +[ "$VERSIONED_SHIP" = "false" ] || PR_TITLE="v$NEW_VERSION $PR_TITLE" +printf '%s' "$PR_TITLE" | $GSTACK_ROOT/bin/gstack-redact --repo-visibility "$REDACT_VIS" --json ``` HIGH blocks (exit 3, no skip). MEDIUM → AskUserQuestion (PII subset offers @@ -2704,18 +2710,14 @@ HIGH blocks (exit 3, no skip). MEDIUM → AskUserQuestion (PII subset offers **If GitHub:** create from the SCANNED file (exact bytes scanned = bytes sent): ```bash -# PR title MUST start with v$NEW_VERSION — enforced on every run, no exceptions. -# (See Step 19 idempotency block + bin/gstack-pr-title-rewrite.sh for the rule.) -gh pr create --base --title "v$NEW_VERSION : " --body-file "$PR_BODY_FILE" +gh pr create --base --title "$PR_TITLE" --body-file "$PR_BODY_FILE" rm -f "$PR_BODY_FILE" ``` **If GitLab:** ```bash -# MR title MUST start with v$NEW_VERSION — enforced on every run, no exceptions. -# (See Step 19 idempotency block + bin/gstack-pr-title-rewrite.sh for the rule.) -glab mr create -b -t "v$NEW_VERSION : " -d "$(cat <<'EOF' +glab mr create -b -t "$PR_TITLE" -d "$(cat <<'EOF' EOF )" @@ -2747,7 +2749,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. @@ -2793,7 +2795,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. diff --git a/test/fixtures/golden/factory-ship-SKILL.md b/test/fixtures/golden/factory-ship-SKILL.md index a2acad24f6..c0c84f1003 100644 --- a/test/fixtures/golden/factory-ship-SKILL.md +++ b/test/fixtures/golden/factory-ship-SKILL.md @@ -2592,6 +2592,7 @@ stay agent judgment; the slot pick stays `gstack-next-version`. bun run $GSTACK_ROOT/bin/gstack-version-bump classify --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. @@ -2645,6 +2646,7 @@ stay agent judgment; the slot pick stays `gstack-next-version`. - 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 @@ -2797,7 +2799,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 @@ -2812,7 +2814,7 @@ user via AskUserQuestion rather than destroying non-WIP commits. 5. Compose each commit message: - First line: `: ` (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' @@ -2926,7 +2928,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: `$GSTACK_ROOT/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:** 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 : `, 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. ## Step 18: Documentation sync (via subagent, before PR creation) @@ -2971,14 +2973,16 @@ glab mr view -F json 2>/dev/null | jq -r 'if .state == "opened" then "MR_EXISTS" If an **open** PR/MR already exists: **update** the PR body using `gh pr edit --body-file "$PR_BODY_FILE"` (GitHub) or `glab mr update -d ...` (GitLab). Always regenerate the PR body from scratch using this run's fresh results (test output, coverage audit, review findings, adversarial review, TODOS summary, documentation_section from Step 18). Never reuse stale PR body content from a prior run. **Run the same redaction scan-at-sink (PR body + title) as the create path (Step 19) before editing — scan the temp file, then `gh pr edit --body-file` from it.** -**Always update the PR title to start with `v$NEW_VERSION`.** PR titles use the workspace-aware format `v : ` — version ALWAYS first, no exceptions, no "custom title kept intentionally" escape hatch. The shared helper `bin/gstack-pr-title-rewrite.sh` is the single source of truth for the rule. +**When `VERSIONED_SHIP` is not `false`, update the PR title to start with `v$NEW_VERSION`.** PR titles use the workspace-aware format `v : `. When `VERSIONED_SHIP=false`, do not rewrite the title; keep the repository's established unversioned title convention. + +If `VERSIONED_SHIP=false`, skip steps 1-4 below. Otherwise: 1. Read the current title: `CURRENT=$(gh pr view --json title -q .title)` (or `glab mr view -F json | jq -r .title`). 2. Compute the corrected title: `NEW_TITLE=$($GSTACK_ROOT/bin/gstack-pr-title-rewrite.sh "$NEW_VERSION" "$CURRENT")`. The helper handles three cases: title already correct (no-op), title has a different `v` prefix (replace it), or title has no version prefix (prepend one). 3. If `NEW_TITLE` differs from `CURRENT`, run `gh pr edit --title "$NEW_TITLE"` (or `glab mr update -t "$NEW_TITLE"`). 4. **Self-check:** re-fetch the title and assert it starts with `v$NEW_VERSION `. If it does not, retry the edit once. If still wrong, surface the failure to the user. -This keeps the title truthful when Step 12's queue-drift detection rebumps a stale version, and forces the format on PRs that were created without it. +This keeps versioned titles truthful when Step 12's queue-drift detection rebumps a stale version without inventing a release convention for versionless repositories. Print the existing URL and continue to Step 20. @@ -3100,8 +3104,10 @@ case $? in 3) echo "BLOCKED — credential in PR body. Rotate + redact, do not create the PR."; exit 1 ;; 2) echo "MEDIUM findings — confirm per finding (sterner on public) before proceeding." ;; esac -# Also scan the title (short, single-line): -printf '%s' "v$NEW_VERSION : " | $GSTACK_ROOT/bin/gstack-redact --repo-visibility "$REDACT_VIS" --json +# Also scan the title (short, single-line). Prefix it only for versioned ships: +PR_TITLE=": " +[ "$VERSIONED_SHIP" = "false" ] || PR_TITLE="v$NEW_VERSION $PR_TITLE" +printf '%s' "$PR_TITLE" | $GSTACK_ROOT/bin/gstack-redact --repo-visibility "$REDACT_VIS" --json ``` HIGH blocks (exit 3, no skip). MEDIUM → AskUserQuestion (PII subset offers @@ -3110,18 +3116,14 @@ HIGH blocks (exit 3, no skip). MEDIUM → AskUserQuestion (PII subset offers **If GitHub:** create from the SCANNED file (exact bytes scanned = bytes sent): ```bash -# PR title MUST start with v$NEW_VERSION — enforced on every run, no exceptions. -# (See Step 19 idempotency block + bin/gstack-pr-title-rewrite.sh for the rule.) -gh pr create --base --title "v$NEW_VERSION : " --body-file "$PR_BODY_FILE" +gh pr create --base --title "$PR_TITLE" --body-file "$PR_BODY_FILE" rm -f "$PR_BODY_FILE" ``` **If GitLab:** ```bash -# MR title MUST start with v$NEW_VERSION — enforced on every run, no exceptions. -# (See Step 19 idempotency block + bin/gstack-pr-title-rewrite.sh for the rule.) -glab mr create -b -t "v$NEW_VERSION : " -d "$(cat <<'EOF' +glab mr create -b -t "$PR_TITLE" -d "$(cat <<'EOF' EOF )" @@ -3153,7 +3155,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. @@ -3199,7 +3201,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. diff --git a/test/gstack-version-bump.test.ts b/test/gstack-version-bump.test.ts index ffcecd1a7f..12c474f0f1 100644 --- a/test/gstack-version-bump.test.ts +++ b/test/gstack-version-bump.test.ts @@ -15,23 +15,26 @@ import { classifyState, VERSION_RE } from '../bin/gstack-version-bump'; const BIN = path.join(import.meta.dir, '..', 'bin', 'gstack-version-bump'); describe('classifyState (idempotency)', () => { + test('VERSIONLESS when VERSION is absent on base and branch', () => { + expect(classifyState('0.0.0.0', '0.0.0.0', true, '1.0.0', false, false)).toBe('VERSIONLESS'); + }); test('FRESH when VERSION matches base and pkg agrees', () => { - expect(classifyState('1.1.0.0', '1.1.0.0', true, '1.1.0.0')).toBe('FRESH'); + expect(classifyState('1.1.0.0', '1.1.0.0', true, '1.1.0.0', true, true)).toBe('FRESH'); }); test('FRESH when VERSION matches base and no package.json', () => { - expect(classifyState('1.1.0.0', '1.1.0.0', false, '')).toBe('FRESH'); + expect(classifyState('1.1.0.0', '1.1.0.0', false, '', true, true)).toBe('FRESH'); }); test('ALREADY_BUMPED when VERSION moved past base and pkg agrees (re-run)', () => { - expect(classifyState('1.2.0.0', '1.1.0.0', true, '1.2.0.0')).toBe('ALREADY_BUMPED'); + expect(classifyState('1.2.0.0', '1.1.0.0', true, '1.2.0.0', true, true)).toBe('ALREADY_BUMPED'); }); test('ALREADY_BUMPED when VERSION moved past base, no package.json', () => { - expect(classifyState('1.2.0.0', '1.1.0.0', false, '')).toBe('ALREADY_BUMPED'); + expect(classifyState('1.2.0.0', '1.1.0.0', false, '', true, true)).toBe('ALREADY_BUMPED'); }); test('DRIFT_STALE_PKG when VERSION bumped but pkg lagging', () => { - expect(classifyState('1.2.0.0', '1.1.0.0', true, '1.1.0.0')).toBe('DRIFT_STALE_PKG'); + expect(classifyState('1.2.0.0', '1.1.0.0', true, '1.1.0.0', true, true)).toBe('DRIFT_STALE_PKG'); }); test('DRIFT_UNEXPECTED when VERSION matches base but pkg diverges (manual edit)', () => { - expect(classifyState('1.1.0.0', '1.1.0.0', true, '1.2.0.0')).toBe('DRIFT_UNEXPECTED'); + expect(classifyState('1.1.0.0', '1.1.0.0', true, '1.2.0.0', true, true)).toBe('DRIFT_UNEXPECTED'); }); }); @@ -131,3 +134,31 @@ describe('classify (idempotency over a real git base)', () => { expect(parsed.currentVersion).toBe('1.1.0.0'); }); }); + +describe('classify (repository without VERSION)', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-versionless-')); + afterAll(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* noop */ } }); + + const git = (...a: string[]) => execFileSync('git', a, { cwd: dir, stdio: 'pipe' }); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '1.0.0' }, null, 2) + '\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 = git('rev-parse', 'HEAD').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 VERSIONLESS and preserves the established package version', () => { + const out = execFileSync('bun', [BIN, 'classify', '--base', 'main'], { cwd: dir }).toString(); + expect(JSON.parse(out)).toEqual({ + state: 'VERSIONLESS', + baseVersion: null, + currentVersion: null, + pkgVersion: '1.0.0', + pkgExists: true, + baseVersionExists: false, + versionExists: false, + }); + expect(fs.existsSync(path.join(dir, 'VERSION'))).toBe(false); + }); +}); diff --git a/test/ship-version-sync.test.ts b/test/ship-version-sync.test.ts index c657795c5f..76bf46073e 100644 --- a/test/ship-version-sync.test.ts +++ b/test/ship-version-sync.test.ts @@ -12,6 +12,8 @@ import { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync } from "no import { tmpdir } from "node:os"; import { join } from "node:path"; +const SHIP_TEMPLATE = readFileSync(join(import.meta.dir, "..", "ship", "SKILL.md.tmpl"), "utf8"); + let dir: string; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "ship-drift-")); @@ -148,6 +150,14 @@ test("DRIFT_UNEXPECTED: VERSION == base, pkg edited (exits non-zero)", () => { expect(r.code).toBe(1); }); +test("ship dispatch preserves the non-versioned path without weakening real drift stops", () => { + expect(SHIP_TEMPLATE).toContain("**VERSIONLESS** → preserve the repository's non-versioned convention"); + expect(SHIP_TEMPLATE).toContain('**DRIFT_UNEXPECTED** → **STOP**'); + expect(SHIP_TEMPLATE).toContain('VERSIONED_SHIP=false'); + expect(readFileSync(join(import.meta.dir, "..", "ship", "sections", "pr-body.md.tmpl"), "utf8")) + .toContain("If `VERSIONED_SHIP=false`, skip steps 1-4 below."); +}); + // --- Parse failures: 2 cases --- test("idempotency: invalid JSON exits non-zero with clear error", () => {