diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 75bde4e..0762c1e 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -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 `, 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" diff --git a/docs/releasing.md b/docs/releasing.md index b7cb533..9d09089 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -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 # what is in it +$ npm stage approve # 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