Skip to content

publish

publish #13

Workflow file for this run

# Publish the release to npm.
#
# moshcode's own install path is `curl … install.sh | sh`, and npm is a second
# channel rather than the primary one — which is exactly why it needs to be
# automatic. A channel that only updates when someone remembers is a channel
# that silently serves an old version forever.
#
# Runs on a published GitHub release, so the release itself is the trigger and
# there is no separate step to forget. `workflow_dispatch` is for re-running one
# that failed on something transient; it is safe because a version already on
# the registry is skipped rather than attempted.
#
# Authenticates with a stored npm automation token, in the repository secret
# `NPM_TOKEN`. Trusted publishing (OIDC) would avoid the stored credential and
# was tried twice; both attempts failed with npm reporting no credential at all,
# and the registration on npmjs.com — the web-UI half of it, pinned to this
# file's name — is what remains unconfirmed. See #305, #309, #311.
#
# If you go back to OIDC, the trap is below: `registry-url` is *required* here
# and *fatal* there. setup-node writes
#
# //registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}
#
# which is how the token reaches npm — and under OIDC, with no token to fill it,
# resolves to an empty credential that stops npm attempting the exchange.
# Removing it changed the error from E404 to ENEEDAUTH but did not publish.
# actions/setup-node#1551, npm/cli#9088.
#
# Since then setup-node v7 stopped exporting a dummy NODE_AUTH_TOKEN when no
# token is set (actions/setup-node#1558), which is the half of that trap that
# corrupted .npmrc under OIDC. It does not affect the token path — we set
# NODE_AUTH_TOKEN explicitly — but it means trusted publishing is worth a third
# attempt before the 2FA-bypass deprecation below forces one.
#
# Worth knowing when this is next revisited: npm is restricting tokens that
# bypass 2FA for direct publishing, so the token path has a horizon.
# https://gh.io/npm-gat-bypass2fa-deprecation
name: publish
on:
release:
types: [published]
workflow_dispatch:
permissions:
contents: read
# Still needed with token auth: provenance is signed with a short-lived OIDC
# token even though the publish itself authenticates with NPM_TOKEN. Without
# it `--provenance` fails.
id-token: write
jobs:
publish:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
# pnpm version is read from the "packageManager" field in package.json.
# Do not pin a version here — it conflicts with packageManager and fails
# with ERR_PNPM_BAD_PM_VERSION.
- uses: pnpm/action-setup@v6
# `registry-url` is what makes setup-node write the .npmrc line that feeds
# NODE_AUTH_TOKEN to npm. Required for token auth — and the thing to delete
# first if this ever moves back to OIDC.
- uses: actions/setup-node@v7
with:
node-version: 24
cache: pnpm
registry-url: https://registry.npmjs.org
- run: pnpm install --frozen-lockfile
# Publishing is the one action here that cannot be taken back — npm will
# not let a version be replaced — so the tests run first, on the exact
# tree about to be packed.
- run: pnpm run --if-present test
env:
CI: true
- name: Read the version being published
id: version
run: |
VERSION="$(node -p "require('./package.json').version")"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "moshcode@$VERSION"
# A release tagged v0.25.0 carrying package.json 0.24.0 would publish the
# wrong tree under a version nobody can reuse. Cheap to check, impossible
# to undo.
- name: Check the tag matches package.json
if: github.event_name == 'release'
env:
TAG: ${{ github.event.release.tag_name }}
VERSION: ${{ steps.version.outputs.version }}
run: |
if [ "$TAG" != "v$VERSION" ]; then
echo "::error::release tag $TAG does not match package.json version $VERSION"
exit 1
fi
# Makes a re-run harmless. Without it, dispatching the workflow twice
# fails the second time on an E403 that reads like something broke.
- name: Skip if this version is already on npm
id: published
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
if npm view "moshcode@$VERSION" version >/dev/null 2>&1; then
echo "already=true" >> "$GITHUB_OUTPUT"
echo "moshcode@$VERSION is already published — nothing to do"
else
echo "already=false" >> "$GITHUB_OUTPUT"
fi
# Said plainly here, rather than as the E404/no-permission npm otherwise
# returns partway through a release — an error that reads as "the package
# does not exist" rather than "there is no credential".
- name: Require an npm token
if: steps.published.outputs.already == 'false'
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
if [ -z "$NPM_TOKEN" ]; then
echo "::error::NPM_TOKEN is not set — add an npm automation token as a repository secret named NPM_TOKEN"
exit 1
fi
- name: Publish
if: steps.published.outputs.already == 'false'
run: npm publish --access public --provenance
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Confirm the registry has it
if: steps.published.outputs.already == 'false'
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
# The registry is read-through cached, so a fresh publish can 404 for
# a moment. Retry rather than report a good publish as a failure.
for _ in 1 2 3 4 5; do
if [ "$(npm view "moshcode@$VERSION" version 2>/dev/null)" = "$VERSION" ]; then
echo "moshcode@$VERSION is on the registry"
exit 0
fi
sleep 5
done
echo "::error::published, but the registry does not report moshcode@$VERSION yet"
exit 1