Skip to content

ci: a manual, guarded way to withdraw a version from npm - #45

Merged
akf66 merged 2 commits into
mainfrom
akf66/ci-unpublish
Aug 26, 2026
Merged

ci: a manual, guarded way to withdraw a version from npm#45
akf66 merged 2 commits into
mainfrom
akf66/ci-unpublish

Conversation

@akf66

@akf66 akf66 commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

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 — 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:

  • 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 leaves the line half-withdrawn, which is 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.

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.2 and npm reads dependents when deciding whether a version may go.

akf66 added 2 commits August 26, 2026 21:32
…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.
@akf66
akf66 merged commit 38ca4d7 into main Aug 26, 2026
2 checks passed
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