Skip to content

Commit 0578d56

Browse files
dmealingclaude
andcommitted
ci(npm-dist-tag): rm is refused by every token we hold; add is the remedy that works
The workflow was built on the premise that the CI `NPM_TOKEN` — the token that publishes the set — is therefore the token that can retag it. With the config shadow fixed, that premise was testable for the first time, and it is false for `rm`. Run 34424234319: `npm whoami` succeeds, so the secret is valid and the earlier E401 was entirely the shadowed npmrc. Then all fourteen packages return `403 Forbidden - DELETE /-/package/@metaobjectsdev%2f<pkg>/dist-tags/next`. The maintainer's local granular token returns the same 403, and `--otp` changes nothing on either, 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. Two independent credentials on two machines, same refusal — this is a grant boundary, not a misconfiguration. What DOES work is `add`, which is how `next` was moved off `1.0.0-rc.8` onto `1.0.0`. So the workflow gains that action rather than only being able to report and to fail: - `add` repoints the tag at a given version across the set (needs `version`). - `rm` is kept — the grant may widen, and it is still the operation one wants — but the header says to expect 403, and a failed `rm` now prints the remedy instead of leaving a reader to conclude the credential is broken. - `ls` stays the default, so a dispatch meant to report still cannot write. This also settles the open question of whether a stale `next` can be retired: not by any automated path. Repointing it at the current release removes the prerelease install path, which was the actual harm; deleting the tag outright is cosmetic beyond that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTcEKXTQMYt84fAjuw5A2M
1 parent 2871626 commit 0578d56

1 file changed

Lines changed: 46 additions & 19 deletions

File tree

‎.github/workflows/npm-dist-tag.yml‎

Lines changed: 46 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
1-
# Retire (or repoint) a dist-tag across the whole lockstep npm set.
1+
# Repoint (or attempt to retire) a dist-tag across the whole lockstep npm set.
22
#
33
# WHY THIS EXISTS. A dist-tag left pointing at a superseded prerelease is a live install
44
# path: `npm i @metaobjectsdev/cli@next` served 1.0.0-rc.8 for as long as `next` outlived
5-
# the 1.0.0 cut. Removing it by hand is 14 registry calls, and the maintainer's LOCAL
6-
# granular token cannot make them — it 403s on `dist-tag rm` for every package and on
7-
# `npm profile get`, and passing `--otp` does not change the error, so the registry is not
8-
# asking for a second factor: that token simply lacks the right. The CI `NPM_TOKEN` is the
9-
# one that publishes, so it is the one that can retag. Running it here also means no token
10-
# ever has to be pasted onto a laptop.
5+
# the 1.0.0 cut. Correcting that by hand is 14 registry calls; doing it here means no
6+
# publish-capable token is ever pasted onto a laptop.
7+
#
8+
# WHAT A TOKEN CAN AND CANNOT DO — measured, not assumed. `add` (repoint) WORKS: that is
9+
# how `next` was moved off rc.8 onto 1.0.0. `rm` DOES NOT, from any credential we hold.
10+
# The maintainer's local granular token 403s on `dist-tag rm`, and so does the CI
11+
# `NPM_TOKEN` that publishes the set (2026-09-10, run 34424234319: `npm whoami` succeeds,
12+
# then every package returns `403 Forbidden - DELETE .../dist-tags/next`). Passing `--otp`
13+
# changes nothing, so the registry is not asking for a second factor. This is npm's
14+
# 2026-07-31 restriction — a bypass-2FA granular token may still publish but may not change
15+
# package access, and deleting a dist-tag is package access. So `rm` is kept because the
16+
# grant may change, but expect it to fail; the working remedy for a stale tag is to REPOINT
17+
# it at the current release with `add`, which leaves no prerelease install path behind.
1118
#
1219
# The set and its order come from scripts/publish-set.mjs — the single source of truth for
1320
# WHICH packages a release touches (CLAUDE.md: never hardcode the publish set; a new
@@ -23,11 +30,15 @@ on:
2330
required: true
2431
default: 'next'
2532
action:
26-
description: 'rm = remove the tag; ls = report only (no writes)'
33+
description: 'ls = report only (no writes); add = repoint at `version`; rm = remove (expect 403)'
2734
required: true
2835
default: 'ls'
2936
type: choice
30-
options: [ls, rm]
37+
options: [ls, add, rm]
38+
version:
39+
description: 'version to point `tag` at (required for add, e.g. 1.0.0)'
40+
required: false
41+
default: ''
3142

3243
jobs:
3344
dist-tag:
@@ -51,6 +62,7 @@ jobs:
5162
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
5263
TAG: ${{ inputs.tag }}
5364
ACTION: ${{ inputs.action }}
65+
VERSION: ${{ inputs.version }}
5466
run: |
5567
set -euo pipefail
5668
echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc
@@ -61,23 +73,38 @@ jobs:
6173
# registry-side refusal of the operation.
6274
npm whoami >/dev/null 2>&1 || { echo "::error::NPM_TOKEN does not authenticate"; exit 1; }
6375
76+
if [ "$ACTION" = "add" ] && [ -z "$VERSION" ]; then
77+
echo "::error::action 'add' needs a version to point '$TAG' at"; exit 1
78+
fi
79+
6480
node scripts/publish-set.mjs > "${RUNNER_TEMP}/set.txt"
6581
test -s "${RUNNER_TEMP}/set.txt"
6682
6783
fail=0
6884
while read -r dir; do
6985
name=$(node -p "require('./${dir}/package.json').name")
70-
if [ "$ACTION" = "ls" ]; then
71-
printf '%-42s %s\n' "$name" "$(npm dist-tag ls "$name" | tr '\n' ' ')"
72-
else
73-
# A tag that is already absent is the desired end state, not a failure.
74-
if npm dist-tag ls "$name" | grep -q "^${TAG}:"; then
75-
npm dist-tag rm "$name" "$TAG" && echo " ✓ $name — removed '$TAG'" || { echo " ✗ $name"; fail=1; }
76-
else
77-
echo " · $name — no '$TAG' tag, nothing to do"
78-
fi
79-
fi
86+
case "$ACTION" in
87+
ls)
88+
printf '%-42s %s\n' "$name" "$(npm dist-tag ls "$name" | tr '\n' ' ')"
89+
;;
90+
add)
91+
npm dist-tag add "${name}@${VERSION}" "$TAG" \
92+
&& echo " ✓ $name — '$TAG' -> $VERSION" || { echo " ✗ $name"; fail=1; }
93+
;;
94+
rm)
95+
# A tag that is already absent is the desired end state, not a failure.
96+
if npm dist-tag ls "$name" | grep -q "^${TAG}:"; then
97+
npm dist-tag rm "$name" "$TAG" && echo " ✓ $name — removed '$TAG'" || { echo " ✗ $name"; fail=1; }
98+
else
99+
echo " · $name — no '$TAG' tag, nothing to do"
100+
fi
101+
;;
102+
esac
80103
done < "${RUNNER_TEMP}/set.txt"
81104
82105
# Report every failure before exiting, so one run names them all.
106+
if [ "$fail" -ne 0 ] && [ "$ACTION" = "rm" ]; then
107+
echo "::notice::403 on DELETE is the expected result for a bypass-2FA token." \
108+
"Repoint the tag at the current release with action 'add' instead."
109+
fi
83110
exit "$fail"

0 commit comments

Comments
 (0)