The Unpublish Snapshots workflow (.github/workflows/unpublishSnapshots.yml) no longer works. The most recent run failed all 101 unpublish attempts: https://github.com/xh/hoist-react/actions/runs/33552025089
Symptom
Every call returns:
npm error code E403
403 Forbidden - PUT https://registry.npmjs.org/@xh%2fhoist/-rev/8665-...
Granular access tokens that bypass two-factor authentication may not perform this action.
Auth itself is fine - npm whoami succeeds as exhi, the version list is fetched, and all 101 targets are correctly identified. Final tally was Total 101 / Success 0 / Failed 101. Nothing was removed.
Cause
As of 2026-07-31, npm blocks granular access tokens configured to bypass 2FA from sensitive package-management actions, unpublish included. Our NPM_TOKEN is such a token. See https://github.blog/changelog/2026-07-31-restricting-npm-bypass-2fa-granular-access-tokens/
There is no unattended-CI fix
- npm removed classic/automation tokens in Nov 2025; granular access tokens are the only type left.
- A GAT either bypasses 2FA (now blocked from unpublish) or is subject to 2FA (needs an interactive OTP per write, which an unattended runner cannot produce).
- Trusted publishing (OIDC) documents
npm publish and npm stage publish only - no unpublish support.
Note the same changelog targets Jan 2027 for removing direct publish from bypass-2FA tokens, so deploySnapshot.yml and deployRelease.yml are on the same clock and will need an OIDC migration regardless of what happens here.
Possible paths, none yet tested
The workflow is always manually dispatched, so an otp input is plausible. Timing from the failed run: setup is only ~6s (job start 19:52:32, first unpublish 19:52:38), but the unpublish loop runs 4m36s for 101 versions - far longer than a 30s TOTP window. That implies chunking to ~10 versions per dispatch, ~11 dispatches to clear the current backlog.
Two unknowns to test locally before rewriting anything:
- Does a GAT with bypass-2FA off, plus
--otp, actually succeed at unpublish? The error reads as the token being disqualified by type rather than missing an OTP, so a newly minted token is needed either way.
- Does npm accept the same OTP across several sequential requests, or is there replay protection? If it is one code per request, chunking is dead.
Both are answerable in a couple of minutes locally: mint the token, unpublish one snapshot with --otp, then immediately try a second with the same code.
Running it locally with generated TOTP codes is the other option. Putting the TOTP seed into GitHub secrets would make CI work unattended, but stores both factors in one place on a shared org account - not recommended.
Also worth deciding
Whether these old public SNAPSHOT builds need removing at all. If the goal is registry tidiness rather than pulling something that should not be public, leaving them costs nothing.
Interim
The workflow is guarded to fail immediately with an explanatory message rather than grinding through ~5 minutes of E403s.
The
Unpublish Snapshotsworkflow (.github/workflows/unpublishSnapshots.yml) no longer works. The most recent run failed all 101 unpublish attempts: https://github.com/xh/hoist-react/actions/runs/33552025089Symptom
Every call returns:
Auth itself is fine -
npm whoamisucceeds asexhi, the version list is fetched, and all 101 targets are correctly identified. Final tally was Total 101 / Success 0 / Failed 101. Nothing was removed.Cause
As of 2026-07-31, npm blocks granular access tokens configured to bypass 2FA from sensitive package-management actions, unpublish included. Our
NPM_TOKENis such a token. See https://github.blog/changelog/2026-07-31-restricting-npm-bypass-2fa-granular-access-tokens/There is no unattended-CI fix
npm publishandnpm stage publishonly - no unpublish support.Note the same changelog targets Jan 2027 for removing direct publish from bypass-2FA tokens, so
deploySnapshot.ymlanddeployRelease.ymlare on the same clock and will need an OIDC migration regardless of what happens here.Possible paths, none yet tested
The workflow is always manually dispatched, so an
otpinput is plausible. Timing from the failed run: setup is only ~6s (job start 19:52:32, first unpublish 19:52:38), but the unpublish loop runs 4m36s for 101 versions - far longer than a 30s TOTP window. That implies chunking to ~10 versions per dispatch, ~11 dispatches to clear the current backlog.Two unknowns to test locally before rewriting anything:
--otp, actually succeed at unpublish? The error reads as the token being disqualified by type rather than missing an OTP, so a newly minted token is needed either way.Both are answerable in a couple of minutes locally: mint the token, unpublish one snapshot with
--otp, then immediately try a second with the same code.Running it locally with generated TOTP codes is the other option. Putting the TOTP seed into GitHub secrets would make CI work unattended, but stores both factors in one place on a shared org account - not recommended.
Also worth deciding
Whether these old public SNAPSHOT builds need removing at all. If the goal is registry tidiness rather than pulling something that should not be public, leaving them costs nothing.
Interim
The workflow is guarded to fail immediately with an explanatory message rather than grinding through ~5 minutes of E403s.