stage the npm release rather than publishing it

npm is withdrawing direct publish from every token that bypasses 2FA:
management operations went in July, publishing goes in January, after
which such a token may only read private packages and stage a publish.
Its trusted publishing is OIDC against GitHub Actions and GitLab, which
this forge is neither, so there is no credential-free path here either.

So the runner does the part a token is still allowed to do. It stages the
tarball and stops; nothing is on the registry until a maintainer approves
it with a code, from `npm stage approve` or the Staged Packages tab. The
forge ends up holding a credential that can propose a release and cannot
make one, which is a better arrangement than the one being taken away.

The secret it wants is therefore an ordinary granular token scoped to
this package, explicitly without 2FA bypass.

Node comes from nodesource now: `npm stage` wants npm 11.15 on Node
22.14, and bookworm ships 18.
This commit is contained in:
funkpower
2026-09-18 15:15:10 -04:00
parent 364dd0afdf
commit 464e37c35b
2 changed files with 56 additions and 17 deletions
+23 -13
View File
@@ -61,7 +61,11 @@ jobs:
- name: Build the npm package
run: |
apt-get install -y --no-install-recommends nodejs npm
# Node from nodesource rather than apt. `npm stage` below wants npm
# 11.15 and Node 22.14, and bookworm ships Node 18.
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y --no-install-recommends nodejs
npm install --global npm@^11.15
rustup target add wasm32-unknown-unknown
version=$(awk '/^name = "wasm-bindgen"$/{getline; gsub(/[",]/,""); print $3; exit}' Cargo.lock)
curl -sSfL "https://github.com/rustwasm/wasm-bindgen/releases/download/$version/wasm-bindgen-$version-x86_64-unknown-linux-musl.tar.gz" \
@@ -91,22 +95,28 @@ jobs:
# so a half-published release would otherwise burn it.
/tmp/venv/bin/twine upload --non-interactive --skip-existing dist/*
- name: Publish to npm
# Staged, not published. npm is withdrawing direct publish from any token
# that bypasses 2FA, and its trusted publishing is OIDC against GitHub
# Actions and GitLab, which this forge is neither. So the runner does the
# part a token may do and stages the tarball, and a maintainer approves it
# with 2FA: `npm stage list`, then `npm stage approve <id>`, or the Staged
# Packages tab on npmjs.com. Nothing reaches the registry until then.
- name: Stage the npm release
env:
# Scoped to this package, for the same reason the PyPI token is
# scoped to this project: npm has trusted publishing too, and it does
# not know this forge either.
# Granular, write on typikon alone, and it does not need to bypass
# 2FA, because staging is exactly what a token that cannot bypass it
# is still allowed to do.
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
if [ -z "$NPM_TOKEN" ]; then
echo "::warning::NPM_TOKEN is not set; skipping the npm publish"
echo "::warning::NPM_TOKEN is not set; nothing staged on npm"
exit 0
fi
echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/.npmrc
# A version cannot be reused on npm either, so a re-run after a
# partial release must not die on the version already being there.
npm publish --access public npm/dist || {
npm view "typikon@${GITHUB_REF_NAME#v}" version >/dev/null 2>&1 \
&& echo "::notice::already published" \
|| exit 1
}
version="${GITHUB_REF_NAME#v}"
if npm view "typikon@$version" version >/dev/null 2>&1; then
echo "::notice::typikon@$version is already published"
exit 0
fi
cd npm/dist && npm stage publish --access public
echo "::notice::staged typikon@$version; approve it with 2FA to publish"
+33 -4
View File
@@ -97,10 +97,20 @@ Also under **funkpower**, also scoped to the one package, added as the
repository secret `NPM_TOKEN`. Make it a **granular access token** with write
on `typikon` alone; a classic automation token is account-wide.
npm has trusted publishing too, and it does not know this forge either, so the
same tradeoff applies. Without the secret the release still succeeds and the
npm step warns and skips, so the first release of a version after the token
lapses is a PyPI release and nothing else. Check the run.
It does **not** need "bypass 2FA", and should not have it. npm is withdrawing
direct publish from every token that bypasses 2FA: management operations went
in July 2026, publishing goes in January 2027, after which such a token may
only read private packages and stage a publish. Staging is what this workflow
does, so an ordinary granular token is both sufficient and the one that
survives.
npm has trusted publishing too, and like PyPI's it is OIDC against GitHub
Actions and GitLab, which this forge is neither. So the forge holds a
long-lived credential and the scoping is the whole of the mitigation.
Without the secret the release still succeeds and the npm step warns and
skips, so the first release after the token lapses is a PyPI release and
nothing else. Check the run.
## What gets published
@@ -119,6 +129,25 @@ cannot compile WebAssembly synchronously on the main thread and so has to be
awaited. The exports map picks between them. There are no per-platform
artefacts, because WebAssembly has no platforms.
### The npm half needs a hand
The tag does not finish the npm release. The runner **stages** the tarball; it
is not on the registry and `npm install typikon` will not see it until someone
approves it with 2FA:
```console
$ npm stage list
$ npm stage view <stage-id> # what is in it
$ npm stage approve <stage-id> # asks for the code
```
Or the **Staged Packages** tab on npmjs.com. Either way a code is asked for,
which is the point: the forge holds a credential that can propose a release and
cannot make one.
`npm stage` wants npm 11.15 or newer on Node 22.14 or newer, on the approving
machine as well as the runner.
**No macOS or Windows wheels.** Forgejo has no hosted runners, so those need a
Mac and a Windows box registered as runners. Until then both fall back to the
sdist, which needs a Rust toolchain on the installing machine — fine for