Skip to content

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

Open
yakimoto wants to merge 1 commit into
mainfrom
fix/ver-001-single-version-source
Open

fix(version): derive every CLI version surface from package.json + gate it in CI (VER-001)#46
yakimoto wants to merge 1 commit into
mainfrom
fix/ver-001-single-version-source

Conversation

@yakimoto

@yakimoto yakimoto commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

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:line literal
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

…-001)

Published @wave-av/cli@1.0.8 printed `1.0.0` from `wave --version`. The
`--version` half was fixed in 1.0.9, but two hardcoded literals survived on
the wire, so even a correct 1.0.9 release would have told the gateway it was
1.0.0:

  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"

The literal is the defect class, not the value. Bumping it to 1.0.9 would
reproduce the bug at the next release, so this removes it instead.

- 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`, because the dev
  layout (src/lib/version.ts) and the shipped bundle (dist/index.js) sit at
  different depths below the package root — a fixed relative path is correct
  in exactly one of them and silently wrong in the other.
- src/cli.ts, api-client.ts, commands/api/index.ts now consume it. Verified
  in the built bundle: zero `wave-cli/1.0.0` and zero hardcoded
  X-Wave-CLI-Version remain.
- src/cli.test.ts: three gates keyed on package.json — --version agreement,
  banner agreement, and a scan of src/ that fails on any new version literal
  outside a documented allowlist (config-file schema version, and the
  0.0.0-unknown sentinel).
- smoke-install.yml: adds a `unit` job so `npm test` runs on PRs at all
  (it previously ran ONLY in release.yml on a `v*` tag, so the version tests
  never gated a PR), and hardens the existing smoke step, which ran
  `wave --version` and discarded the output, to compare it and the banner
  against package.json.

release.yml is deliberately untouched — PRs #17 and #45 own that file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codeant-ai

codeant-ai Bot commented Sep 4, 2026

Copy link
Copy Markdown

Your free trial PR review limit of 300 PRs has been reached. Please upgrade your plan to continue using CodeAnt AI.

@qodo-code-review

Copy link
Copy Markdown

ⓘ Qodo reviews are paused because your workspace is out of credits. Ask your workspace admin to add credits to resume reviews. Manage billing

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @yakimoto, this account has used its review budget of 2,500,000 diff characters for the last 7 days.

You can request another review in 18 hours and 57 minutes by commenting @sourcery-ai review.

@coderabbitai

coderabbitai Bot commented Sep 4, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 42 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available. Your 91 included PR review attempts over the past 7 days set your current allowance at 1 review per hour.

Your organization has reached its usage spending cap. Adjust your spending cap in the billing tab.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Team

Run ID: 925152ea-8ceb-4b29-ba10-521de50714b0

📥 Commits

Reviewing files that changed from the base of the PR and between 70e0ad8 and 91093d5.

📒 Files selected for processing (6)
  • .github/workflows/smoke-install.yml
  • src/cli.test.ts
  • src/cli.ts
  • src/commands/api/index.ts
  • src/lib/api-client.ts
  • src/lib/version.ts

Comment @coderabbitai help to get the list of available commands.

@cursor

cursor Bot commented Sep 4, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_696bbdca-e30e-4d83-922c-02387a00e309)

@sourcery-ai

sourcery-ai Bot commented Sep 4, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR establishes package.json as the single source of truth for every CLI version surface, removes stale wire literals, adds source and packed-tarball regression checks, and makes those checks enforceable on pull requests through CI.

Sequence diagram for runtime version resolution and outbound headers

sequenceDiagram
    participant CLI as CLI process
    participant Version as version.ts
    participant Package as nearest package.json
    participant Gateway as Gateway

    CLI->>Version: readOwnVersion()
    Version->>Package: walk upward and read version
    Package-->>Version: version
    Version-->>CLI: CLI_VERSION
    CLI->>Gateway: request with X-Wave-CLI-Version: CLI_VERSION
    CLI->>Gateway: request with User-Agent: cliUserAgent()
Loading

Flow diagram for the VER-001 pull request gate

flowchart TD
    A[Pull request] --> B[Build and install dependencies]
    B --> C[npm test\nsource version gate]
    C --> D[Pack and install tarball]
    D --> E[Compare wave --version to package.json]
    E --> F[Clear banner-suppressing environment variables]
    F --> G[Compare help banner to v plus package.json version]
    G --> H{All surfaces agree?}
    H -- Yes --> I[Pass]
    H -- No --> J[Fail CI with VER-001 error]
Loading

File-Level Changes

Change Details Files
Centralize CLI version resolution and replace runtime literals across user-facing and outbound surfaces.
  • Add an upward package.json resolver with CLI_VERSION, UNKNOWN_VERSION, and cliUserAgent().
  • Use the shared values for --version/help output and API/User-Agent headers.
  • Leave config schema and scaffold package versions intentionally independent.
src/lib/version.ts
src/cli.ts
src/lib/api-client.ts
src/commands/api/index.ts
Add regression coverage that detects both version drift and reintroduced hardcoded literals.
  • Verify package.json, --version, help banner, and outbound User-Agent agreement.
  • Scan non-test TypeScript sources for semantic-version literals with a documented allowlist.
  • Exercise banner checks with environment-based suppression disabled.
src/cli.test.ts
Make version truth a pull-request CI gate and validate the packed installation path.
  • Add a PR-runnable unit job executing build and npm test.
  • Compare installed tarball output for --version and help banner against package.json with exact failure checks.
  • Ensure the smoke test validates the depth-independent package.json resolution in a clean install.
.github/workflows/smoke-install.yml

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@macroscopeapp

macroscopeapp Bot commented Sep 4, 2026

Copy link
Copy Markdown

Approvability

Verdict: Would Approve

Macroscope's review found this PR approvable — This is a focused version-consistency fix that centralizes package.json version usage across CLI output and outbound metadata, with tests covering source and packed-install behavior. The remaining changes only strengthen CI enforcement, and all changed files are within the supplied author ownership scope.

Not approved because:

  • Credit balance exhausted. Approvability relies on correctness review in order to determine eligibility

Review your spending limits in Billing settings. You can add or adjust custom eligibility rules. Learn more.

Comment thread src/lib/version.ts
Comment on lines +27 to +41
try {
let dir = dirname(fileURLToPath(import.meta.url));
const { root } = parse(dir);

for (;;) {
const candidate = join(dir, "package.json");
if (existsSync(candidate)) {
const pkg = JSON.parse(readFileSync(candidate, "utf-8")) as { version?: unknown };
if (typeof pkg.version === "string" && pkg.version.length > 0) {
return pkg.version;
}
}

if (dir === root) break;
const parent = dirname(dir);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Edge Case: A malformed ancestor package.json aborts the walk-up entirely

The try/catch wraps the whole walk-up loop, so if any ancestor directory (not the CLI's own) contains a package.json with invalid JSON — plausible when installed nested inside another tool's node_modules or a monorepo workspace — JSON.parse throws and the function immediately returns UNKNOWN_VERSION instead of continuing to walk further up to the real package.json. Move the JSON.parse/read into a per-iteration try/catch (continue the loop on parse failure) so only a fatal error before the loop starts (e.g. fileURLToPath failing) falls through to the outer catch.

Catch parse errors per-directory so one bad ancestor package.json doesn't abort the whole resolution.:

for (;;) {
  const candidate = join(dir, "package.json");
  if (existsSync(candidate)) {
    try {
      const pkg = JSON.parse(readFileSync(candidate, "utf-8")) as { version?: unknown };
      if (typeof pkg.version === "string" && pkg.version.length > 0) {
        return pkg.version;
      }
    } catch {
      // malformed package.json at this level; keep walking up
    }
  }
  if (dir === root) break;
  const parent = dirname(dir);
  if (parent === dir) break;
  dir = parent;
}
  • Apply fix

Check the box to apply the fix or reply for a change | Was this helpful? React with 👍 / 👎

Comment thread src/commands/api/index.ts
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"User-Agent": "wave-cli/1.0.0",
"User-Agent": cliUserAgent(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Quality: No direct test asserts the outbound header values at the call sites

cliUserAgent() and CLI_VERSION are unit-tested in isolation, but no test asserts that the actual User-Agent / X-Wave-CLI-Version headers built in commands/api/index.ts and lib/api-client.ts use them (e.g. via a mocked client/fetch). This is low-risk given the trivial substitution, but a quick assertion on the constructed headers object in each call site would close the gap the incident was specifically about (headers silently drifting from the printed version).

Was this helpful? React with 👍 / 👎

@gitar-bot

gitar-bot Bot commented Sep 4, 2026

Copy link
Copy Markdown

Note

Automatic reviews are paused because your team has used its included automatic processing for this billing period (headroom scales with your seat count). You can still comment "Gitar review" to run one anytime, and automatic reviews resume on their own by October 1. Add seats for more headroom.
Learn more

Code Review 👍 Approved with suggestions 0 resolved / 2 findings

Centralizes CLI versioning in new src/lib/version.ts with walk-up to the nearest package.json, ensuring --version, help banner, X-Wave-CLI-Version, and User-Agent stay synchronized. Adds comprehensive version-truth gates in src/cli.test.ts and hardens smoke-install.yml to run npm test on PRs and assert agreement between installed CLI output and package.json. Consider moving the JSON.parse call in the walk-up loop into a per-iteration try/catch so a malformed ancestor package.json doesn't abort the search entirely, and add direct assertions on the constructed User-Agent and X-Wave-CLI-Version headers at their call sites to close the gap the incident was specifically about.

💡 Edge Case: A malformed ancestor package.json aborts the walk-up entirely

📄 src/lib/version.ts:27-41

The try/catch wraps the whole walk-up loop, so if any ancestor directory (not the CLI's own) contains a package.json with invalid JSON — plausible when installed nested inside another tool's node_modules or a monorepo workspace — JSON.parse throws and the function immediately returns UNKNOWN_VERSION instead of continuing to walk further up to the real package.json. Move the JSON.parse/read into a per-iteration try/catch (continue the loop on parse failure) so only a fatal error before the loop starts (e.g. fileURLToPath failing) falls through to the outer catch.

Catch parse errors per-directory so one bad ancestor package.json doesn't abort the whole resolution.
for (;;) {
  const candidate = join(dir, "package.json");
  if (existsSync(candidate)) {
    try {
      const pkg = JSON.parse(readFileSync(candidate, "utf-8")) as { version?: unknown };
      if (typeof pkg.version === "string" && pkg.version.length > 0) {
        return pkg.version;
      }
    } catch {
      // malformed package.json at this level; keep walking up
    }
  }
  if (dir === root) break;
  const parent = dirname(dir);
  if (parent === dir) break;
  dir = parent;
}
💡 Quality: No direct test asserts the outbound header values at the call sites

📄 src/commands/api/index.ts:35 📄 src/lib/api-client.ts:50

cliUserAgent() and CLI_VERSION are unit-tested in isolation, but no test asserts that the actual User-Agent / X-Wave-CLI-Version headers built in commands/api/index.ts and lib/api-client.ts use them (e.g. via a mocked client/fetch). This is low-risk given the trivial substitution, but a quick assertion on the constructed headers object in each call site would close the gap the incident was specifically about (headers silently drifting from the printed version).

🤖 Prompt for agents
Code Review: Centralizes CLI versioning in new `src/lib/version.ts` with walk-up to the nearest `package.json`, ensuring `--version`, help banner, `X-Wave-CLI-Version`, and `User-Agent` stay synchronized. Adds comprehensive version-truth gates in `src/cli.test.ts` and hardens `smoke-install.yml` to run `npm test` on PRs and assert agreement between installed CLI output and `package.json`. Consider moving the `JSON.parse` call in the walk-up loop into a per-iteration try/catch so a malformed ancestor package.json doesn't abort the search entirely, and add direct assertions on the constructed `User-Agent` and `X-Wave-CLI-Version` headers at their call sites to close the gap the incident was specifically about.

1. 💡 Edge Case: A malformed ancestor package.json aborts the walk-up entirely
   Files: src/lib/version.ts:27-41

   The try/catch wraps the whole walk-up loop, so if any ancestor directory (not the CLI's own) contains a package.json with invalid JSON — plausible when installed nested inside another tool's node_modules or a monorepo workspace — `JSON.parse` throws and the function immediately returns `UNKNOWN_VERSION` instead of continuing to walk further up to the real package.json. Move the `JSON.parse`/read into a per-iteration try/catch (continue the loop on parse failure) so only a fatal error before the loop starts (e.g. `fileURLToPath` failing) falls through to the outer catch.

   Fix (Catch parse errors per-directory so one bad ancestor package.json doesn't abort the whole resolution.):
   for (;;) {
     const candidate = join(dir, "package.json");
     if (existsSync(candidate)) {
       try {
         const pkg = JSON.parse(readFileSync(candidate, "utf-8")) as { version?: unknown };
         if (typeof pkg.version === "string" && pkg.version.length > 0) {
           return pkg.version;
         }
       } catch {
         // malformed package.json at this level; keep walking up
       }
     }
     if (dir === root) break;
     const parent = dirname(dir);
     if (parent === dir) break;
     dir = parent;
   }

2. 💡 Quality: No direct test asserts the outbound header values at the call sites
   Files: src/commands/api/index.ts:35, src/lib/api-client.ts:50

   `cliUserAgent()` and `CLI_VERSION` are unit-tested in isolation, but no test asserts that the actual `User-Agent` / `X-Wave-CLI-Version` headers built in `commands/api/index.ts` and `lib/api-client.ts` use them (e.g. via a mocked client/fetch). This is low-risk given the trivial substitution, but a quick assertion on the constructed `headers` object in each call site would close the gap the incident was specifically about (headers silently drifting from the printed version).

Options

Display: compact → Showing less information.

Comment with these commands to change the behavior for this request:

Compact
gitar display:verbose         

Was this helpful? React with 👍 / 👎 | Gitar

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant