ci: a manual, guarded way to withdraw a version from npm - #45
Merged
Conversation
…ps itself Cuts the release that carries #41 (App mode gone from the skill) and #42 (reactions on the default token, judge model from the recipe), and fixes a dependency the package should never have had. THE PACKAGE DEPENDED ON ITSELF. 1.1.0 added `@orcarouter/code-review: ^1.0.2` to its own `dependencies` and five releases carried it. Every `npx` therefore downloaded a second, older copy of the CLI into node_modules before running the one it came for — latency on the first thing a new user does, and RELEASE.md already said the CLI has no dependencies. Nothing failed, which is why it survived: the bin resolves from the top level, so the nested copy is dead weight rather than a wrong entry point. A self-reference is also a registry dependent, and npm reads dependents when deciding whether a version may be withdrawn. Pinned by a test rather than a note — `dependencies`, `peerDependencies` and `optionalDependencies` must all be empty. The invariant was already documented in prose and still broken for five releases. 2.0.0, not 1.5.1. 1.5.0 shipped the App-mode removal as a minor, and the 1.x line is being unpublished inside npm's 72-hour window, so 2.0.0 is the first version on the registry that a user can actually install — the major is where the break belongs, and a lone `1.5.1` would imply a history the packument no longer has. RELEASE.md records what went with the withdrawn versions, because a packument with one version reads as a truncated upload. It also says plainly that unpublishing was defensible only for a two-day-old line with no known consumers, and that deprecation is the default everywhere else. Three versions move together (gate 1): package.json, plugin.json, marketplace.json.
Publishing 2.0.0 was the easy half. Withdrawing 1.0.2-1.5.0 needs a credential
that can unpublish, and the only one this project has is the NPM_TOKEN
repository secret — nobody's laptop holds it, and npm's window is 72 hours from
publish, so "log in later" is not a plan. This runs the withdrawal where the
token already lives and leaves a run log of who removed what.
Guards, because the failure mode is deleting the live release rather than the
dead ones:
* `confirm` must be typed as the word "unpublish". A dispatch button is one
click; this job's blast radius is the whole package.
* The version in package.json is refused outright. It is what dist-tags.latest
points at and what every npx resolves.
* Verification reads the ANONYMOUS packument afterwards and fails if the live
version is missing or if `latest` moved. `npm unpublish` exiting 0 is not
evidence, for the same reason gate 6 exists in publish.yml.
A version that cannot be withdrawn warns and the loop continues, then the job
fails at the end. Aborting mid-list would leave the line half-withdrawn, which
is the one state worse than either end.
Inputs reach the shell through `env`, never interpolated into a run block. This
job holds a publish token, and `${{ inputs.versions }}` inside a script is a
command-injection hole.
Shares the publish-npm concurrency group so it can never race a publish.
This was referenced Aug 26, 2026
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Publishing 2.0.0 was the easy half. Withdrawing 1.0.2 → 1.5.0 needs a credential that can unpublish, and the only one this project has is the
NPM_TOKENrepository secret — no maintainer laptop holds it, and npm's window is 72 hours from publish (it closes for 1.0.2 at 2026-08-28T09:23Z), so "log in later" is not a plan. This runs the withdrawal where the token already lives and leaves a run log of who removed what.Guards
The failure mode here is deleting the live release rather than the dead ones, so:
confirmmust be typed as the wordunpublish. A dispatch button is one click; this job's blast radius is the whole package.package.jsonis refused outright — it is whatdist-tags.latestpoints at and what everynpxresolves.latestmoved.npm unpublishexiting 0 is not evidence, for the same reason gate 6 exists inpublish.yml.A version that cannot be withdrawn warns and the loop continues, then the job fails at the end. Aborting mid-list leaves the line half-withdrawn, which is worse than either end.
Inputs reach the shell through
env, never interpolated into arunblock — this job holds a publish token, and${{ inputs.versions }}inside a script is a command-injection hole.Shares the
publish-npmconcurrency group so it can never race a publish.Then
Dispatched once against
1.5.0 1.4.0 1.3.2 1.3.1 1.3.0 1.2.1 1.2.0 1.1.0 1.0.2— dependents first, because every version up to 1.5.0 declared a dependency on itself at^1.0.2and npm reads dependents when deciding whether a version may go.