From e3c961d00f24334066b4caeb57634c012a346c00 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sat, 18 Apr 2026 23:58:59 +0800 Subject: [PATCH] fix(ship): detect + repair VERSION/package.json drift in Step 12 (v1.1.1.0) (#1063) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ship): detect + repair VERSION/package.json drift in Step 12 /ship Step 12's idempotency check read only VERSION and its bump action wrote only VERSION. package.json's version field was never updated, so the first bump silently drifted and re-runs couldn't see it (they keyed on VERSION alone). Any consumer reading package.json (bun pm, npm publish, registry UIs) saw a stale semver. Rewrites Step 12 as a four-state dispatch: FRESH → normal bump, writes VERSION + package.json in sync ALREADY_BUMPED → skip, reuse current VERSION DRIFT_STALE_PKG → sync-only repair path, no re-bump (prevents double-bump on re-run) DRIFT_UNEXPECTED → halt and ask user (pkg edited manually, ambiguous which value is authoritative) Hardening: NEW_VERSION validated against MAJOR.MINOR.PATCH.MICRO pattern before any write; node-or-bun required for JSON parsing (no sed fallback — unsafe on nested "version" fields); invalid JSON fails hard instead of silently corrupting. Adds test/ship-version-sync.test.ts with 12 cases covering every state transition, including the critical drift-repair regression that verifies sync does not double-bump (the bug Codex caught in the plan review of my own original fix). Co-Authored-By: Claude Opus 4.7 (1M context) * chore(ship): regenerate SKILL.md + refresh golden fixtures Mechanical follow-on from the Step 12 template edit. `bun run gen:skill-docs --host all` regenerates ship/SKILL.md; host-config golden-file regression tests then need fresh baselines copied from the regenerated claude/codex/factory host variants. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(ship): harden Step 12 against whitespace + invalid REPAIR_VERSION Claude adversarial subagent surfaced three correctness risks in the Step 12 state machine: - CURRENT_VERSION and BASE_VERSION were not stripped of CR/whitespace on read. A CRLF VERSION file would mismatch the clean package.json version, falsely classify as DRIFT_STALE_PKG, then propagate the carriage return into package.json via the repair path. - REPAIR_VERSION was unvalidated. The bump path validates NEW_VERSION against the 4-digit semver pattern, but the drift-repair path wrote whatever cat VERSION returned directly into package.json. A manually-corrupted VERSION file would silently poison the repair. - Empty-string CURRENT_VERSION (0-byte VERSION, directory-at-VERSION) fell through to "not equal to base" and misclassified as ALREADY_BUMPED. Template fix strips \r/newlines/whitespace on every VERSION read, guards against empty-string results, and applies the same semver regex gate in the repair path that already protects the bump path. Adds two regression tests (trailing-CR idempotency + invalid-semver repair rejection). Total Step 12 coverage: 14 tests, 14/14 pass. Opens two follow-up TODOs flagged but not fixed in this branch: test/template drift risk (the tests still reimplement template bash) and BASE_VERSION silent fallback on git-show failure. Co-Authored-By: Claude Opus 4.7 (1M context) * chore(ship): regenerate SKILL.md + refresh goldens after hardening Mechanical follow-on from the whitespace + REPAIR_VERSION validation edits to ship/SKILL.md.tmpl. bun run gen:skill-docs --host all regenerates ship/SKILL.md; host-config golden-file regression tests need fresh baselines copied from the regenerated claude/codex/factory host variants. Co-Authored-By: Claude Opus 4.7 (1M context) * chore: bump version and changelog (v1.0.1.0) Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 10 + TODOS.md | 24 +++ VERSION | 2 +- package.json | 2 +- ship/SKILL.md | 101 +++++++++- ship/SKILL.md.tmpl | 101 +++++++++- test/fixtures/golden/claude-ship-SKILL.md | 101 +++++++++- test/fixtures/golden/codex-ship-SKILL.md | 101 +++++++++- test/fixtures/golden/factory-ship-SKILL.md | 101 +++++++++- test/ship-version-sync.test.ts | 224 +++++++++++++++++++++ 10 files changed, 730 insertions(+), 37 deletions(-) create mode 100644 test/ship-version-sync.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b31735b8..5e05187a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [1.1.1.0] - 2026-04-18 + +### Fixed +- **`/ship` no longer silently lets `VERSION` and `package.json` drift.** Before this fix, `/ship`'s Step 12 read and bumped only the `VERSION` file. Any downstream consumer that reads `package.json` (registry UIs, `bun pm view`, `npm publish`, future helpers) would see a stale semver, and because the idempotency check keyed on `VERSION` alone, the next `/ship` run couldn't detect it had drifted. Now Step 12 classifies into four states — FRESH, ALREADY_BUMPED, DRIFT_STALE_PKG, DRIFT_UNEXPECTED — detects drift in every direction, repairs it via a sync-only path that can't double-bump, and halts loudly when `VERSION` and `package.json` disagree in an ambiguous way. +- **Hardened against malformed version strings.** `NEW_VERSION` is validated against the 4-digit semver pattern before any write, and the drift-repair path applies the same check to `VERSION` contents before propagating them into `package.json`. Trailing carriage returns and whitespace are stripped from both file reads. If `package.json` is invalid JSON, `/ship` stops loudly instead of silently rewriting a corrupted file. + +### For contributors +- New test file at `test/ship-version-sync.test.ts` — 14 cases covering every branch of the new Step 12 logic, including the critical no-double-bump path (drift-repair must never call the normal bump action), trailing-CR regression, and invalid-semver repair rejection. +- Review history on this fix: one round of `/plan-eng-review`, one round of `/codex` plan review (found a double-bump bug in the original design), one round of Claude adversarial subagent (found CRLF handling gap and unvalidated `REPAIR_VERSION`). All surfaced issues applied in-branch. + ## [1.1.0.0] - 2026-04-18 ### Added diff --git a/TODOS.md b/TODOS.md index 3b28fc2e..d3354110 100644 --- a/TODOS.md +++ b/TODOS.md @@ -437,6 +437,30 @@ Linux cookie import shipped in v0.11.11.0 (Wave 3). Supports Chrome, Chromium, B ## Ship +### /ship Step 12 test harness should exec the actual template bash, not a reimplementation + +**What:** `test/ship-version-sync.test.ts` currently reimplements the bash from `ship/SKILL.md.tmpl` Step 12 inside template literals. When the template changes, both sides must be updated — exactly the drift-risk pattern the Step 12 fix is meant to prevent, applied to our own testing strategy. Replace with a helper that extracts the fenced bash blocks from the template at test time and runs them verbatim (similar to the `skill-parser.ts` pattern). + +**Why:** Surfaced by the Claude adversarial subagent during the v1.0.1.0 ship. Today the tests would stay green while the template regresses, because the error-message strings already differ between test and template. It's a silent-drift bug waiting to happen. + +**Context:** The fixed test file is at `test/ship-version-sync.test.ts` (branched off garrytan/ship-version-sync). Existing precedent for extracting-from-skill-md is at `test/helpers/skill-parser.ts`. Pattern: read the template, slice from `## Step 12` to the next `---`, grep fenced bash, feed to `/bin/bash` with substituted fixtures. + +**Effort:** S (human: ~2h / CC: ~30min) +**Priority:** P2 +**Depends on:** None. + +### /ship Step 12 BASE_VERSION silent fallback to 0.0.0.0 when git show fails + +**What:** `BASE_VERSION=$(git show origin/:VERSION 2>/dev/null || echo "0.0.0.0")` silently defaults to `0.0.0.0` in any failure mode — detached HEAD, no origin, offline, base branch renamed. In such states, a real drift could be misclassified or silently repaired with the wrong value. Distinguish "origin/ unreachable" from "origin/:VERSION absent" and fail loudly on the former. + +**Why:** Flagged as CRITICAL (confidence 8/10) by the Claude adversarial subagent during the v1.0.1.0 ship. Low practical risk because `/ship` Step 3 already fetches origin before Step 12 runs — any reachability failure would abort Step 3 long before this code runs. Still, defense in depth: if someone invokes Step 12 bash outside the full /ship pipeline (e.g., via a standalone helper), the fallback masks a real problem. + +**Context:** Fix: wrap with `git rev-parse --verify origin/` probe; if that fails, error out rather than defaulting. Touches `ship/SKILL.md.tmpl` Step 12 idempotency block (around line 409). Tests need a case where `git show` fails. + +**Effort:** S (human: ~1h / CC: ~15min) +**Priority:** P3 +**Depends on:** None. + ### GitLab support for /land-and-deploy **What:** Add GitLab MR merge + CI polling support to `/land-and-deploy` skill. Currently uses `gh pr view`, `gh pr checks`, `gh pr merge`, and `gh run list/view` in 15+ places — each needs a GitLab conditional path using `glab ci status`, `glab mr merge`, etc. diff --git a/VERSION b/VERSION index a6bbdb5f..410f6a9e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.0.0 +1.1.1.0 diff --git a/package.json b/package.json index 732fcde1..aaffac7c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.1.0.0", + "version": "1.1.1.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/ship/SKILL.md b/ship/SKILL.md index 5ae15c37..3c7cb7d2 100644 --- a/ship/SKILL.md +++ b/ship/SKILL.md @@ -2404,16 +2404,57 @@ already knows. A good test: would this insight save time in a future session? If ## Step 12: Version bump (auto-decide) -**Idempotency check:** Before bumping, compare VERSION against the base branch. +**Idempotency check:** Before bumping, classify the state by comparing `VERSION` against the base branch AND against `package.json`'s `version` field. Four states: FRESH (do bump), ALREADY_BUMPED (skip bump), DRIFT_STALE_PKG (sync pkg only, no re-bump), DRIFT_UNEXPECTED (stop and ask). ```bash -BASE_VERSION=$(git show origin/:VERSION 2>/dev/null || echo "0.0.0.0") -CURRENT_VERSION=$(cat VERSION 2>/dev/null || echo "0.0.0.0") -echo "BASE: $BASE_VERSION HEAD: $CURRENT_VERSION" -if [ "$CURRENT_VERSION" != "$BASE_VERSION" ]; then echo "ALREADY_BUMPED"; fi +BASE_VERSION=$(git show origin/:VERSION 2>/dev/null | tr -d '\r\n[:space:]' || echo "0.0.0.0") +CURRENT_VERSION=$(cat VERSION 2>/dev/null | tr -d '\r\n[:space:]' || echo "0.0.0.0") +[ -z "$BASE_VERSION" ] && BASE_VERSION="0.0.0.0" +[ -z "$CURRENT_VERSION" ] && CURRENT_VERSION="0.0.0.0" +PKG_VERSION="" +PKG_EXISTS=0 +if [ -f package.json ]; then + PKG_EXISTS=1 + if command -v node >/dev/null 2>&1; then + PKG_VERSION=$(node -e 'const p=require("./package.json");process.stdout.write(p.version||"")' 2>/dev/null) + PARSE_EXIT=$? + elif command -v bun >/dev/null 2>&1; then + PKG_VERSION=$(bun -e 'const p=require("./package.json");process.stdout.write(p.version||"")' 2>/dev/null) + PARSE_EXIT=$? + else + echo "ERROR: package.json exists but neither node nor bun is available. Install one and re-run." + exit 1 + fi + if [ "$PARSE_EXIT" != "0" ]; then + echo "ERROR: package.json is not valid JSON. Fix the file before re-running /ship." + exit 1 + fi +fi +echo "BASE: $BASE_VERSION VERSION: $CURRENT_VERSION package.json: ${PKG_VERSION:-}" + +if [ "$CURRENT_VERSION" = "$BASE_VERSION" ]; then + if [ "$PKG_EXISTS" = "1" ] && [ -n "$PKG_VERSION" ] && [ "$PKG_VERSION" != "$CURRENT_VERSION" ]; then + echo "STATE: DRIFT_UNEXPECTED" + echo "package.json version ($PKG_VERSION) disagrees with VERSION ($CURRENT_VERSION) while VERSION matches base." + echo "This looks like a manual edit to package.json bypassing /ship. Reconcile manually, then re-run." + exit 1 + fi + echo "STATE: FRESH" +else + if [ "$PKG_EXISTS" = "1" ] && [ -n "$PKG_VERSION" ] && [ "$PKG_VERSION" != "$CURRENT_VERSION" ]; then + echo "STATE: DRIFT_STALE_PKG" + else + echo "STATE: ALREADY_BUMPED" + fi +fi ``` -If output shows `ALREADY_BUMPED`, VERSION was already bumped on this branch (prior `/ship` run). Skip the bump action (do not modify VERSION), but read the current VERSION value — it is needed for CHANGELOG and PR body. Continue to the next step. Otherwise proceed with the bump. +Read the `STATE:` line and dispatch: + +- **FRESH** → proceed with the bump action below (steps 1–4). +- **ALREADY_BUMPED** → skip the bump. Reuse `CURRENT_VERSION` for CHANGELOG and PR body. Continue to the next step. +- **DRIFT_STALE_PKG** → a prior `/ship` bumped `VERSION` but failed to update `package.json`. Run the sync-only repair block below (after step 4). Do NOT re-bump. Reuse `CURRENT_VERSION` for CHANGELOG and PR body. +- **DRIFT_UNEXPECTED** → `/ship` has halted (exit 1). Resolve manually; /ship cannot tell which file is authoritative. 1. Read the current `VERSION` file (4-digit format: `MAJOR.MINOR.PATCH.MICRO`) @@ -2429,7 +2470,53 @@ If output shows `ALREADY_BUMPED`, VERSION was already bumped on this branch (pri - Bumping a digit resets all digits to its right to 0 - Example: `0.19.1.0` + PATCH → `0.19.2.0` -4. Write the new version to the `VERSION` file. +4. **Validate** `NEW_VERSION` and write it to **both** `VERSION` and `package.json`. This block runs only when `STATE: FRESH`. + +```bash +if ! printf '%s' "$NEW_VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "ERROR: NEW_VERSION ($NEW_VERSION) does not match MAJOR.MINOR.PATCH.MICRO pattern. Aborting." + exit 1 +fi +echo "$NEW_VERSION" > VERSION +if [ -f package.json ]; then + if command -v node >/dev/null 2>&1; then + node -e 'const fs=require("fs"),p=require("./package.json");p.version=process.argv[1];fs.writeFileSync("package.json",JSON.stringify(p,null,2)+"\n")' "$NEW_VERSION" || { + echo "ERROR: failed to update package.json. VERSION was written but package.json is now stale. Fix and re-run — the new idempotency check will detect the drift." + exit 1 + } + elif command -v bun >/dev/null 2>&1; then + bun -e 'const fs=require("fs"),p=require("./package.json");p.version=process.argv[1];fs.writeFileSync("package.json",JSON.stringify(p,null,2)+"\n")' "$NEW_VERSION" || { + echo "ERROR: failed to update package.json. VERSION was written but package.json is now stale." + exit 1 + } + else + echo "ERROR: package.json exists but neither node nor bun is available." + exit 1 + fi +fi +``` + +**DRIFT_STALE_PKG repair path** — runs when idempotency reports `STATE: DRIFT_STALE_PKG`. No re-bump; sync `package.json.version` to the current `VERSION` and continue. Reuse `CURRENT_VERSION` for CHANGELOG and PR body. + +```bash +REPAIR_VERSION=$(cat VERSION | tr -d '\r\n[:space:]') +if ! printf '%s' "$REPAIR_VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "ERROR: VERSION file contents ($REPAIR_VERSION) do not match MAJOR.MINOR.PATCH.MICRO pattern. Refusing to propagate invalid semver into package.json. Fix VERSION manually, then re-run /ship." + exit 1 +fi +if command -v node >/dev/null 2>&1; then + node -e 'const fs=require("fs"),p=require("./package.json");p.version=process.argv[1];fs.writeFileSync("package.json",JSON.stringify(p,null,2)+"\n")' "$REPAIR_VERSION" || { + echo "ERROR: drift repair failed — could not update package.json." + exit 1 + } +else + bun -e 'const fs=require("fs"),p=require("./package.json");p.version=process.argv[1];fs.writeFileSync("package.json",JSON.stringify(p,null,2)+"\n")' "$REPAIR_VERSION" || { + echo "ERROR: drift repair failed." + exit 1 + } +fi +echo "Drift repaired: package.json synced to $REPAIR_VERSION. No version bump performed." +``` --- diff --git a/ship/SKILL.md.tmpl b/ship/SKILL.md.tmpl index e262d74e..75c73ccf 100644 --- a/ship/SKILL.md.tmpl +++ b/ship/SKILL.md.tmpl @@ -403,16 +403,57 @@ For each comment in `comments`: ## Step 12: Version bump (auto-decide) -**Idempotency check:** Before bumping, compare VERSION against the base branch. +**Idempotency check:** Before bumping, classify the state by comparing `VERSION` against the base branch AND against `package.json`'s `version` field. Four states: FRESH (do bump), ALREADY_BUMPED (skip bump), DRIFT_STALE_PKG (sync pkg only, no re-bump), DRIFT_UNEXPECTED (stop and ask). ```bash -BASE_VERSION=$(git show origin/:VERSION 2>/dev/null || echo "0.0.0.0") -CURRENT_VERSION=$(cat VERSION 2>/dev/null || echo "0.0.0.0") -echo "BASE: $BASE_VERSION HEAD: $CURRENT_VERSION" -if [ "$CURRENT_VERSION" != "$BASE_VERSION" ]; then echo "ALREADY_BUMPED"; fi +BASE_VERSION=$(git show origin/:VERSION 2>/dev/null | tr -d '\r\n[:space:]' || echo "0.0.0.0") +CURRENT_VERSION=$(cat VERSION 2>/dev/null | tr -d '\r\n[:space:]' || echo "0.0.0.0") +[ -z "$BASE_VERSION" ] && BASE_VERSION="0.0.0.0" +[ -z "$CURRENT_VERSION" ] && CURRENT_VERSION="0.0.0.0" +PKG_VERSION="" +PKG_EXISTS=0 +if [ -f package.json ]; then + PKG_EXISTS=1 + if command -v node >/dev/null 2>&1; then + PKG_VERSION=$(node -e 'const p=require("./package.json");process.stdout.write(p.version||"")' 2>/dev/null) + PARSE_EXIT=$? + elif command -v bun >/dev/null 2>&1; then + PKG_VERSION=$(bun -e 'const p=require("./package.json");process.stdout.write(p.version||"")' 2>/dev/null) + PARSE_EXIT=$? + else + echo "ERROR: package.json exists but neither node nor bun is available. Install one and re-run." + exit 1 + fi + if [ "$PARSE_EXIT" != "0" ]; then + echo "ERROR: package.json is not valid JSON. Fix the file before re-running /ship." + exit 1 + fi +fi +echo "BASE: $BASE_VERSION VERSION: $CURRENT_VERSION package.json: ${PKG_VERSION:-}" + +if [ "$CURRENT_VERSION" = "$BASE_VERSION" ]; then + if [ "$PKG_EXISTS" = "1" ] && [ -n "$PKG_VERSION" ] && [ "$PKG_VERSION" != "$CURRENT_VERSION" ]; then + echo "STATE: DRIFT_UNEXPECTED" + echo "package.json version ($PKG_VERSION) disagrees with VERSION ($CURRENT_VERSION) while VERSION matches base." + echo "This looks like a manual edit to package.json bypassing /ship. Reconcile manually, then re-run." + exit 1 + fi + echo "STATE: FRESH" +else + if [ "$PKG_EXISTS" = "1" ] && [ -n "$PKG_VERSION" ] && [ "$PKG_VERSION" != "$CURRENT_VERSION" ]; then + echo "STATE: DRIFT_STALE_PKG" + else + echo "STATE: ALREADY_BUMPED" + fi +fi ``` -If output shows `ALREADY_BUMPED`, VERSION was already bumped on this branch (prior `/ship` run). Skip the bump action (do not modify VERSION), but read the current VERSION value — it is needed for CHANGELOG and PR body. Continue to the next step. Otherwise proceed with the bump. +Read the `STATE:` line and dispatch: + +- **FRESH** → proceed with the bump action below (steps 1–4). +- **ALREADY_BUMPED** → skip the bump. Reuse `CURRENT_VERSION` for CHANGELOG and PR body. Continue to the next step. +- **DRIFT_STALE_PKG** → a prior `/ship` bumped `VERSION` but failed to update `package.json`. Run the sync-only repair block below (after step 4). Do NOT re-bump. Reuse `CURRENT_VERSION` for CHANGELOG and PR body. +- **DRIFT_UNEXPECTED** → `/ship` has halted (exit 1). Resolve manually; /ship cannot tell which file is authoritative. 1. Read the current `VERSION` file (4-digit format: `MAJOR.MINOR.PATCH.MICRO`) @@ -428,7 +469,53 @@ If output shows `ALREADY_BUMPED`, VERSION was already bumped on this branch (pri - Bumping a digit resets all digits to its right to 0 - Example: `0.19.1.0` + PATCH → `0.19.2.0` -4. Write the new version to the `VERSION` file. +4. **Validate** `NEW_VERSION` and write it to **both** `VERSION` and `package.json`. This block runs only when `STATE: FRESH`. + +```bash +if ! printf '%s' "$NEW_VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "ERROR: NEW_VERSION ($NEW_VERSION) does not match MAJOR.MINOR.PATCH.MICRO pattern. Aborting." + exit 1 +fi +echo "$NEW_VERSION" > VERSION +if [ -f package.json ]; then + if command -v node >/dev/null 2>&1; then + node -e 'const fs=require("fs"),p=require("./package.json");p.version=process.argv[1];fs.writeFileSync("package.json",JSON.stringify(p,null,2)+"\n")' "$NEW_VERSION" || { + echo "ERROR: failed to update package.json. VERSION was written but package.json is now stale. Fix and re-run — the new idempotency check will detect the drift." + exit 1 + } + elif command -v bun >/dev/null 2>&1; then + bun -e 'const fs=require("fs"),p=require("./package.json");p.version=process.argv[1];fs.writeFileSync("package.json",JSON.stringify(p,null,2)+"\n")' "$NEW_VERSION" || { + echo "ERROR: failed to update package.json. VERSION was written but package.json is now stale." + exit 1 + } + else + echo "ERROR: package.json exists but neither node nor bun is available." + exit 1 + fi +fi +``` + +**DRIFT_STALE_PKG repair path** — runs when idempotency reports `STATE: DRIFT_STALE_PKG`. No re-bump; sync `package.json.version` to the current `VERSION` and continue. Reuse `CURRENT_VERSION` for CHANGELOG and PR body. + +```bash +REPAIR_VERSION=$(cat VERSION | tr -d '\r\n[:space:]') +if ! printf '%s' "$REPAIR_VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "ERROR: VERSION file contents ($REPAIR_VERSION) do not match MAJOR.MINOR.PATCH.MICRO pattern. Refusing to propagate invalid semver into package.json. Fix VERSION manually, then re-run /ship." + exit 1 +fi +if command -v node >/dev/null 2>&1; then + node -e 'const fs=require("fs"),p=require("./package.json");p.version=process.argv[1];fs.writeFileSync("package.json",JSON.stringify(p,null,2)+"\n")' "$REPAIR_VERSION" || { + echo "ERROR: drift repair failed — could not update package.json." + exit 1 + } +else + bun -e 'const fs=require("fs"),p=require("./package.json");p.version=process.argv[1];fs.writeFileSync("package.json",JSON.stringify(p,null,2)+"\n")' "$REPAIR_VERSION" || { + echo "ERROR: drift repair failed." + exit 1 + } +fi +echo "Drift repaired: package.json synced to $REPAIR_VERSION. No version bump performed." +``` --- diff --git a/test/fixtures/golden/claude-ship-SKILL.md b/test/fixtures/golden/claude-ship-SKILL.md index 5ae15c37..3c7cb7d2 100644 --- a/test/fixtures/golden/claude-ship-SKILL.md +++ b/test/fixtures/golden/claude-ship-SKILL.md @@ -2404,16 +2404,57 @@ already knows. A good test: would this insight save time in a future session? If ## Step 12: Version bump (auto-decide) -**Idempotency check:** Before bumping, compare VERSION against the base branch. +**Idempotency check:** Before bumping, classify the state by comparing `VERSION` against the base branch AND against `package.json`'s `version` field. Four states: FRESH (do bump), ALREADY_BUMPED (skip bump), DRIFT_STALE_PKG (sync pkg only, no re-bump), DRIFT_UNEXPECTED (stop and ask). ```bash -BASE_VERSION=$(git show origin/:VERSION 2>/dev/null || echo "0.0.0.0") -CURRENT_VERSION=$(cat VERSION 2>/dev/null || echo "0.0.0.0") -echo "BASE: $BASE_VERSION HEAD: $CURRENT_VERSION" -if [ "$CURRENT_VERSION" != "$BASE_VERSION" ]; then echo "ALREADY_BUMPED"; fi +BASE_VERSION=$(git show origin/:VERSION 2>/dev/null | tr -d '\r\n[:space:]' || echo "0.0.0.0") +CURRENT_VERSION=$(cat VERSION 2>/dev/null | tr -d '\r\n[:space:]' || echo "0.0.0.0") +[ -z "$BASE_VERSION" ] && BASE_VERSION="0.0.0.0" +[ -z "$CURRENT_VERSION" ] && CURRENT_VERSION="0.0.0.0" +PKG_VERSION="" +PKG_EXISTS=0 +if [ -f package.json ]; then + PKG_EXISTS=1 + if command -v node >/dev/null 2>&1; then + PKG_VERSION=$(node -e 'const p=require("./package.json");process.stdout.write(p.version||"")' 2>/dev/null) + PARSE_EXIT=$? + elif command -v bun >/dev/null 2>&1; then + PKG_VERSION=$(bun -e 'const p=require("./package.json");process.stdout.write(p.version||"")' 2>/dev/null) + PARSE_EXIT=$? + else + echo "ERROR: package.json exists but neither node nor bun is available. Install one and re-run." + exit 1 + fi + if [ "$PARSE_EXIT" != "0" ]; then + echo "ERROR: package.json is not valid JSON. Fix the file before re-running /ship." + exit 1 + fi +fi +echo "BASE: $BASE_VERSION VERSION: $CURRENT_VERSION package.json: ${PKG_VERSION:-}" + +if [ "$CURRENT_VERSION" = "$BASE_VERSION" ]; then + if [ "$PKG_EXISTS" = "1" ] && [ -n "$PKG_VERSION" ] && [ "$PKG_VERSION" != "$CURRENT_VERSION" ]; then + echo "STATE: DRIFT_UNEXPECTED" + echo "package.json version ($PKG_VERSION) disagrees with VERSION ($CURRENT_VERSION) while VERSION matches base." + echo "This looks like a manual edit to package.json bypassing /ship. Reconcile manually, then re-run." + exit 1 + fi + echo "STATE: FRESH" +else + if [ "$PKG_EXISTS" = "1" ] && [ -n "$PKG_VERSION" ] && [ "$PKG_VERSION" != "$CURRENT_VERSION" ]; then + echo "STATE: DRIFT_STALE_PKG" + else + echo "STATE: ALREADY_BUMPED" + fi +fi ``` -If output shows `ALREADY_BUMPED`, VERSION was already bumped on this branch (prior `/ship` run). Skip the bump action (do not modify VERSION), but read the current VERSION value — it is needed for CHANGELOG and PR body. Continue to the next step. Otherwise proceed with the bump. +Read the `STATE:` line and dispatch: + +- **FRESH** → proceed with the bump action below (steps 1–4). +- **ALREADY_BUMPED** → skip the bump. Reuse `CURRENT_VERSION` for CHANGELOG and PR body. Continue to the next step. +- **DRIFT_STALE_PKG** → a prior `/ship` bumped `VERSION` but failed to update `package.json`. Run the sync-only repair block below (after step 4). Do NOT re-bump. Reuse `CURRENT_VERSION` for CHANGELOG and PR body. +- **DRIFT_UNEXPECTED** → `/ship` has halted (exit 1). Resolve manually; /ship cannot tell which file is authoritative. 1. Read the current `VERSION` file (4-digit format: `MAJOR.MINOR.PATCH.MICRO`) @@ -2429,7 +2470,53 @@ If output shows `ALREADY_BUMPED`, VERSION was already bumped on this branch (pri - Bumping a digit resets all digits to its right to 0 - Example: `0.19.1.0` + PATCH → `0.19.2.0` -4. Write the new version to the `VERSION` file. +4. **Validate** `NEW_VERSION` and write it to **both** `VERSION` and `package.json`. This block runs only when `STATE: FRESH`. + +```bash +if ! printf '%s' "$NEW_VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "ERROR: NEW_VERSION ($NEW_VERSION) does not match MAJOR.MINOR.PATCH.MICRO pattern. Aborting." + exit 1 +fi +echo "$NEW_VERSION" > VERSION +if [ -f package.json ]; then + if command -v node >/dev/null 2>&1; then + node -e 'const fs=require("fs"),p=require("./package.json");p.version=process.argv[1];fs.writeFileSync("package.json",JSON.stringify(p,null,2)+"\n")' "$NEW_VERSION" || { + echo "ERROR: failed to update package.json. VERSION was written but package.json is now stale. Fix and re-run — the new idempotency check will detect the drift." + exit 1 + } + elif command -v bun >/dev/null 2>&1; then + bun -e 'const fs=require("fs"),p=require("./package.json");p.version=process.argv[1];fs.writeFileSync("package.json",JSON.stringify(p,null,2)+"\n")' "$NEW_VERSION" || { + echo "ERROR: failed to update package.json. VERSION was written but package.json is now stale." + exit 1 + } + else + echo "ERROR: package.json exists but neither node nor bun is available." + exit 1 + fi +fi +``` + +**DRIFT_STALE_PKG repair path** — runs when idempotency reports `STATE: DRIFT_STALE_PKG`. No re-bump; sync `package.json.version` to the current `VERSION` and continue. Reuse `CURRENT_VERSION` for CHANGELOG and PR body. + +```bash +REPAIR_VERSION=$(cat VERSION | tr -d '\r\n[:space:]') +if ! printf '%s' "$REPAIR_VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "ERROR: VERSION file contents ($REPAIR_VERSION) do not match MAJOR.MINOR.PATCH.MICRO pattern. Refusing to propagate invalid semver into package.json. Fix VERSION manually, then re-run /ship." + exit 1 +fi +if command -v node >/dev/null 2>&1; then + node -e 'const fs=require("fs"),p=require("./package.json");p.version=process.argv[1];fs.writeFileSync("package.json",JSON.stringify(p,null,2)+"\n")' "$REPAIR_VERSION" || { + echo "ERROR: drift repair failed — could not update package.json." + exit 1 + } +else + bun -e 'const fs=require("fs"),p=require("./package.json");p.version=process.argv[1];fs.writeFileSync("package.json",JSON.stringify(p,null,2)+"\n")' "$REPAIR_VERSION" || { + echo "ERROR: drift repair failed." + exit 1 + } +fi +echo "Drift repaired: package.json synced to $REPAIR_VERSION. No version bump performed." +``` --- diff --git a/test/fixtures/golden/codex-ship-SKILL.md b/test/fixtures/golden/codex-ship-SKILL.md index 6553f3b2..562f0b3c 100644 --- a/test/fixtures/golden/codex-ship-SKILL.md +++ b/test/fixtures/golden/codex-ship-SKILL.md @@ -2019,16 +2019,57 @@ already knows. A good test: would this insight save time in a future session? If ## Step 12: Version bump (auto-decide) -**Idempotency check:** Before bumping, compare VERSION against the base branch. +**Idempotency check:** Before bumping, classify the state by comparing `VERSION` against the base branch AND against `package.json`'s `version` field. Four states: FRESH (do bump), ALREADY_BUMPED (skip bump), DRIFT_STALE_PKG (sync pkg only, no re-bump), DRIFT_UNEXPECTED (stop and ask). ```bash -BASE_VERSION=$(git show origin/:VERSION 2>/dev/null || echo "0.0.0.0") -CURRENT_VERSION=$(cat VERSION 2>/dev/null || echo "0.0.0.0") -echo "BASE: $BASE_VERSION HEAD: $CURRENT_VERSION" -if [ "$CURRENT_VERSION" != "$BASE_VERSION" ]; then echo "ALREADY_BUMPED"; fi +BASE_VERSION=$(git show origin/:VERSION 2>/dev/null | tr -d '\r\n[:space:]' || echo "0.0.0.0") +CURRENT_VERSION=$(cat VERSION 2>/dev/null | tr -d '\r\n[:space:]' || echo "0.0.0.0") +[ -z "$BASE_VERSION" ] && BASE_VERSION="0.0.0.0" +[ -z "$CURRENT_VERSION" ] && CURRENT_VERSION="0.0.0.0" +PKG_VERSION="" +PKG_EXISTS=0 +if [ -f package.json ]; then + PKG_EXISTS=1 + if command -v node >/dev/null 2>&1; then + PKG_VERSION=$(node -e 'const p=require("./package.json");process.stdout.write(p.version||"")' 2>/dev/null) + PARSE_EXIT=$? + elif command -v bun >/dev/null 2>&1; then + PKG_VERSION=$(bun -e 'const p=require("./package.json");process.stdout.write(p.version||"")' 2>/dev/null) + PARSE_EXIT=$? + else + echo "ERROR: package.json exists but neither node nor bun is available. Install one and re-run." + exit 1 + fi + if [ "$PARSE_EXIT" != "0" ]; then + echo "ERROR: package.json is not valid JSON. Fix the file before re-running /ship." + exit 1 + fi +fi +echo "BASE: $BASE_VERSION VERSION: $CURRENT_VERSION package.json: ${PKG_VERSION:-}" + +if [ "$CURRENT_VERSION" = "$BASE_VERSION" ]; then + if [ "$PKG_EXISTS" = "1" ] && [ -n "$PKG_VERSION" ] && [ "$PKG_VERSION" != "$CURRENT_VERSION" ]; then + echo "STATE: DRIFT_UNEXPECTED" + echo "package.json version ($PKG_VERSION) disagrees with VERSION ($CURRENT_VERSION) while VERSION matches base." + echo "This looks like a manual edit to package.json bypassing /ship. Reconcile manually, then re-run." + exit 1 + fi + echo "STATE: FRESH" +else + if [ "$PKG_EXISTS" = "1" ] && [ -n "$PKG_VERSION" ] && [ "$PKG_VERSION" != "$CURRENT_VERSION" ]; then + echo "STATE: DRIFT_STALE_PKG" + else + echo "STATE: ALREADY_BUMPED" + fi +fi ``` -If output shows `ALREADY_BUMPED`, VERSION was already bumped on this branch (prior `/ship` run). Skip the bump action (do not modify VERSION), but read the current VERSION value — it is needed for CHANGELOG and PR body. Continue to the next step. Otherwise proceed with the bump. +Read the `STATE:` line and dispatch: + +- **FRESH** → proceed with the bump action below (steps 1–4). +- **ALREADY_BUMPED** → skip the bump. Reuse `CURRENT_VERSION` for CHANGELOG and PR body. Continue to the next step. +- **DRIFT_STALE_PKG** → a prior `/ship` bumped `VERSION` but failed to update `package.json`. Run the sync-only repair block below (after step 4). Do NOT re-bump. Reuse `CURRENT_VERSION` for CHANGELOG and PR body. +- **DRIFT_UNEXPECTED** → `/ship` has halted (exit 1). Resolve manually; /ship cannot tell which file is authoritative. 1. Read the current `VERSION` file (4-digit format: `MAJOR.MINOR.PATCH.MICRO`) @@ -2044,7 +2085,53 @@ If output shows `ALREADY_BUMPED`, VERSION was already bumped on this branch (pri - Bumping a digit resets all digits to its right to 0 - Example: `0.19.1.0` + PATCH → `0.19.2.0` -4. Write the new version to the `VERSION` file. +4. **Validate** `NEW_VERSION` and write it to **both** `VERSION` and `package.json`. This block runs only when `STATE: FRESH`. + +```bash +if ! printf '%s' "$NEW_VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "ERROR: NEW_VERSION ($NEW_VERSION) does not match MAJOR.MINOR.PATCH.MICRO pattern. Aborting." + exit 1 +fi +echo "$NEW_VERSION" > VERSION +if [ -f package.json ]; then + if command -v node >/dev/null 2>&1; then + node -e 'const fs=require("fs"),p=require("./package.json");p.version=process.argv[1];fs.writeFileSync("package.json",JSON.stringify(p,null,2)+"\n")' "$NEW_VERSION" || { + echo "ERROR: failed to update package.json. VERSION was written but package.json is now stale. Fix and re-run — the new idempotency check will detect the drift." + exit 1 + } + elif command -v bun >/dev/null 2>&1; then + bun -e 'const fs=require("fs"),p=require("./package.json");p.version=process.argv[1];fs.writeFileSync("package.json",JSON.stringify(p,null,2)+"\n")' "$NEW_VERSION" || { + echo "ERROR: failed to update package.json. VERSION was written but package.json is now stale." + exit 1 + } + else + echo "ERROR: package.json exists but neither node nor bun is available." + exit 1 + fi +fi +``` + +**DRIFT_STALE_PKG repair path** — runs when idempotency reports `STATE: DRIFT_STALE_PKG`. No re-bump; sync `package.json.version` to the current `VERSION` and continue. Reuse `CURRENT_VERSION` for CHANGELOG and PR body. + +```bash +REPAIR_VERSION=$(cat VERSION | tr -d '\r\n[:space:]') +if ! printf '%s' "$REPAIR_VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "ERROR: VERSION file contents ($REPAIR_VERSION) do not match MAJOR.MINOR.PATCH.MICRO pattern. Refusing to propagate invalid semver into package.json. Fix VERSION manually, then re-run /ship." + exit 1 +fi +if command -v node >/dev/null 2>&1; then + node -e 'const fs=require("fs"),p=require("./package.json");p.version=process.argv[1];fs.writeFileSync("package.json",JSON.stringify(p,null,2)+"\n")' "$REPAIR_VERSION" || { + echo "ERROR: drift repair failed — could not update package.json." + exit 1 + } +else + bun -e 'const fs=require("fs"),p=require("./package.json");p.version=process.argv[1];fs.writeFileSync("package.json",JSON.stringify(p,null,2)+"\n")' "$REPAIR_VERSION" || { + echo "ERROR: drift repair failed." + exit 1 + } +fi +echo "Drift repaired: package.json synced to $REPAIR_VERSION. No version bump performed." +``` --- diff --git a/test/fixtures/golden/factory-ship-SKILL.md b/test/fixtures/golden/factory-ship-SKILL.md index 6fbe2902..ee8b11fd 100644 --- a/test/fixtures/golden/factory-ship-SKILL.md +++ b/test/fixtures/golden/factory-ship-SKILL.md @@ -2395,16 +2395,57 @@ already knows. A good test: would this insight save time in a future session? If ## Step 12: Version bump (auto-decide) -**Idempotency check:** Before bumping, compare VERSION against the base branch. +**Idempotency check:** Before bumping, classify the state by comparing `VERSION` against the base branch AND against `package.json`'s `version` field. Four states: FRESH (do bump), ALREADY_BUMPED (skip bump), DRIFT_STALE_PKG (sync pkg only, no re-bump), DRIFT_UNEXPECTED (stop and ask). ```bash -BASE_VERSION=$(git show origin/:VERSION 2>/dev/null || echo "0.0.0.0") -CURRENT_VERSION=$(cat VERSION 2>/dev/null || echo "0.0.0.0") -echo "BASE: $BASE_VERSION HEAD: $CURRENT_VERSION" -if [ "$CURRENT_VERSION" != "$BASE_VERSION" ]; then echo "ALREADY_BUMPED"; fi +BASE_VERSION=$(git show origin/:VERSION 2>/dev/null | tr -d '\r\n[:space:]' || echo "0.0.0.0") +CURRENT_VERSION=$(cat VERSION 2>/dev/null | tr -d '\r\n[:space:]' || echo "0.0.0.0") +[ -z "$BASE_VERSION" ] && BASE_VERSION="0.0.0.0" +[ -z "$CURRENT_VERSION" ] && CURRENT_VERSION="0.0.0.0" +PKG_VERSION="" +PKG_EXISTS=0 +if [ -f package.json ]; then + PKG_EXISTS=1 + if command -v node >/dev/null 2>&1; then + PKG_VERSION=$(node -e 'const p=require("./package.json");process.stdout.write(p.version||"")' 2>/dev/null) + PARSE_EXIT=$? + elif command -v bun >/dev/null 2>&1; then + PKG_VERSION=$(bun -e 'const p=require("./package.json");process.stdout.write(p.version||"")' 2>/dev/null) + PARSE_EXIT=$? + else + echo "ERROR: package.json exists but neither node nor bun is available. Install one and re-run." + exit 1 + fi + if [ "$PARSE_EXIT" != "0" ]; then + echo "ERROR: package.json is not valid JSON. Fix the file before re-running /ship." + exit 1 + fi +fi +echo "BASE: $BASE_VERSION VERSION: $CURRENT_VERSION package.json: ${PKG_VERSION:-}" + +if [ "$CURRENT_VERSION" = "$BASE_VERSION" ]; then + if [ "$PKG_EXISTS" = "1" ] && [ -n "$PKG_VERSION" ] && [ "$PKG_VERSION" != "$CURRENT_VERSION" ]; then + echo "STATE: DRIFT_UNEXPECTED" + echo "package.json version ($PKG_VERSION) disagrees with VERSION ($CURRENT_VERSION) while VERSION matches base." + echo "This looks like a manual edit to package.json bypassing /ship. Reconcile manually, then re-run." + exit 1 + fi + echo "STATE: FRESH" +else + if [ "$PKG_EXISTS" = "1" ] && [ -n "$PKG_VERSION" ] && [ "$PKG_VERSION" != "$CURRENT_VERSION" ]; then + echo "STATE: DRIFT_STALE_PKG" + else + echo "STATE: ALREADY_BUMPED" + fi +fi ``` -If output shows `ALREADY_BUMPED`, VERSION was already bumped on this branch (prior `/ship` run). Skip the bump action (do not modify VERSION), but read the current VERSION value — it is needed for CHANGELOG and PR body. Continue to the next step. Otherwise proceed with the bump. +Read the `STATE:` line and dispatch: + +- **FRESH** → proceed with the bump action below (steps 1–4). +- **ALREADY_BUMPED** → skip the bump. Reuse `CURRENT_VERSION` for CHANGELOG and PR body. Continue to the next step. +- **DRIFT_STALE_PKG** → a prior `/ship` bumped `VERSION` but failed to update `package.json`. Run the sync-only repair block below (after step 4). Do NOT re-bump. Reuse `CURRENT_VERSION` for CHANGELOG and PR body. +- **DRIFT_UNEXPECTED** → `/ship` has halted (exit 1). Resolve manually; /ship cannot tell which file is authoritative. 1. Read the current `VERSION` file (4-digit format: `MAJOR.MINOR.PATCH.MICRO`) @@ -2420,7 +2461,53 @@ If output shows `ALREADY_BUMPED`, VERSION was already bumped on this branch (pri - Bumping a digit resets all digits to its right to 0 - Example: `0.19.1.0` + PATCH → `0.19.2.0` -4. Write the new version to the `VERSION` file. +4. **Validate** `NEW_VERSION` and write it to **both** `VERSION` and `package.json`. This block runs only when `STATE: FRESH`. + +```bash +if ! printf '%s' "$NEW_VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "ERROR: NEW_VERSION ($NEW_VERSION) does not match MAJOR.MINOR.PATCH.MICRO pattern. Aborting." + exit 1 +fi +echo "$NEW_VERSION" > VERSION +if [ -f package.json ]; then + if command -v node >/dev/null 2>&1; then + node -e 'const fs=require("fs"),p=require("./package.json");p.version=process.argv[1];fs.writeFileSync("package.json",JSON.stringify(p,null,2)+"\n")' "$NEW_VERSION" || { + echo "ERROR: failed to update package.json. VERSION was written but package.json is now stale. Fix and re-run — the new idempotency check will detect the drift." + exit 1 + } + elif command -v bun >/dev/null 2>&1; then + bun -e 'const fs=require("fs"),p=require("./package.json");p.version=process.argv[1];fs.writeFileSync("package.json",JSON.stringify(p,null,2)+"\n")' "$NEW_VERSION" || { + echo "ERROR: failed to update package.json. VERSION was written but package.json is now stale." + exit 1 + } + else + echo "ERROR: package.json exists but neither node nor bun is available." + exit 1 + fi +fi +``` + +**DRIFT_STALE_PKG repair path** — runs when idempotency reports `STATE: DRIFT_STALE_PKG`. No re-bump; sync `package.json.version` to the current `VERSION` and continue. Reuse `CURRENT_VERSION` for CHANGELOG and PR body. + +```bash +REPAIR_VERSION=$(cat VERSION | tr -d '\r\n[:space:]') +if ! printf '%s' "$REPAIR_VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "ERROR: VERSION file contents ($REPAIR_VERSION) do not match MAJOR.MINOR.PATCH.MICRO pattern. Refusing to propagate invalid semver into package.json. Fix VERSION manually, then re-run /ship." + exit 1 +fi +if command -v node >/dev/null 2>&1; then + node -e 'const fs=require("fs"),p=require("./package.json");p.version=process.argv[1];fs.writeFileSync("package.json",JSON.stringify(p,null,2)+"\n")' "$REPAIR_VERSION" || { + echo "ERROR: drift repair failed — could not update package.json." + exit 1 + } +else + bun -e 'const fs=require("fs"),p=require("./package.json");p.version=process.argv[1];fs.writeFileSync("package.json",JSON.stringify(p,null,2)+"\n")' "$REPAIR_VERSION" || { + echo "ERROR: drift repair failed." + exit 1 + } +fi +echo "Drift repaired: package.json synced to $REPAIR_VERSION. No version bump performed." +``` --- diff --git a/test/ship-version-sync.test.ts b/test/ship-version-sync.test.ts new file mode 100644 index 00000000..c657795c --- /dev/null +++ b/test/ship-version-sync.test.ts @@ -0,0 +1,224 @@ +// /ship Step 12: VERSION ↔ package.json drift detection + repair. +// Mirrors the bash blocks in ship/SKILL.md.tmpl Step 12. When the template +// changes, update both sides together. +// +// Coverage gap: node-absent + bun-present path. Simulating "no node" in-process +// is flaky across dev machines; covered by manual spot-check + CI running on +// bun-only images if/when we add them. + +import { test, expect, beforeEach, afterEach } from "bun:test"; +import { execSync } from "node:child_process"; +import { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +let dir: string; +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "ship-drift-")); +}); +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +const writeFiles = (files: Record) => { + for (const [name, content] of Object.entries(files)) { + writeFileSync(join(dir, name), content); + } +}; + +const pkgJson = (version: string | null, extra: Record = {}) => + JSON.stringify( + version === null ? { name: "x", ...extra } : { name: "x", version, ...extra }, + null, + 2, + ) + "\n"; + +const idempotency = (base: string): { stdout: string; code: number } => { + const script = ` +cd "${dir}" || exit 2 +BASE_VERSION="${base}" +CURRENT_VERSION=$(cat VERSION 2>/dev/null | tr -d '\\r\\n[:space:]' || echo "0.0.0.0") +[ -z "$CURRENT_VERSION" ] && CURRENT_VERSION="0.0.0.0" +PKG_VERSION="" +PKG_EXISTS=0 +if [ -f package.json ]; then + PKG_EXISTS=1 + if command -v node >/dev/null 2>&1; then + PKG_VERSION=$(node -e 'const p=require("./package.json");process.stdout.write(p.version||"")' 2>/dev/null) + PARSE_EXIT=$? + elif command -v bun >/dev/null 2>&1; then + PKG_VERSION=$(bun -e 'const p=require("./package.json");process.stdout.write(p.version||"")' 2>/dev/null) + PARSE_EXIT=$? + else + echo "ERROR: no parser"; exit 1 + fi + if [ "$PARSE_EXIT" != "0" ]; then + echo "ERROR: invalid JSON"; exit 1 + fi +fi +if [ "$CURRENT_VERSION" = "$BASE_VERSION" ]; then + if [ "$PKG_EXISTS" = "1" ] && [ -n "$PKG_VERSION" ] && [ "$PKG_VERSION" != "$CURRENT_VERSION" ]; then + echo "STATE: DRIFT_UNEXPECTED"; exit 1 + fi + echo "STATE: FRESH" +else + if [ "$PKG_EXISTS" = "1" ] && [ -n "$PKG_VERSION" ] && [ "$PKG_VERSION" != "$CURRENT_VERSION" ]; then + echo "STATE: DRIFT_STALE_PKG" + else + echo "STATE: ALREADY_BUMPED" + fi +fi`; + try { + const stdout = execSync(script, { shell: "/bin/bash", encoding: "utf8" }); + return { stdout: stdout.trim(), code: 0 }; + } catch (e: any) { + return { stdout: (e.stdout || "").toString().trim(), code: e.status ?? 1 }; + } +}; + +const bump = (newVer: string): { code: number } => { + const script = ` +cd "${dir}" || exit 2 +NEW_VERSION="${newVer}" +if ! printf '%s' "$NEW_VERSION" | grep -qE '^[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+$'; then + echo "invalid semver" >&2; exit 1 +fi +echo "$NEW_VERSION" > VERSION +if [ -f package.json ]; then + node -e 'const fs=require("fs"),p=require("./package.json");p.version=process.argv[1];fs.writeFileSync("package.json",JSON.stringify(p,null,2)+"\\n")' "$NEW_VERSION" +fi`; + try { + execSync(script, { shell: "/bin/bash", stdio: "pipe" }); + return { code: 0 }; + } catch (e: any) { + return { code: e.status ?? 1 }; + } +}; + +const syncRepair = (): { code: number } => { + const script = ` +cd "${dir}" || exit 2 +REPAIR_VERSION=$(cat VERSION | tr -d '\\r\\n[:space:]') +if ! printf '%s' "$REPAIR_VERSION" | grep -qE '^[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+$'; then + echo "invalid repair semver" >&2; exit 1 +fi +node -e 'const fs=require("fs"),p=require("./package.json");p.version=process.argv[1];fs.writeFileSync("package.json",JSON.stringify(p,null,2)+"\\n")' "$REPAIR_VERSION"`; + try { + execSync(script, { shell: "/bin/bash", stdio: "pipe" }); + return { code: 0 }; + } catch (e: any) { + return { code: e.status ?? 1 }; + } +}; + +const pkgVersion = () => + JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).version; + +// --- Idempotency classification: 6 cases --- + +test("FRESH: VERSION == base, pkg synced", () => { + writeFiles({ VERSION: "0.0.0.0\n", "package.json": pkgJson("0.0.0.0") }); + expect(idempotency("0.0.0.0")).toEqual({ stdout: "STATE: FRESH", code: 0 }); +}); + +test("FRESH: VERSION == base, no package.json", () => { + writeFiles({ VERSION: "0.0.0.0\n" }); + expect(idempotency("0.0.0.0")).toEqual({ stdout: "STATE: FRESH", code: 0 }); +}); + +test("ALREADY_BUMPED: VERSION ahead, pkg synced", () => { + writeFiles({ VERSION: "0.1.0.0\n", "package.json": pkgJson("0.1.0.0") }); + expect(idempotency("0.0.0.0")).toEqual({ stdout: "STATE: ALREADY_BUMPED", code: 0 }); +}); + +test("ALREADY_BUMPED: VERSION ahead, no package.json", () => { + writeFiles({ VERSION: "0.1.0.0\n" }); + expect(idempotency("0.0.0.0")).toEqual({ stdout: "STATE: ALREADY_BUMPED", code: 0 }); +}); + +test("DRIFT_STALE_PKG: VERSION ahead, pkg stale", () => { + writeFiles({ VERSION: "0.1.0.0\n", "package.json": pkgJson("0.0.0.0") }); + expect(idempotency("0.0.0.0")).toEqual({ stdout: "STATE: DRIFT_STALE_PKG", code: 0 }); +}); + +test("DRIFT_UNEXPECTED: VERSION == base, pkg edited (exits non-zero)", () => { + writeFiles({ VERSION: "0.0.0.0\n", "package.json": pkgJson("0.5.0.0") }); + const r = idempotency("0.0.0.0"); + expect(r.stdout.startsWith("STATE: DRIFT_UNEXPECTED")).toBe(true); + expect(r.code).toBe(1); +}); + +// --- Parse failures: 2 cases --- + +test("idempotency: invalid JSON exits non-zero with clear error", () => { + writeFiles({ VERSION: "0.1.0.0\n", "package.json": "{ not valid" }); + const r = idempotency("0.0.0.0"); + expect(r.code).toBe(1); + expect(r.stdout).toContain("invalid JSON"); +}); + +test("idempotency: package.json with no version field treated as ", () => { + writeFiles({ VERSION: "0.1.0.0\n", "package.json": pkgJson(null) }); + // PKG_VERSION is empty → drift check skipped → ALREADY_BUMPED + expect(idempotency("0.0.0.0")).toEqual({ stdout: "STATE: ALREADY_BUMPED", code: 0 }); +}); + +// --- Bump: 3 cases --- + +test("bump: writes VERSION and package.json in sync", () => { + writeFiles({ VERSION: "0.0.0.0\n", "package.json": pkgJson("0.0.0.0") }); + expect(bump("0.1.0.0").code).toBe(0); + expect(readFileSync(join(dir, "VERSION"), "utf8").trim()).toBe("0.1.0.0"); + expect(pkgVersion()).toBe("0.1.0.0"); +}); + +test("bump: rejects invalid NEW_VERSION", () => { + writeFiles({ VERSION: "0.0.0.0\n", "package.json": pkgJson("0.0.0.0") }); + const r = bump("not-a-version"); + expect(r.code).toBe(1); + // VERSION is unchanged — validation runs before any write. + expect(readFileSync(join(dir, "VERSION"), "utf8").trim()).toBe("0.0.0.0"); +}); + +test("bump: no package.json is silent", () => { + writeFiles({ VERSION: "0.0.0.0\n" }); + expect(bump("0.1.0.0").code).toBe(0); + expect(readFileSync(join(dir, "VERSION"), "utf8").trim()).toBe("0.1.0.0"); + expect(existsSync(join(dir, "package.json"))).toBe(false); +}); + +// --- Adversarial review regressions: trailing whitespace + invalid REPAIR_VERSION --- + +test("trailing CR in VERSION does not cause false DRIFT_STALE_PKG", () => { + // Before the tr-strip fix, VERSION="0.1.0.0\r" read via cat would mismatch + // pkg.version="0.1.0.0" and classify as DRIFT_STALE_PKG, then repair would + // write garbage \r into package.json. Now CURRENT_VERSION is stripped. + writeFileSync(join(dir, "VERSION"), "0.1.0.0\r\n"); + writeFileSync(join(dir, "package.json"), pkgJson("0.1.0.0")); + expect(idempotency("0.0.0.0")).toEqual({ stdout: "STATE: ALREADY_BUMPED", code: 0 }); +}); + +test("DRIFT REPAIR rejects invalid VERSION semver instead of propagating", () => { + // If VERSION is corrupted/manually-edited to something non-semver, the + // repair path must refuse rather than writing junk into package.json. + writeFileSync(join(dir, "VERSION"), "not-a-semver\n"); + writeFileSync(join(dir, "package.json"), pkgJson("0.0.0.0")); + const r = syncRepair(); + expect(r.code).toBe(1); + // package.json must NOT have been overwritten with the garbage. + expect(pkgVersion()).toBe("0.0.0.0"); +}); + +// --- THE critical regression test: drift-repair does NOT double-bump --- + +test("DRIFT REPAIR: sync path syncs pkg to VERSION without re-bumping", () => { + // Simulate a prior /ship that bumped VERSION but failed to touch package.json. + writeFiles({ VERSION: "0.1.0.0\n", "package.json": pkgJson("0.0.0.0") }); + // Idempotency classifies as DRIFT_STALE_PKG. + expect(idempotency("0.0.0.0").stdout).toBe("STATE: DRIFT_STALE_PKG"); + // Sync-only repair runs — no re-bump. + expect(syncRepair().code).toBe(0); + // VERSION is unchanged. package.json now matches VERSION. No 0.2.0.0. + expect(readFileSync(join(dir, "VERSION"), "utf8").trim()).toBe("0.1.0.0"); + expect(pkgVersion()).toBe("0.1.0.0"); +});