diff --git a/.github/workflows/unpublish.yml b/.github/workflows/unpublish.yml deleted file mode 100644 index 817405d..0000000 --- a/.github/workflows/unpublish.yml +++ /dev/null @@ -1,135 +0,0 @@ -# Withdraw versions of `@orcarouter/code-review` from npm, by hand, on purpose. -# -# This is a demolition tool. It exists because npm's unpublish window is 72 -# hours and the credential that can use it is the `NPM_TOKEN` repository secret -# — not something on any maintainer's laptop. Running it from CI keeps the token -# where it already lives and leaves a run log of who withdrew what. -# -# An unpublish CANNOT be undone: the version number is burned forever, and -# anyone who pinned it gets an E404 rather than a warning. Prefer -# `npm deprecate`, which leaves the tarball installable and prints a warning. -# Reach for this only for a version that is days old with no known consumers. -# -# Two guards, because the failure mode is deleting the live release: -# -# 1. `confirm` must be typed as the exact word "unpublish". A dispatch button -# is one click and this workflow's blast radius is the whole package. -# 2. The version in `package.json` on the default branch is refused. That is -# what `dist-tags.latest` points at and what every `npx` resolves; the job -# fails before touching the registry rather than after. -# -# Order matters when withdrawing a run of versions: list DEPENDENTS FIRST. Up to -# 1.5.0 this package declared a dependency on ITSELF at ^1.0.2, and npm reads -# dependents when deciding whether a version may go. -# -# Inputs reach the shell through `env`, never through `${{ }}` inside a `run` -# block — an interpolated dispatch input is a command-injection hole, and this -# job holds a publish token. - -name: Unpublish - -concurrency: - group: publish-npm # never race the publish job - cancel-in-progress: false - -on: - workflow_dispatch: - inputs: - versions: - description: "Versions to withdraw, space-separated, dependents first (e.g. 1.5.0 1.4.0 1.0.2)" - required: true - type: string - confirm: - description: 'Type "unpublish" to confirm. Irreversible.' - required: true - type: string - -permissions: - contents: read - -jobs: - unpublish: - runs-on: ubuntu-latest - env: - VERSIONS: ${{ inputs.versions }} - CONFIRM: ${{ inputs.confirm }} - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: "20" - registry-url: "https://registry.npmjs.org" - - - name: Guard - run: | - set -euo pipefail - if [ "$CONFIRM" != "unpublish" ]; then - echo "::error::confirm must be the word \"unpublish\". Nothing was touched." - exit 1 - fi - - PKG_NAME=$(node -p "require('./package.json').name") - PKG_LIVE=$(node -p "require('./package.json').version") - echo "PKG_NAME=$PKG_NAME" >> "$GITHUB_ENV" - echo "PKG_LIVE=$PKG_LIVE" >> "$GITHUB_ENV" - - for v in $VERSIONS; do - if [ "$v" = "$PKG_LIVE" ]; then - echo "::error::$v is the version in package.json — that is the live release. Refusing." - exit 1 - fi - done - echo "::notice::withdrawing: $VERSIONS (keeping $PKG_LIVE)" - - - name: Unpublish - run: | - set -uo pipefail - FAILED="" - for v in $VERSIONS; do - echo "--- $PKG_NAME@$v" - if npm unpublish "$PKG_NAME@$v" --force; then - echo "::notice::withdrew $PKG_NAME@$v" - else - # Keep going: a version outside the 72-hour window, or already - # gone, must not strand the rest of the list half-withdrawn. - echo "::warning::could not withdraw $PKG_NAME@$v" - FAILED="$FAILED $v" - fi - done - echo "FAILED=${FAILED# }" >> "$GITHUB_ENV" - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - # The packument is what `npm install` reads. Asserting against it — and - # anonymously — is the only proof that the withdrawal took effect and that - # the version everyone installs is still there. - - name: Verify the registry - run: | - set -euo pipefail - sleep 15 - SLUG=$(node -p "encodeURIComponent(process.env.PKG_NAME)") - curl -fsSL "https://registry.npmjs.org/$SLUG" -o packument.json - node -e ' - const p = require("./packument.json"); - const left = Object.keys(p.versions).sort(); - const latest = p["dist-tags"].latest; - const live = process.env.PKG_LIVE; - console.log("versions on the registry:", left.join(", ")); - console.log("latest:", latest); - if (!left.includes(live)) { - console.error(`::error::${live} is GONE from the packument — the live release was withdrawn`); - process.exit(1); - } - if (latest !== live) { - console.error(`::error::latest is ${latest}, expected ${live}`); - process.exit(1); - } - const survivors = (process.env.VERSIONS || "").split(/\s+/).filter((v) => v && left.includes(v)); - if (survivors.length) console.error(`::warning::still present: ${survivors.join(", ")}`); - ' - - if [ -n "${FAILED:-}" ]; then - echo "::error::these versions could not be withdrawn: $FAILED" - exit 1 - fi diff --git a/RELEASE.md b/RELEASE.md index f05d88e..2e9c419 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -166,6 +166,22 @@ npm's unpublish window is 72 hours and a withdrawn version number can never be reused, so anything checkable before publishing is checked there rather than discovered afterward. +**Unpublishing cannot be automated, and the `NPM_TOKEN` secret cannot do it.** +Tried, on the 1.x withdrawal: every version came back + +``` +npm error code E403 +Granular access tokens that bypass two-factor authentication may not +perform this action. +``` + +CI can publish and cannot withdraw, by registry policy rather than by +configuration — so no token, scope, or workflow fixes it, and a workflow that +offers the button is a guard rail around something that never runs. Withdrawing +a version takes an interactive `npm login` (web or OTP) from a maintainer's own +machine, inside the 72 hours. Plan on that when a release needs pulling: the +person, not the pipeline. + Auth is the `NPM_TOKEN` repository secret. Use a **granular access token scoped to this one package**, not a classic automation token — a leaked classic token can publish anything in the account. Rotate with `gh secret set NPM_TOKEN`.