fix(version): derive every CLI version surface from package.json + gate it in CI (VER-001)

Merged
YA
2mo ago
+285-37

VER-001 / ART-001 — CLI version truth

Criterion: package.json version == wave --version == help banner == outbound version headers, enforced by a gate that fails on disagreement.

The defect

Published @wave-av/cli@1.0.8 prints 1.0.0. Reproduced in a clean room against the real registry:

$ npm i @wave-av/cli@1.0.8 --registry=https://registry.npmjs.org/
$ node -p "require('./node_modules/@wave-av/cli/package.json').version"   -> 1.0.8
$ ./node_modules/.bin/wave --version                                      -> 1.0.0

The --version half was fixed on main (package.json is now 1.0.9), but two hardcoded literals survived on the wire, so even a correct 1.0.9 release would still have told the gateway it was 1.0.0:

file:lineliteral
src/lib/api-client.ts:49"X-Wave-CLI-Version": "1.0.0"
src/commands/api/index.ts:34"User-Agent": "wave-cli/1.0.0"

Both were confirmed present in the built bundle of both the published 1.0.8 tarball and a fresh 1.0.9 pack.

Separately, the version tests never ran on a PR. git grep -n 'npm test' -- .github on main returns exactly one hit — release.yml:67, which is on: push: tags: v*. smoke-install.yml does run on PRs, but its version step ran npx wave --version and discarded the output, so a build printing any version at all passed it. That is precisely how 1.0.8 shipped.

The fix

A hardcoded version literal is the defect class, not the value — updating it to 1.0.9 reproduces the bug at the next release. So the literals are removed, not corrected.

  • NEW src/lib/version.ts — the single source of truth. CLI_VERSION and cliUserAgent() derive from package.json. Resolution walks up to the nearest package.json rather than reading a fixed ../package.json: in development this module is src/lib/version.ts (two levels below the package root) while the shipped bundle is dist/index.js (one level below it), so a fixed relative path is correct in exactly one layout and silently wrong in the other. This is verified against a real installed tarball below, not assumed.
  • src/cli.ts, src/lib/api-client.ts, src/commands/api/index.ts all consume it. Post-build bundle check: grep -c 'wave-cli/1\.0\.0' dist/index.js -> 0, and X-Wave-CLI-Version now reads CLI_VERSION.
  • Deliberately not touched: src/lib/config/schema.ts:11 (that "1.0.0" is the on-disk config file schema version and must not track releases) and templates/*/package.json (scaffold output).
  • No version bump. package.json stays at 1.0.9, which is built and unpublished; bumping would strand another number.

The proving gate

Three arms, each demonstrated failing before passing.

1. Source gate — src/cli.test.ts. 11 tests -> 15. Adds --version agreement, banner agreement (via the only reachable path — clear the env vars detectEnvironment() keys on, then program.helpInformation()), a cliUserAgent() assertion, and a scan of src/**/*.ts that fails on any x.y.z literal outside a documented, reviewable allowlist.

Injecting the original defect back makes it fail with exactly the two real offenders:

FAIL src/cli.test.ts > VER-001: no hardcoded version literals under src/
+ [
+   "commands/api/index.ts:35 \"User-Agent\": \"wave-cli/1.0.0\",",
+   "lib/api-client.ts:50 \"X-Wave-CLI-Version\": \"1.0.0\",",
+ ]

Drifting only the banner fails it independently: AssertionError: expected '1.0.0' to be '1.0.9'.

Restored: Test Files 4 passed (4) / Tests 15 passed (15).

2. End-to-end — real packed tarball, clean-room install. This is what proves the depth-independent package.json walk actually works from node_modules/@wave-av/cli/dist/index.js:

package.json   = 1.0.9
wave --version = 1.0.9
help banner    = v1.0.9
VER-001 PASS: package.json == --version == banner == 1.0.9

