npm dist-tag #6
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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" |