How a kit release is cut, what the publish job refuses to publish, and the migration
away from a long-lived npm token. For the consumer side — verifying a release you did
not build — see docs/VERIFY.md.
One tag. .github/workflows/publish.yml triggers on v* and builds the tree that tag
points at, publishing 14 packages: the sandstream-kit CLI, sandstream-kit-adapter-sdk,
and the twelve sandstream-kit-plugin-* workspaces.
Because the tag's tree is what ships, the tag goes on the commit whose package.json and
CHANGELOG.md describe the release — not on whatever main happens to be. When feature
work merges after the release-prep commit, that work waits for the next version rather
than shipping under a version whose changelog does not mention it.
# 1. Prepare: bump package.json, write the CHANGELOG section, merge that PR.
# 2. Tag the prep commit itself (signed — the job verifies the signature):
git tag -s v6.6.4 <prep-commit> -m "v6.6.4 — <one line>"
git push origin v6.6.4
# 3. Approve the `npm-publish` environment when GitHub asks.Each of these fails the release rather than shipping something unverifiable:
- the tag does not match
package.json's version; CHANGELOG.mdhas no section for that version (checked before the publish, so an undocumented release aborts while aborting is still free);- the tag is unsigned, or signed by a key whose fingerprint is not the one pinned in the
MAINTAINER_KEY_FPRsecret (the in-repo public key alone is not trusted — a tag pusher could swap it); npm audit --audit-level=highfinds a high CVE;- the bumblebee supply-chain catalog matches anything, or could not run;
- the test suite fails, or the production build fails;
- a plugin's
sandstream-kit-adapter-sdkpeer range does not admit the SDK version being published, or the SDK major has drifted off its frozen1.xcontract.
A prerelease version (any - identifier) publishes under the next dist-tag, so latest
only ever moves on a stable release.
There is no npm token in this repository any more. The packages that shipped before
sandstream-kit-plugin-aisle carry a GitHub Actions trusted publisher naming
sandstream/kit, the workflow file publish.yml, and the environment npm-publish,
with the single permission npm publish (not staged publish — least privilege).
sandstream-kit-plugin-aisle needs the same npm-side Trusted Publisher setup before
the first release that includes it. The job exchanges its OIDC identity for a
short-lived credential, so the environment is part of the credential rather than
merely the guard around a secret:
configure required reviewers on npm-publish in Settings → Environments, or the human gate
does not exist.
Two constraints keep it working:
- Keep the publish job triggered directly by the tag push. npm's validation reads the
calling workflow's name, so putting the publish behind
workflow_callbreaks the check. - Never re-add
NODE_AUTH_TOKEN. npm prefers a token whenever one is present, so adding one silently moves publishing back onto the credential npm is retiring.src/publish-workflow.test.tsfails if any executing line in the workflow carries one.
--provenance stays on the publish commands. Provenance is automatic under trusted
publishing, so the flag is a no-op there and states the intent explicitly.
Token publishing is disallowed (2026-08-19, after 6.6.5-rc.1 proved the OIDC path). Every package's "Publishing access" is set to require two-factor authentication and disallow bypass 2fa tokens, which is what turns "we also have OIDC" into "only OIDC can publish". Each setting was read back from its own settings page.
Consequences worth knowing before the next release:
- There is no token fallback left. If a package's trusted publisher is misconfigured, its publish step fails and the fix is to correct the publisher in npm's UI — not to publish with a token. The workspace loop is idempotent, so re-running the job after the fix completes the set.
- The CLI path for these settings needs TOTP.
npm access set mfa=publish <pkg>sets the same thing without the web UI, but it returnsEOTPand--otp=<code>needs an authenticator app. A security-key-only account cannot use it, and npm does not offer adding TOTP alongside an existing security key — only disabling 2FA and re-enrolling, which is not worth doing for this. - npm's step-up 2FA is flaky under repetition. Expect saves to hang on "Verifying…" or
return "Something went wrong. Please try again later." Nothing half-saves; re-select the
radio and submit again. Select the value FIRST and submit only when you are ready to touch
the key — the prompt expires in about a minute, and
Update Package Settingsdoes nothing at all if the radio has not actually changed.
npm is retiring 2FA-bypass granular access tokens as a publishing credential:
| Phase | When | Effect |
|---|---|---|
| 1 | ~August 2026 | such a token no longer skips 2FA for sensitive operations (token create/delete, package access, maintainer/org/team changes). Publishing still works. |
| 2 | ~January 2027 | such a token cannot publish directly. It can read private packages and stage a publish for human 2FA approval. |
The migration target is trusted publishing: GitHub Actions exchanges its OIDC identity for a short-lived credential, so there is no long-lived npm secret in the repository at all.
Prerequisite, already in place. Trusted publishing exists only in npm ≥ 11.5.1 on
node ≥ 22.14.0. actions/setup-node ships npm 10.9.x for node 22, so the job installs
npm@^11.5.1 explicitly before doing anything else. src/publish-workflow.test.ts asserts
that step exists, pins a high-enough version, and runs before any publish — a comment cannot
fail CI, and a publish job runs only on a tag push, the worst moment to discover a too-old
client.
The registry-side work is done for the pre-AISLE packages (2026-08-19): every package below was configured and its saved connection read back from its own settings page. The list is kept because a NEW package needs the same treatment before its first release — no token exists to fall back on.
| Package | ||
|---|---|---|
sandstream-kit |
sandstream-kit-adapter-sdk |
sandstream-kit-plugin-cloudflare |
sandstream-kit-plugin-fly |
sandstream-kit-plugin-github |
sandstream-kit-plugin-railway |
sandstream-kit-plugin-sentrux |
sandstream-kit-plugin-sentry |
sandstream-kit-plugin-snyk |
sandstream-kit-plugin-stripe |
sandstream-kit-plugin-supabase |
sandstream-kit-plugin-vercel |
sandstream-kit-plugin-wiz |
sandstream-kit-plugin-aisle is intentionally not in the verified list yet. Configure
its npm Trusted Publisher before the release that first publishes it, then move it into
the verified table in that same release-prep PR.
For each (and for any new package): its npm page → Settings → Trusted Publisher → GitHub
Actions, then Organization or user = sandstream, Repository = kit,
Workflow filename = publish.yml (filename only, no path), Environment name =
npm-publish, Allowed actions = npm publish only. Naming the environment is the part
worth not skipping: it is what limits credential minting to the reviewer-gated job.
npm requires step-up 2FA (security key) to save each one, and it does NOT keep the session elevated — expect one key tap per package, and expect a save to be lost if the prompt times out. Nothing half-saves, so a retry is free. Rapid automation of the UI also trips Cloudflare's bot check, which only a human can clear.
Self-hosted runners are not supported by trusted publishing; this job runs on
ubuntu-latest, which is.
scripts/trusted-publishing-wizard.sh walks all of it. It derives the package list from
this repo rather than carrying a copy (a hardcoded list drifts the moment someone adds a
plugin, and a missed package is the one failure this migration must not have: no token and
no trusted publisher means that package's publish step fails), records what you confirm in
.kit/trusted-publishing.state so you can stop and resume, refuses to remove the token until
every package is recorded, and ends by pointing at a prerelease as the only honest test. Run
it from the repo root:
./scripts/trusted-publishing-wizard.shIt cannot verify the npmjs.com side for you — npm access on npm 11.19.0 covers
status/collaborators/grant/revoke and has no read or write path for a trusted publisher, so
the setting exists only in the web UI. What the wizard verifies is everything else: the
workflow file, the npm client floor, the environment's existence, and the package list.
The workflow change that followed was a deletion: NODE_AUTH_TOKEN is gone from both publish
steps, and id-token: write was already granted. Keep a read-only granular token only if a
private dependency ever needs installing. If the first OIDC publish ever rejects
--provenance, dropping that flag is a one-line follow-up — kept deliberately so the release
path changed one thing, not two.
Prove it with a prerelease, not with the next real version. publish.yml routes any version
containing a hyphen to the next dist-tag, so latest does not move. Bump to
X.Y.Z-rc.1, give it its own ## [X.Y.Z-rc.1] CHANGELOG section (the job refuses to publish
a version the changelog does not document, and it checks that before publishing), tag the
release commit signed, and push. If one package fails with 404/403, its trusted publisher is
missing or its fields do not match — the workspace loop is idempotent, so fix that package and
re-run the job; the ones already published are skipped.
Verify the first OIDC release the same way as any other: npm view sandstream-kit version,
install the published tarball and run its binary, and confirm both SBOMs are attached to the
GitHub release. npm audit signatures should still report a verified registry signature and
provenance.
- Confirm the published version really runs, not merely that the job was green:
npm i sandstream-kit@<version>in a scratch directory, thenkit --version. - Check the GitHub release carries
sbom.cyclonedx.jsonandsbom.spdx.json. - If the attest step warned, the release has npm provenance but no GitHub artifact
attestation —
gh attestation verifywill find nothing for it. That is a warning, not a failed release, and it is worth re-running rather than leaving silently unattested.