Skip to content

fix(ci): publish prereleases under the beta dist-tag, not latest - #11

Merged
ericlakich merged 2 commits into
mainfrom
fix/publish-beta-dist-tag
Aug 7, 2026
Merged

fix(ci): publish prereleases under the beta dist-tag, not latest#11
ericlakich merged 2 commits into
mainfrom
fix/publish-beta-dist-tag

Conversation

@ericlakich

@ericlakich ericlakich commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Merge this before creating the v1.0.0-beta release tag. The publish workflow reads from main at release time, so if the release is cut first, 1.0.0-beta lands on latest and the fix arrives too late.

The problem

publish.yml ran npm publish --provenance --access public with no --tag. npm writes the latest dist-tag unless told otherwise — it does not detect a -beta suffix and route it elsewhere.

The registry confirms every prerelease so far went to latest:

$ npm dist-tag ls @ziptax/node-sdk
latest: 0.2.0-beta

There is no beta tag on the package at all. CLAUDE.md claimed "Prerelease versions (e.g., -beta) are published under the beta dist-tag, not latest" — 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. On latest, a plain npm install @ziptax/node-sdk would hand that to every new consumer.

The fix

The workflow now derives the dist-tag from package.json, using the first prerelease identifier:

version dist-tag
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 an invalid dist-tag fails the publish outright rather than degrading:

  • Lowercased, so 1.0.0-BETA doesn't quietly create a second, separate BETA tag.
  • Falls back to beta when the identifier doesn't start with a letter. This one is a live footgun: npm version prerelease without --preid produces 0.2.4-0, and npm rejects a dist-tag that parses as a semver range — so --tag 0 would fail the release. CLAUDE.md documents --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:

1.0.0-beta           -> beta
1.0.0-beta.0         -> beta
0.2.3-beta           -> beta
1.0.0                -> latest
2.13.4               -> latest
1.0.0-rc.1           -> rc
1.0.0-alpha.2        -> alpha
1.0.0-BETA           -> beta      (lowercased)
10.20.30-beta.5      -> beta
1.0.0+build.7        -> latest    (build metadata is not a prerelease)
1.0.0-0              -> beta      (numeric identifier, would be rejected)
1.0.0-1.2            -> beta
1.0.0-next-1         -> next-1

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 with GITHUB_OUTPUT/GITHUB_STEP_SUMMARY pointed at temp files, against this branch's actual package.json:

Publishing 0.2.3-beta under dist-tag 'beta'
--- GITHUB_OUTPUT ---
version=0.2.3-beta
tag=beta

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-beta has a GitHub Release and a git tag, but its publish run failed (run 23064188029, 2026-03-13) at the npm publish step. 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's package.json said 0.2.2-beta, correctly, so it wasn't a version/tag mismatch or a republish conflict.
  • v0.2.3-beta was 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_TOKEN is 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. latest still points at 0.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)

latest will be pointed at 1.0.0-beta once it is published, so a plain
npm install @ziptax/node-sdk resolves to the current SDK rather than staying on
0.2.0-beta. Both latest and beta will then reference the same version.

Order matters:

  1. Merge this PR.
  2. Verify NPM_TOKEN is still valid — see the note above; the publish path has
    not succeeded since 2026-02-17.
  3. Merge #10 (v1.0.0-beta).
  4. Create the v1.0.0-beta GitHub Release. The workflow publishes it under
    beta; confirm via the "Determine npm dist-tag" step summary.
  5. Promote it to the default install:
npm dist-tag add @ziptax/node-sdk@1.0.0-beta latest
  1. Confirm:
npm dist-tag ls @ziptax/node-sdk

Step 5 is intentionally manual and not part of publish.yml. Automating it would
re-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.md now
documents both points.

Note that this puts a breaking version on latest. That is the deliberate call
here — every published version of this package has been a -beta, and latest
already points at one.

🤖 Generated with Claude Code

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>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Devin Review found 4 potential issues.

Open in Devin Review

Comment on lines +63 to +73
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';
}
")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📝 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-betabeta, 1.0.0latest, 1.0.0-0beta, 1.0.0-rc.1rc, 1.0.0-BETAbeta, 1.0.0+b7latest. 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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +69 to +72
// 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';
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔍 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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

/^[a-z][a-z0-9-]*$/.test(id) ? id : 'beta';
}
")
echo "version=$VERSION" >> "$GITHUB_OUTPUT"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📝 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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +50 to +51
- name: Determine npm dist-tag
id: dist-tag

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔍 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.

Open in Devin Review

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>
@ericlakich
ericlakich merged commit 97cc096 into main Aug 7, 2026
13 checks passed
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