3. CI gate — .github/workflows/smoke-install.yml. Adds a unit job so npm test runs on PRs at all, and rewrites the version step to compare against package.json. I executed the step's actual shell (extracted from the YAML) against three builds:

  • fixed build -> VER-001: package.json == --version == banner == 1.0.9, exit 0
  • the exact 1.0.8 defect injected -> ::error::VER-001: installed CLI reports '1.0.0' but package.json says '1.0.9', exit 1
  • banner-only drift -> ::error::VER-001: help banner reported 'v1.0.0', expected 'v1.0.9', exit 1

One subtlety worth review: the CLI suppresses its banner under CI/agent env vars, so the banner check clears all eleven that detectEnvironment() reads. Without that it would assert against a banner that was never printed and pass for the wrong reason. It compares the extracted version for equality rather than grepping for a substring, so a missing banner fails rather than quietly passing.

Scope / collisions

.github/workflows/release.yml is deliberately untouched — open PRs #17 and #45 both own it. Two release-side items therefore belong to those PRs, not this one:

  1. release.yml publishes to npm but has no gh release create step, which is why GitHub Releases stop at v1.0.0 while eight npm versions exist (git ls-remote --tags origin returns exactly one tag). It should add gh release create "$GITHUB_REF_NAME" --generate-notes --verify-tag with contents: write scoped to that job.
  2. release.yml:82-88 already compares --version to package.json; the banner half added here is worth mirroring there.

The CHANGELOG's own compare links currently 404 as a result (compare/v1.0.8...v1.0.9 -> 404).

Verified / not verified

  • Verified: all output above was run locally in an isolated worktree off origin/main (70e0ad8). Type-check error count is 148 on this branch and 148 at origin/main — unchanged, none in any file this PR touches, and tsc is not wired into any workflow. npm run lint fails identically at origin/main (no eslint.config.* in the repo) — pre-existing, untouched here.
  • Verified on real CI (this PR's own run, 33821620422) — the new unit job and the hardened smoke step both passed, on Node 20 and Node 22:
unit                    Test Files  4 passed (4)
                             Tests  15 passed (15)

smoke (20) / smoke (22) package.json=1.0.9  wave --version=1.0.9
                        VER-001: package.json == --version == banner == 1.0.9

The banner half passing on GitHub Actions is the notable one: it confirms the env-var clearing works on a real runner, where the banner is otherwise suppressed.

  • UNVERIFIED: nothing outstanding in this PR's own scope. The release-side items in "Scope / collisions" above are unverified by construction — they belong to release.yml, which this PR does not touch.

Publishing (operator, named-floor — not done here)

This PR does not publish and does not tag. npm latest remains 1.0.8, so the fix is not live for users until 1.0.9 is published. Blocker from recon, re-confirmed: gh secret list --repo wave-av/cli shows only OPENAI_KEY — no NPM_TOKEN — so release.yml's token fallback is inert and it depends entirely on npm OIDC trusted publishing, which I could not verify is registered.

Note for the operator: the Doppler NPM_TOKEN could not publish @wave-av packages. A registry 404 on PUT means the token lacks org publish rights, not a missing package — do not read that 404 as "the package does not exist."

After merge, one of:

# (A) register the trusted publisher on npmjs.com for @wave-av/cli -> wave-av/cli -> .github/workflows/release.yml
# (B) gh secret set NPM_TOKEN --repo wave-av/cli   (pipe the value; never pass it in argv)

# then, with tag ruleset 13298944 "tag-protection" active (may need operator rights):
git -C ~/wave-av/cli tag -s v1.0.9 -m 'v1.0.9' <post-merge-main-sha>
git -C ~/wave-av/cli push origin v1.0.9

Rollback

git revert 91093d5. The change is source-only plus one workflow file; it adds no dependency, alters no runtime behaviour beyond the reported version string, and touches no published artifact. Reverting restores the two literals and the previous CI step exactly.

🤖 Generated with Claude Code