Skip to content

Commit c3bee68

Browse files
garrytanclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 07b8983 commit c3bee68

3 files changed

Lines changed: 61 additions & 5 deletions

File tree

TODOS.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,30 @@ Linux cookie import shipped in v0.11.11.0 (Wave 3). Supports Chrome, Chromium, B
437437

438438
## Ship
439439

440+
### /ship Step 12 test harness should exec the actual template bash, not a reimplementation
441+
442+
**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).
443+
444+
**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.
445+
446+
**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.
447+
448+
**Effort:** S (human: ~2h / CC: ~30min)
449+
**Priority:** P2
450+
**Depends on:** None.
451+
452+
### /ship Step 12 BASE_VERSION silent fallback to 0.0.0.0 when git show fails
453+
454+
**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.
455+
456+
**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.
457+
458+
**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.
459+
460+
**Effort:** S (human: ~1h / CC: ~15min)
461+
**Priority:** P3
462+
**Depends on:** None.
463+
440464
### GitLab support for /land-and-deploy
441465

442466
**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.

ship/SKILL.md.tmpl

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -406,8 +406,10 @@ For each comment in `comments`:
406406
**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).
407407

408408
```bash
409-
BASE_VERSION=$(git show origin/<base>:VERSION 2>/dev/null || echo "0.0.0.0")
410-
CURRENT_VERSION=$(cat VERSION 2>/dev/null || echo "0.0.0.0")
409+
BASE_VERSION=$(git show origin/<base>:VERSION 2>/dev/null | tr -d '\r\n[:space:]' || echo "0.0.0.0")
410+
CURRENT_VERSION=$(cat VERSION 2>/dev/null | tr -d '\r\n[:space:]' || echo "0.0.0.0")
411+
[ -z "$BASE_VERSION" ] && BASE_VERSION="0.0.0.0"
412+
[ -z "$CURRENT_VERSION" ] && CURRENT_VERSION="0.0.0.0"
411413
PKG_VERSION=""
412414
PKG_EXISTS=0
413415
if [ -f package.json ]; then
@@ -496,7 +498,11 @@ fi
496498
**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.
497499

498500
```bash
499-
REPAIR_VERSION=$(cat VERSION)
501+
REPAIR_VERSION=$(cat VERSION | tr -d '\r\n[:space:]')
502+
if ! printf '%s' "$REPAIR_VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'; then
503+
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."
504+
exit 1
505+
fi
500506
if command -v node >/dev/null 2>&1; then
501507
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" || {
502508
echo "ERROR: drift repair failed — could not update package.json."

test/ship-version-sync.test.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ const idempotency = (base: string): { stdout: string; code: number } => {
3737
const script = `
3838
cd "${dir}" || exit 2
3939
BASE_VERSION="${base}"
40-
CURRENT_VERSION=$(cat VERSION 2>/dev/null || echo "0.0.0.0")
40+
CURRENT_VERSION=$(cat VERSION 2>/dev/null | tr -d '\\r\\n[:space:]' || echo "0.0.0.0")
41+
[ -z "$CURRENT_VERSION" ] && CURRENT_VERSION="0.0.0.0"
4142
PKG_VERSION=""
4243
PKG_EXISTS=0
4344
if [ -f package.json ]; then
@@ -97,7 +98,10 @@ fi`;
9798
const syncRepair = (): { code: number } => {
9899
const script = `
99100
cd "${dir}" || exit 2
100-
REPAIR_VERSION=$(cat VERSION)
101+
REPAIR_VERSION=$(cat VERSION | tr -d '\\r\\n[:space:]')
102+
if ! printf '%s' "$REPAIR_VERSION" | grep -qE '^[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+$'; then
103+
echo "invalid repair semver" >&2; exit 1
104+
fi
101105
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"`;
102106
try {
103107
execSync(script, { shell: "/bin/bash", stdio: "pipe" });
@@ -183,6 +187,28 @@ test("bump: no package.json is silent", () => {
183187
expect(existsSync(join(dir, "package.json"))).toBe(false);
184188
});
185189

190+
// --- Adversarial review regressions: trailing whitespace + invalid REPAIR_VERSION ---
191+
192+
test("trailing CR in VERSION does not cause false DRIFT_STALE_PKG", () => {
193+
// Before the tr-strip fix, VERSION="0.1.0.0\r" read via cat would mismatch
194+
// pkg.version="0.1.0.0" and classify as DRIFT_STALE_PKG, then repair would
195+
// write garbage \r into package.json. Now CURRENT_VERSION is stripped.
196+
writeFileSync(join(dir, "VERSION"), "0.1.0.0\r\n");
197+
writeFileSync(join(dir, "package.json"), pkgJson("0.1.0.0"));
198+
expect(idempotency("0.0.0.0")).toEqual({ stdout: "STATE: ALREADY_BUMPED", code: 0 });
199+
});
200+
201+
test("DRIFT REPAIR rejects invalid VERSION semver instead of propagating", () => {
202+
// If VERSION is corrupted/manually-edited to something non-semver, the
203+
// repair path must refuse rather than writing junk into package.json.
204+
writeFileSync(join(dir, "VERSION"), "not-a-semver\n");
205+
writeFileSync(join(dir, "package.json"), pkgJson("0.0.0.0"));
206+
const r = syncRepair();
207+
expect(r.code).toBe(1);
208+
// package.json must NOT have been overwritten with the garbage.
209+
expect(pkgVersion()).toBe("0.0.0.0");
210+
});
211+
186212
// --- THE critical regression test: drift-repair does NOT double-bump ---
187213

188214
test("DRIFT REPAIR: sync path syncs pkg to VERSION without re-bumping", () => {

0 commit comments

Comments
 (0)