Skip to content

npm dist-tag

npm dist-tag #6

Workflow file for this run

# Repoint (or attempt to retire) a dist-tag across the whole lockstep npm set.
#
# WHY THIS EXISTS. A dist-tag left pointing at a superseded prerelease is a live install
# path: `npm i @metaobjectsdev/cli@next` served 1.0.0-rc.8 for as long as `next` outlived
# the 1.0.0 cut. Correcting that by hand is 14 registry calls; doing it here means no
# publish-capable token is ever pasted onto a laptop.
#
# WHAT A TOKEN CAN AND CANNOT DO — measured, not assumed. `add` (repoint) WORKS: that is
# how `next` was moved off rc.8 onto 1.0.0. `rm` DOES NOT, from any credential we hold.
# The maintainer's local granular token 403s on `dist-tag rm`, and so does the CI
# `NPM_TOKEN` that publishes the set (2026-09-10, run 34424234319: `npm whoami` succeeds,
# then every package returns `403 Forbidden - DELETE .../dist-tags/next`). Passing `--otp`
# changes nothing, so the registry is not asking for a second factor. This is npm's
# 2026-07-31 restriction — a bypass-2FA granular token may still publish but may not change
# package access, and deleting a dist-tag is package access. So `rm` is kept because the
# grant may change, but expect it to fail; the working remedy for a stale tag is to REPOINT
# it at the current release with `add`, which leaves no prerelease install path behind.
#
# The set and its order come from scripts/publish-set.mjs — the single source of truth for
# WHICH packages a release touches (CLAUDE.md: never hardcode the publish set; a new
# publish path must read it too). Written to a file first so a non-zero exit fails the step
# rather than yielding a silently short list.
name: npm dist-tag
on:
workflow_dispatch:
inputs:
tag:
description: 'dist-tag to operate on (e.g. next)'
required: true
default: 'next'
action:
description: 'ls = report only (no writes); add = repoint at `version`; rm = remove (expect 403)'
required: true
default: 'ls'
type: choice
options: [ls, add, rm]
version:
description: 'version to point `tag` at (required for add, e.g. 1.0.0)'
required: false
default: ''
jobs:
dist-tag:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# NO `registry-url:` here. It makes setup-node write its own .npmrc and export
# NPM_CONFIG_USERCONFIG at it, which SHADOWS the ~/.npmrc the step below writes —
# npm then authenticates with setup-node's literal `${NODE_AUTH_TOKEN}` placeholder
# and returns E401 "token seems to be invalid" for every package, which reads as a
# dead secret rather than a shadowed config. Authenticate exactly the way
# publish-npm.yml does, so the token that publishes the set is the token that retags it.
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '22'
- name: ${{ inputs.action }} '${{ inputs.tag }}' across the lockstep set
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
TAG: ${{ inputs.tag }}
ACTION: ${{ inputs.action }}
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc
trap 'rm -f ~/.npmrc' EXIT
# Fail on the credential, once, before touching 14 packages. Without this an
# auth problem arrives as fourteen identical per-package errors and looks like a
# registry-side refusal of the operation.
npm whoami >/dev/null 2>&1 || { echo "::error::NPM_TOKEN does not authenticate"; exit 1; }
if [ "$ACTION" = "add" ] && [ -z "$VERSION" ]; then
echo "::error::action 'add' needs a version to point '$TAG' at"; exit 1
fi
node scripts/publish-set.mjs > "${RUNNER_TEMP}/set.txt"
test -s "${RUNNER_TEMP}/set.txt"
fail=0
while read -r dir; do
name=$(node -p "require('./${dir}/package.json').name")
case "$ACTION" in
ls)
printf '%-42s %s\n' "$name" "$(npm dist-tag ls "$name" | tr '\n' ' ')"
;;
add)
npm dist-tag add "${name}@${VERSION}" "$TAG" \
&& echo " ✓ $name — '$TAG' -> $VERSION" || { echo " ✗ $name"; fail=1; }
;;
rm)
# A tag that is already absent is the desired end state, not a failure.
if npm dist-tag ls "$name" | grep -q "^${TAG}:"; then
npm dist-tag rm "$name" "$TAG" && echo " ✓ $name — removed '$TAG'" || { echo " ✗ $name"; fail=1; }
else
echo " · $name — no '$TAG' tag, nothing to do"
fi
;;
esac
done < "${RUNNER_TEMP}/set.txt"
# Report every failure before exiting, so one run names them all.
if [ "$fail" -ne 0 ] && [ "$ACTION" = "rm" ]; then
echo "::notice::403 on DELETE is the expected result for a bypass-2FA token." \
"Repoint the tag at the current release with action 'add' instead."
fi
exit "$fail"