mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-02 03:35:09 +02:00
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) <noreply@anthropic.com>
This commit is contained in:
+86
-5
@@ -403,16 +403,55 @@ 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
|
||||
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 +467,49 @@ 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)
|
||||
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."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
// /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 || echo "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)
|
||||
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);
|
||||
});
|
||||
|
||||
// --- 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