fix(ci): publish prereleases under the beta dist-tag, not latest - #11
Conversation
publish.yml ran `npm publish --provenance --access public` with no --tag. npm writes the `latest` dist-tag unless told otherwise and does not route prerelease versions anywhere else on its own, so every -beta release so far became what a plain `npm install @ziptax/node-sdk` resolves to. The registry confirms it: the package has only a `latest` tag, pointing at 0.2.0-beta, and no `beta` tag at all. This contradicted CLAUDE.md, which claimed prereleases were published under `beta`. That has never been true in CI. It matters for v1.0.0-beta, which is a breaking release: on `latest` it would hand the rewritten merchant-endpoint API to every new consumer. The workflow now derives the dist-tag from the version in package.json, using the first prerelease identifier: 1.0.0 -> latest 1.0.0-beta -> beta 1.0.0-beta.0 -> beta 1.0.0-rc.1 -> rc Two guards on the derived value, because a bad dist-tag fails the publish outright: - lowercased, so 1.0.0-BETA does not create a second `BETA` tag - falls back to `beta` when the identifier does not start with a letter. `npm version prerelease` without --preid yields 0.2.4-0, and npm rejects a dist-tag that parses as a semver range, so `--tag 0` would fail. The chosen version and tag are echoed to the log and the job summary so a release can be audited without re-reading the workflow. CLAUDE.md updated to describe what the workflow actually does, and to note that manual publishes must pass --tag explicitly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| TAG=$(node -p " | ||
| const v = require('./package.json').version; | ||
| const m = /^[0-9]+\.[0-9]+\.[0-9]+-([0-9A-Za-z-]+)/.exec(v); | ||
| if (!m) { 'latest' } | ||
| else { | ||
| const id = m[1].toLowerCase(); | ||
| // A dist-tag must not parse as a semver range, so it has to start | ||
| // with a letter. Anything else falls back to the project default. | ||
| /^[a-z][a-z0-9-]*$/.test(id) ? id : 'beta'; | ||
| } | ||
| ") |
There was a problem hiding this comment.
📝 Info: Dist-tag derivation verified end-to-end, including shell quoting
I executed the exact node -p snippet from .github/workflows/publish.yml:63-73 against synthetic package.json files: 1.0.0-beta→beta, 1.0.0→latest, 1.0.0-0→beta, 1.0.0-rc.1→rc, 1.0.0-BETA→beta, 1.0.0+b7→latest. Two quoting hazards that would normally break this pattern are absent here: inside bash double quotes \. is preserved verbatim (backslash only escapes $, `, ", \, newline), and the $/ in /^[a-z][a-z0-9-]*$/ is not a valid parameter expansion so bash leaves it literal. node -p also prints the completion value of the trailing if/else statement, so the non-expression form works.
Was this helpful? React with 👍 or 👎 to provide feedback.
| // A dist-tag must not parse as a semver range, so it has to start | ||
| // with a letter. Anything else falls back to the project default. | ||
| /^[a-z][a-z0-9-]*$/.test(id) ? id : 'beta'; | ||
| } |
There was a problem hiding this comment.
🔍 Numeric-prerelease fallback silently reassigns the beta tag
A version like 1.0.0-0 or 1.0.0-1.2 falls back to beta (.github/workflows/publish.yml:71). That avoids an npm rejection, but it also means an accidentally-created bare prerelease (npm version prerelease without --preid) would move the beta dist-tag onto a version nobody intended to promote, and the publish would still succeed. Failing the step with a clear message might be preferable to silently repointing beta.
Was this helpful? React with 👍 or 👎 to provide feedback.
| /^[a-z][a-z0-9-]*$/.test(id) ? id : 'beta'; | ||
| } | ||
| ") | ||
| echo "version=$VERSION" >> "$GITHUB_OUTPUT" |
There was a problem hiding this comment.
📝 Info: version step output is written but never consumed
echo "version=$VERSION" >> "$GITHUB_OUTPUT" at .github/workflows/publish.yml:74 produces an output no later step reads (only steps.dist-tag.outputs.tag is used at .github/workflows/publish.yml:83). Harmless, but dead weight unless a follow-up step is planned.
Was this helpful? React with 👍 or 👎 to provide feedback.
| - name: Determine npm dist-tag | ||
| id: dist-tag |
There was a problem hiding this comment.
🔍 No version bump in this PR — requires the skip-version-check label
package.json stays at 0.2.3-beta (unchanged from the base commit) and CHANGELOG.md is untouched. CLAUDE.md and CONTRIBUTING.md require a version bump on every PR to main, with an explicit carve-out for CI/CD and docs-only changes when the skip-version-check label is applied. This PR only touches .github/workflows/publish.yml and CLAUDE.md, so it qualifies for the carve-out — but the label must actually be applied or the version-check job will fail.
Was this helpful? React with 👍 or 👎 to provide feedback.
Adds a dist-tags section to CLAUDE.md covering `npm dist-tag ls` for inspecting what the registry serves, and the `npm dist-tag add` form for promoting a prerelease to `latest` once it is published. Notes the two properties that make the promotion worth pausing over: it takes effect immediately for every consumer with no staging, and the version has to already exist on the registry. Also records that this must stay a manual per-release decision rather than moving into publish.yml. Automatically routing every release to `latest` is exactly the bug that made a -beta the default install. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The problem
publish.ymlrannpm publish --provenance --access publicwith no--tag. npm writes thelatestdist-tag unless told otherwise — it does not detect a-betasuffix and route it elsewhere.The registry confirms every prerelease so far went to
latest:There is no
betatag on the package at all.CLAUDE.mdclaimed "Prerelease versions (e.g.,-beta) are published under thebetadist-tag, notlatest" — that has never been true in CI.It matters now because #10 ships v1.0.0-beta, a breaking release that replaces the direct TaxCloud connection with Ziptax-proxied
/merchant/*endpoints. Onlatest, a plainnpm install @ziptax/node-sdkwould hand that to every new consumer.The fix
The workflow now derives the dist-tag from
package.json, using the first prerelease identifier:1.0.0latest1.0.0-betabeta1.0.0-beta.0beta1.0.0-rc.1rcTwo guards on the derived value, because an invalid dist-tag fails the publish outright rather than degrading:
1.0.0-BETAdoesn't quietly create a second, separateBETAtag.betawhen the identifier doesn't start with a letter. This one is a live footgun:npm version prereleasewithout--preidproduces0.2.4-0, and npm rejects a dist-tag that parses as a semver range — so--tag 0would fail the release.CLAUDE.mddocuments--preid=beta, but the bare form is easy to reach for.The chosen version and tag are echoed to the log and the job summary, so a release can be audited without re-reading the workflow.
Verification
Derivation exercised against real and edge-case versions:
No prerelease can reach
latest, and no input produces a tag npm would reject.I also extracted the step's
run:block straight out of the YAML and executed it withGITHUB_OUTPUT/GITHUB_STEP_SUMMARYpointed at temp files, against this branch's actualpackage.json:YAML parses cleanly and the step order is unchanged apart from the new step.
Review notes
Something else is wrong with releases, and it's not this. While checking the registry I found the published versions are only
0.1.2-beta,0.1.4-beta,0.2.0-beta. So:v0.2.2-betahas a GitHub Release and a git tag, but its publish run failed (run 23064188029, 2026-03-13) at thenpm publishstep. Tests, lint, type-check and build all passed. The logs have since expired (HTTP 410) so I can't see the error. I checked the obvious cause — the tag'spackage.jsonsaid0.2.2-beta, correctly, so it wasn't a version/tag mismatch or a republish conflict.v0.2.3-betawas never released at all — no tag, no GitHub Release, despite being merged and changelogged.Net effect: the publish path has been unproven since 2026-02-17. Worth confirming
NPM_TOKENis still valid before cutting v1.0.0-beta, since an expired automation token would produce exactly this signature. I didn't touch it — that's a credentials question for a maintainer, not something to guess at in a PR.Existing dist-tags are untouched.
lateststill points at0.2.0-beta. Moving it is immediate and affects every consumer, so I left it for a deliberate decision — see below.Drive-by: added the missing trailing newline to
publish.yml.Release sequence (decided)
latestwill be pointed at1.0.0-betaonce it is published, so a plainnpm install @ziptax/node-sdkresolves to the current SDK rather than staying on0.2.0-beta. Bothlatestandbetawill then reference the same version.Order matters:
NPM_TOKENis still valid — see the note above; the publish path hasnot succeeded since 2026-02-17.
v1.0.0-betaGitHub Release. The workflow publishes it underbeta; confirm via the "Determine npm dist-tag" step summary.Step 5 is intentionally manual and not part of
publish.yml. Automating it wouldre-create the bug this PR fixes. It takes effect immediately for every consumer,
and the version must already be published or the command fails.
CLAUDE.mdnowdocuments both points.
Note that this puts a breaking version on
latest. That is the deliberate callhere — every published version of this package has been a
-beta, andlatestalready points at one.
🤖 Generated with Claude Code