mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-02 03:35:09 +02:00
merge: origin/main v1.1.1.0 into garrytan/fix-checkpoints
Main shipped the /ship VERSION/package.json drift-detection fix as v1.1.1.0 — exact collision with our branch's existing version. Bumped ours to 1.1.2.0. Resolved conflicts: - VERSION: 1.1.1.0 → 1.1.2.0 - package.json: 1.1.1.0 → 1.1.2.0 - CHANGELOG.md: moved our /checkpoint → /context-save entry up one header level to [1.1.2.0] and kept main's /ship drift-fix entry at [1.1.1.0]. Sequence now: 1.1.2.0 → 1.1.1.0 → 1.1.0.0 → 1.0.0.0. - Migration renamed v1.1.1.0.sh → v1.1.2.0.sh (version string inside and test path reference both updated). Also bumped the /context-save + /context-restore CHANGELOG entry to credit the adversarial-review hardening wave (HOME guard, realpath fallback, title sanitize, collision-safe filenames, context-restore head cap, autoplan test regex tightening) as contributor-facing notes — previous entry didn't reflect the security work that landed after the initial ship. No overlap between main's /ship Step 12 logic and this branch's work. SKILL.md files regenerated via bun run gen:skill-docs --host all. Golden fixtures updated. bun test: 0 failures across 80+ targeted tests and the full suite. Migration ownership guard: 7/7 pass (~85ms).
This commit is contained in:
+15
-2
@@ -1,6 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## [1.1.1.0] - 2026-04-18
|
||||
## [1.1.2.0] - 2026-04-19
|
||||
|
||||
### Changed
|
||||
- **`/checkpoint` is now `/context-save` + `/context-restore`.** Claude Code treats `/checkpoint` as a native rewind alias in current environments, which was shadowing the gstack skill. Symptom: you'd type `/checkpoint`, the agent would describe it as a "built-in you need to type directly," and nothing would get saved. The fix is a clean rename and a split into two skills. One that saves, one that restores. Your old saved files still load via `/context-restore` (storage path unchanged).
|
||||
@@ -14,9 +14,22 @@
|
||||
- **Empty-set bug on macOS.** If you ran `/checkpoint resume` (now `/context-restore`) with zero saved files, `find ... | xargs ls -1t` would fall back to listing your current directory. Confusing output, no clean "no saved contexts yet" message. Replaced with `find | sort -r | head` so empty input stays empty.
|
||||
|
||||
### For contributors
|
||||
- New `gstack-upgrade/migrations/v1.1.1.0.sh` removes the stale on-disk `/checkpoint` install so Claude Code's native `/rewind` alias is no longer shadowed. Ownership-guarded: the migration only removes the install if it's a symlink resolving into `~/.claude/skills/gstack/`. A user's own `/checkpoint` skill (regular file, or symlink pointing elsewhere) is preserved with a notice.
|
||||
- New `gstack-upgrade/migrations/v1.1.2.0.sh` removes the stale on-disk `/checkpoint` install so Claude Code's native `/rewind` alias is no longer shadowed. Ownership-guarded across three install shapes (directory symlink into gstack, directory with SKILL.md symlinked into gstack, anything else). User-owned `/checkpoint` skills preserved with a notice. Migration hardened after adversarial review: explicit `HOME` unset/empty guard, `realpath` with python3 fallback, `rm --` flag, macOS sidecar handling.
|
||||
- `test/migration-checkpoint-ownership.test.ts` ships 7 scenarios covering all 3 install shapes + idempotency + no-op-when-gstack-not-installed + SKILL.md-symlink-outside-gstack. Free tier, ~85ms.
|
||||
- Split `checkpoint-save-resume` E2E into `context-save-writes-file` and `context-restore-loads-latest`. The latter seeds two files with scrambled mtimes so the "filename-prefix, not mtime" guarantee is locked in.
|
||||
- `context-save` now sanitizes the title in bash (allowlist `[a-z0-9.-]`, cap 60 chars) instead of trusting LLM-side slugification, and appends a random suffix on same-second collisions to enforce the append-only contract.
|
||||
- `context-restore` caps its filename listing at 20 most-recent entries so users with 10k+ saved files don't blow the context window.
|
||||
- `test/skill-e2e-autoplan-dual-voice.test.ts` was shipped broken on main (wrong `runSkillTest` option names, wrong result-field access, wrong helper signatures, missing Agent/Skill tools). Fixed end-to-end: 1/1 pass on first attempt, $0.68, 211s. Voice-detection regexes now match JSON-shaped tool_use entries and phase-completion markers, not bare prompt-text mentions.
|
||||
|
||||
## [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
|
||||
|
||||
|
||||
@@ -455,6 +455,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/<base>: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/<base> unreachable" from "origin/<base>: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/<base>` 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.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Migration: v1.1.1.0 — Remove stale /checkpoint skill installs
|
||||
# Migration: v1.1.2.0 — Remove stale /checkpoint skill installs
|
||||
#
|
||||
# Claude Code ships /checkpoint as a native alias for /rewind, which was
|
||||
# shadowing the gstack checkpoint skill. The skill has been split into
|
||||
@@ -25,7 +25,7 @@ set -euo pipefail
|
||||
# CI runners) survives and produces dangerous absolute paths like
|
||||
# "/.claude/skills/...". Abort cleanly.
|
||||
if [ -z "${HOME:-}" ]; then
|
||||
echo " [v1.1.1.0] HOME is unset or empty — skipping migration." >&2
|
||||
echo " [v1.1.2.0] HOME is unset or empty — skipping migration." >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -75,10 +75,10 @@ if [ -L "$OLD_TOPLEVEL" ]; then
|
||||
target_real=$(resolve_real "$OLD_TOPLEVEL")
|
||||
if [ -n "$GSTACK_ROOT_REAL" ] && path_inside "$target_real" "$GSTACK_ROOT_REAL"; then
|
||||
rm -- "$OLD_TOPLEVEL"
|
||||
echo " [v1.1.1.0] Removed stale /checkpoint symlink (was shadowing Claude Code's /rewind alias)."
|
||||
echo " [v1.1.2.0] Removed stale /checkpoint symlink (was shadowing Claude Code's /rewind alias)."
|
||||
removed_any=1
|
||||
else
|
||||
echo " [v1.1.1.0] Leaving $OLD_TOPLEVEL alone — symlink target is outside gstack (or unresolvable)."
|
||||
echo " [v1.1.2.0] Leaving $OLD_TOPLEVEL alone — symlink target is outside gstack (or unresolvable)."
|
||||
fi
|
||||
elif [ -d "$OLD_TOPLEVEL" ]; then
|
||||
# Regular directory. Only remove if it contains exactly one file named
|
||||
@@ -92,13 +92,13 @@ elif [ -d "$OLD_TOPLEVEL" ]; then
|
||||
# Strip macOS sidecars first (not user content), then remove the dir.
|
||||
find "$OLD_TOPLEVEL" -maxdepth 1 \( -name '.DS_Store' -o -name '._*' \) -type f -delete 2>/dev/null || true
|
||||
rm -r -- "$OLD_TOPLEVEL"
|
||||
echo " [v1.1.1.0] Removed stale /checkpoint install directory (gstack prefix-mode)."
|
||||
echo " [v1.1.2.0] Removed stale /checkpoint install directory (gstack prefix-mode)."
|
||||
removed_any=1
|
||||
else
|
||||
echo " [v1.1.1.0] Leaving $OLD_TOPLEVEL alone — SKILL.md symlink target is outside gstack."
|
||||
echo " [v1.1.2.0] Leaving $OLD_TOPLEVEL alone — SKILL.md symlink target is outside gstack."
|
||||
fi
|
||||
else
|
||||
echo " [v1.1.1.0] Leaving $OLD_TOPLEVEL alone — not a gstack-owned install (has custom content)."
|
||||
echo " [v1.1.2.0] Leaving $OLD_TOPLEVEL alone — not a gstack-owned install (has custom content)."
|
||||
fi
|
||||
fi
|
||||
# Missing → no-op (idempotency).
|
||||
@@ -111,10 +111,10 @@ if [ -L "$OLD_NAMESPACED" ]; then
|
||||
target_real=$(resolve_real "$OLD_NAMESPACED")
|
||||
if [ -n "$GSTACK_ROOT_REAL" ] && path_inside "$target_real" "$GSTACK_ROOT_REAL"; then
|
||||
rm -- "$OLD_NAMESPACED"
|
||||
echo " [v1.1.1.0] Removed stale ~/.claude/skills/gstack/checkpoint symlink."
|
||||
echo " [v1.1.2.0] Removed stale ~/.claude/skills/gstack/checkpoint symlink."
|
||||
removed_any=1
|
||||
else
|
||||
echo " [v1.1.1.0] Leaving $OLD_NAMESPACED alone — symlink target is outside gstack."
|
||||
echo " [v1.1.2.0] Leaving $OLD_NAMESPACED alone — symlink target is outside gstack."
|
||||
fi
|
||||
elif [ -d "$OLD_NAMESPACED" ]; then
|
||||
# Regular directory. This is the gstack-prefix install location. Check that
|
||||
@@ -123,15 +123,15 @@ elif [ -d "$OLD_NAMESPACED" ]; then
|
||||
target_real=$(resolve_real "$OLD_NAMESPACED")
|
||||
if [ -n "$GSTACK_ROOT_REAL" ] && path_inside "$target_real" "$GSTACK_ROOT_REAL"; then
|
||||
rm -rf -- "$OLD_NAMESPACED"
|
||||
echo " [v1.1.1.0] Removed stale ~/.claude/skills/gstack/checkpoint/ (replaced by context-save + context-restore)."
|
||||
echo " [v1.1.2.0] Removed stale ~/.claude/skills/gstack/checkpoint/ (replaced by context-save + context-restore)."
|
||||
removed_any=1
|
||||
else
|
||||
echo " [v1.1.1.0] Leaving $OLD_NAMESPACED alone — resolves outside gstack."
|
||||
echo " [v1.1.2.0] Leaving $OLD_NAMESPACED alone — resolves outside gstack."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$removed_any" = "1" ]; then
|
||||
echo " [v1.1.1.0] /checkpoint is now Claude Code's native /rewind alias. Use /context-save to save state and /context-restore to resume."
|
||||
echo " [v1.1.2.0] /checkpoint is now Claude Code's native /rewind alias. Use /context-save to save state and /context-restore to resume."
|
||||
fi
|
||||
|
||||
exit 0
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.1.1.0",
|
||||
"version": "1.1.2.0",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
+94
-7
@@ -2405,16 +2405,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/<base>: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/<base>: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:-<none>}"
|
||||
|
||||
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`)
|
||||
|
||||
@@ -2430,7 +2471,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."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
+94
-7
@@ -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/<base>: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/<base>: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:-<none>}"
|
||||
|
||||
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."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
+94
-7
@@ -2405,16 +2405,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/<base>: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/<base>: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:-<none>}"
|
||||
|
||||
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`)
|
||||
|
||||
@@ -2430,7 +2471,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."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
+94
-7
@@ -2020,16 +2020,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/<base>: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/<base>: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:-<none>}"
|
||||
|
||||
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`)
|
||||
|
||||
@@ -2045,7 +2086,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."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
+94
-7
@@ -2396,16 +2396,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/<base>: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/<base>: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:-<none>}"
|
||||
|
||||
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`)
|
||||
|
||||
@@ -2421,7 +2462,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."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const MIGRATION = path.join(ROOT, 'gstack-upgrade', 'migrations', 'v1.1.1.0.sh');
|
||||
const MIGRATION = path.join(ROOT, 'gstack-upgrade', 'migrations', 'v1.1.2.0.sh');
|
||||
|
||||
function runMigration(tmpHome: string): { exitCode: number; stdout: string; stderr: string } {
|
||||
const result = spawnSync('bash', [MIGRATION], {
|
||||
@@ -28,7 +28,7 @@ function setupFakeGstackRoot(tmpHome: string): string {
|
||||
return gstackDir;
|
||||
}
|
||||
|
||||
describe('migration v1.1.1.0 — checkpoint ownership guard', () => {
|
||||
describe('migration v1.1.2.0 — checkpoint ownership guard', () => {
|
||||
let tmpHome: string;
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -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<string, string>) => {
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
writeFileSync(join(dir, name), content);
|
||||
}
|
||||
};
|
||||
|
||||
const pkgJson = (version: string | null, extra: Record<string, unknown> = {}) =>
|
||||
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 <none>", () => {
|
||||
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");
|
||||
});
|
||||
Reference in New Issue
Block a user