Skip to content

Revert the unpublish workflow — npm forbids it for CI tokens - #46

Merged
akf66 merged 1 commit into
mainfrom
akf66/unpublish-needs-a-human
Aug 26, 2026
Merged

Revert the unpublish workflow — npm forbids it for CI tokens#46
akf66 merged 1 commit into
mainfrom
akf66/unpublish-needs-a-human

Conversation

@akf66

@akf66 akf66 commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

#45 was correct and cannot work. Dispatched against all nine 1.x versions (run), every one came back:

npm error code E403
Granular access tokens that bypass two-factor authentication may not perform this action.

That is registry policy, not configuration. RELEASE.md specifies a granular token scoped to this one package precisely so a leak cannot publish anything else — and npm will not let such a token unpublish at all. A classic automation token is the only thing that would satisfy it, which trades a bounded blast radius for an unbounded one in order to automate an operation that should be rare and deliberate.

So the guards guarded a button that never fires. Reverting rather than leaving it: a dispatch entry named Unpublish that 403s on every input is worse than its absence, because the next person reads it as the supported path and burns part of a 72-hour window finding out.

The finding is worth more than the workflow, so it goes in RELEASE.md: CI publishes, humans withdraw, and withdrawing needs an interactive npm login with 2FA from a maintainer's own machine inside the window.

2.0.0 (#44) is unaffected — live, public, latest, zero dependencies.

…try says no

The workflow was correct and cannot work. Every one of the nine versions came
back the same way:

  npm error code E403
  Granular access tokens that bypass two-factor authentication may not
  perform this action.

That is registry policy, not configuration. RELEASE.md specifies a granular
token scoped to this package precisely so a leak cannot publish anything else,
and npm will not let such a token unpublish at all. A classic automation token
would be the only way to satisfy it, which trades a bounded blast radius for an
unbounded one to automate an operation that should be rare and deliberate.

So the guards guarded a button that never fires. Reverted rather than left in
place: a dispatch entry named "Unpublish" that 403s on every input is worse than
its absence, because the next person reads it as the supported path and burns
part of a 72-hour window finding out.

The finding is worth more than the workflow, and it goes in RELEASE.md instead:
CI publishes, humans withdraw, and withdrawing needs an interactive login with
2FA from a maintainer's own machine inside the window.
@akf66
akf66 merged commit dbacf71 into main Aug 26, 2026
2 checks passed
akf66 added a commit that referenced this pull request Aug 26, 2026
…#47)

The 1.x line has to stop being installed, and unpublishing it from CI turned out
to be impossible: npm refuses a granular token that bypasses 2FA (#46). This is
the half of that job the registry does allow.

`npm deprecate` is also the better instrument for the goal. It leaves the
tarball in place, so anyone pinned keeps working, and prints a warning on every
install — which is the outcome an unpublish only approximates, by breaking them
instead. RELEASE.md already named it the default; there was just no way to run
it, because the only credential this project has is a repository secret.

Reversible, which is why this one is safe to keep where the unpublish workflow
was not: dispatching with an empty `message` clears the flag, per npm's own
convention. A mistake here is one more dispatch, not a burned version number.

One guard, for the one-click mistake that matters: the version in package.json
is refused. Deprecating whatever dist-tags.latest points at puts a warning on
every install of the package and reads as an outage.

Verification reads the ANONYMOUS packument and asserts each version landed in
the requested state, plus that the live release is not flagged. `npm deprecate`
exiting 0 is not evidence, for the same reason publish.yml has a gate 6.

A version that refuses warns and the loop continues, then the job fails at the
end — one refusal must not leave the rest of the line unmarked.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant