dev-prune 1.0.0

Universal, lockfile-safe workspace pruner and background dependency cleaner
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
# 🚢 Releasing `dev-prune`

Everything needed to cut a release, and everything that has to exist on the outside
world for one to succeed. Written for a maintainer, not a user.

The short version: **a release is one `git push` of one tag.** Nothing is built,
uploaded, or published by hand. If a step below sounds like manual work, it is either
one-time setup or a pre-flight check.

---

## 📋 Contents

- [Launch day: the first release]#-launch-day-the-first-release
- [The one-time setup]#-the-one-time-setup
- [Cutting a release]#-cutting-a-release
- [What the workflow does]#-what-the-workflow-does
- [The changelog contract]#-the-changelog-contract
- [Registry reference: who reviews what]#-registry-reference-who-reviews-what
- [Why a Rust binary is welcome on npm and PyPI]#-why-a-rust-binary-is-welcome-on-npm-and-pypi
- [Gated channels not yet wired up]#-gated-channels-not-yet-wired-up
- [When a release goes wrong]#-when-a-release-goes-wrong

---

## 🚀 Launch day: the first release

Every release after the first is [one tag push](#-cutting-a-release). The first one also
has to bring the outside world into existence. In order, because several steps depend on
the one above:

1. **Create the repository** as `Life-Experimentalist/dev-prune`, **public**. Public is
   not cosmetic: `npm publish --provenance` fails on a private repository, and the
   release check reads the public releases endpoint unauthenticated.

   ```bash
   gh repo create Life-Experimentalist/dev-prune --public --description "Reclaims disk space from idle Git repositories by deleting only what a lockfile can rebuild." --homepage "https://devprune.vkrishna04.me"
   ```

   Then set the topics, which are what GitHub search and the topic pages index on:

   ```bash
   gh repo edit Life-Experimentalist/dev-prune --add-topic rust,cli,developer-tools,disk-space,node-modules,monorepo,cleanup,devtools
   ```

   The social preview has no API and has to be uploaded by hand: Settings → General →
   Social preview → Upload `assets/banner.png`. It is the image every link to the
   repository renders as on Twitter/X, Slack, Discord and Hacker News, and without it
   they render as a grey octocat.

2. **Push `main`.** CI runs immediately — ten jobs, including `cargo package`, both
   packaging scripts against fabricated assets, the ARM64 Windows cross-check, and the
   changelog gate. Nothing below is worth doing until it is green.

   ```bash
   git remote add origin https://github.com/Life-Experimentalist/dev-prune.git && git push -u origin main
   ```
3. **Turn on Pages**: Settings → Pages → Source → **GitHub Actions**. Then point DNS at
   it — a `CNAME` record for `devprune` at `Life-Experimentalist.github.io` — and tick
   *Enforce HTTPS* once the certificate is issued. `site/public/CNAME` already carries
   the hostname, so nothing in the repository needs editing.

   Pages is off by default and the API says so plainly, which is a faster answer than
   waiting on DNS:

   ```powershell
   gh api repos/Life-Experimentalist/dev-prune/pages
   ```

   `HTTP 404` means Pages has never been enabled. Once it is, that returns the live URL
   and the certificate state.
4. **Prove the install scripts are actually served.** The one-liner in the README, the
   site and every doc points at this URL, and it is the single most-run command in the
   project:

   ```bash
   curl -fsSL https://devprune.vkrishna04.me/install.sh | head -5
   ```

5. **Add the credentials**[npm]#npm, [PyPI]#pypi-trusted-publishing--no-token-anywhere
   and [crates.io]#cratesio. Each channel needs a secret *and* its `*_PUBLISH`
   variable; a channel with neither is skipped, so a partial set is a valid way to
   start and the rest begin working on the next release with no code change.

   Confirm it rather than assuming it — this is the step whose omission is invisible
   until after the tag exists:

   ```powershell
   gh secret list; gh variable list
   ```

   You want `NPM_TOKEN` and `CARGO_REGISTRY_TOKEN` in the first list, and
   `NPM_PUBLISH`, `CRATES_PUBLISH` and `PYPI_PUBLISH` in the second. Empty output means
   nothing is configured, and the release will reach the GitHub release page only.
6. **Check the changelog date.** `CHANGELOG.md` dates the 1.0.0 section, and the date
   should be the day it actually ships. Fix it in place if the calendar has moved on.
7. **Read the release body before it becomes one.** This exact text is what appears on
   the GitHub release page:

   ```bash
   sh scripts/changelog-section.sh 1.0.0
   ```

   On Windows, `sh` is not on `PATH` but Git ships one, and running the real extractor
   beats reimplementing it — a second copy is a second thing to keep in step:

   ```powershell
   & "$env:ProgramFiles\Git\bin\sh.exe" scripts/changelog-section.sh 1.0.0
   ```

8. **Tag and push.**

   ```bash
   git tag -a v1.0.0 -m "v1.0.0" && git push origin v1.0.0
   ```

   PowerShell has no `&&`; use `;` and check in between, or just run the two separately.

9. **Verify each channel** once the workflow finishes. These are the commands users will
   actually run, and running them is the only proof the packages resolve:

   ```bash
   npx dev-prune@1.0.0 -V
   uvx dev-prune@1.0.0 -V
   cargo install dev-prune --version 1.0.0
   cargo binstall dev-prune@1.0.0
   curl -fsSL https://devprune.vkrishna04.me/install.sh | sh
   ```

   `cargo binstall` is the one worth watching: it reads
   `[package.metadata.binstall]` from the published crate and downloads a release asset
   by name. If an asset was renamed without updating that table, binstall does not fail
   — it quietly falls back to compiling from source, which looks like success and takes
   two minutes instead of two seconds. Run it with `--no-cleanup` and read the log.

   The last one is the shell installer. Its Windows counterpart is a different script and
   a different one-liner — `curl` in PowerShell is an alias for `Invoke-WebRequest` and
   will not behave like the binary, so verify the PowerShell path on PowerShell:

   ```powershell
   iwr -useb https://devprune.vkrishna04.me/install.ps1 | iex
   ```

Names are unclaimed on all three registries as of the check in
[Name availability](#name-availability) — but "unclaimed" has a shelf life, and the only
way to hold a name is to publish under it.

---

## 🔑 The one-time setup

Five channels publish automatically, and **each one is off until you switch it on**. A
channel that is off reports `skipped`, not `success` — so the run page tells you which
registries actually received the release, rather than looking identical either way.

Every channel needs **two** things: the credential, and a repository *variable* saying
the channel is configured. The variable exists because GitHub does not make the `secrets`
context available to a job-level `if:`, so a missing secret can only be detected inside
the job — by which point the job already exists and will report success when it returns
early. The variable is checked before the job starts.

Once the variable is `true`, a missing or mistyped secret **fails the release loudly**
instead of quietly skipping it.

| Channel | Credential | Variable to set | Off ⇒ |
|---|---|---|---|
| GitHub Release | `GITHUB_TOKEN` | — always runs | n/a |
| npm | `NPM_TOKEN` secret | `NPM_PUBLISH` = `true` | Job reports `skipped` |
| PyPI | *no secret* — Trusted Publishing | `PYPI_PUBLISH` = `true` | Job reports `skipped` |
| crates.io | `CARGO_REGISTRY_TOKEN` secret | `CRATES_PUBLISH` = `true` | Job reports `skipped` |
| GitHub Pages (site) | Automatic | — Settings → Pages → "GitHub Actions" | Site does not deploy |

Secrets and variables live in the same place, on two different tabs: **Settings → Secrets
and variables → Actions**. Putting a variable's value in the Secrets tab is the easiest
way to get a release that publishes nothing.

Whatever the outcome, the **What actually shipped** job writes a per-channel table to the
run summary, and warns if the release reached the GitHub release page and no registry.

### npm

1. Create an account on [npmjs.com]https://www.npmjs.com/ and enable 2FA.
2. Create a **Granular Access Token** (Access Tokens → Generate New Token → Granular).
   - Permissions: **Read and write** on packages.
   - Scope it to the seven package names below, or leave it account-wide before the
     first publish — granular tokens cannot name a package that does not exist yet.
   - Set an expiry you will actually remember. 90 days is the default; a year is fine
     for a personal project.
3. Save it as the repository secret **`NPM_TOKEN`**.
4. Set the repository **variable** **`NPM_PUBLISH`** to `true`. Without it the publish
   job does not run at all, and the release reports `skipped` for npm.

The seven packages the release publishes:

```
dev-prune                  ← the dispatcher everyone installs
dev-prune-linux-x64
dev-prune-linux-arm64
dev-prune-darwin-x64
dev-prune-darwin-arm64
dev-prune-win32-x64
dev-prune-win32-arm64
```

Nothing needs to be reserved in advance — the first publish creates all seven. The six
platform packages are published *before* the dispatcher, because npm resolves the
dispatcher's `optionalDependencies` as soon as it exists.

Publishes use `--provenance`, which requires the repository to be **public** and the
workflow to have `id-token: write`. It attaches a signed attestation tying each tarball
to this workflow, this commit and this tag; npm shows it as a green "Provenance" badge
on the package page. A private repo makes the publish fail — drop the `--provenance`
flag in that case.

### PyPI (Trusted Publishing — no token anywhere)

1. Create an account on [pypi.org]https://pypi.org/ and enable 2FA.
2. Go to **Your projects → Publishing → Add a new pending publisher** and fill in:

   | Field | Value |
   |---|---|
   | PyPI Project Name | `dev-prune` |
   | Owner | `Life-Experimentalist` |
   | Repository name | `dev-prune` |
   | Workflow name | `release.yml` |
   | Environment name | `pypi` |

   These four values are matched exactly against the OIDC token GitHub mints. The
   workflow filename and the `environment: pypi` block in `release.yml` are load-bearing
   — renaming either breaks publishing until PyPI is updated to match.
3. In the repository, create the environment: **Settings → Environments → New
   environment → `pypi`**. No secrets go in it. Add a required reviewer if you want a
   human approval gate before each PyPI upload.
4. Set the repository **variable** (not secret) **`PYPI_PUBLISH`** to `true`:
   Settings → Secrets and variables → Actions → Variables → New repository variable.

   The gate is a variable because Trusted Publishing has no secret to test for — without
   it, the job would hard-fail on every release until PyPI was configured.

### crates.io

1. Sign in at [crates.io]https://crates.io/ with GitHub.
2. Account Settings → API Tokens → New Token. Scopes: `publish-new` and `publish-update`.
3. Save it as the repository secret **`CARGO_REGISTRY_TOKEN`**.
4. Set the repository **variable** **`CRATES_PUBLISH`** to `true`.

### Name availability

Checked on 2026-08-12. All three names are unclaimed on all three registries:

| Name | npm | PyPI | crates.io |
|---|---|---|---|
| `dev-prune` | free | free | free |
| `devp` | free | free | free |
| `devprune` | free | free | free |

`Cargo.toml`, `npm/package.json` and `scripts/build_wheels.py` all publish as
**`dev-prune`**. Registering `devp` and `devprune` as placeholders is optional; npm and
PyPI both discourage name-squatting, and neither has a redirect mechanism, so a
placeholder is only worth it if you intend to publish something real under it.

---

## 🏷️ Cutting a release

```bash
cargo fmt --all -- --check && cargo clippy --all-targets --all-features -- -D warnings && cargo test --all
```

CI runs all of this on every push, so the only reason to run it locally is to avoid
tagging something that will fail.

Then:

1. **Bump the version** in `Cargo.toml`. Run `cargo build` so `Cargo.lock` picks it up —
   the release builds with `--locked` and will fail on a stale lockfile.
2. **Write the changelog entry.** See [the changelog contract]#-the-changelog-contract.
   CI fails if `CHANGELOG.md` has no section matching the version in `Cargo.toml`, so
   this is enforced, not merely encouraged.
3. **Commit and push to `main`.** Wait for CI to go green.
4. **Tag and push the tag:**

   ```bash
   git tag -a v1.0.0 -m "v1.0.0"
   git push origin v1.0.0
   ```

That is the release. A tag matching `v*` triggers everything below.

To re-run a release after fixing a failed job, use **Actions → Release → Run workflow**
and enter the tag. The npm job skips packages already on the registry, so a re-run
finishes what the first attempt started rather than dying on a conflict.

---

## ⚙️ What the workflow does

```mermaid
flowchart TD
    tag["git push origin v1.0.0"] --> build["build (6 matrix jobs)"]

    subgraph checks["Pre-flight, before any compilation"]
        v1["Cargo.toml version == tag"]
        v2["CHANGELOG.md has a section for it"]
    end

    build --> checks
    checks --> compile["cargo build --release --locked"]
    compile --> static["Linux binaries proven statically linked"]
    static --> pack["Archive + .sha256 sidecar per target"]

    pack --> publish["publish: GitHub Release"]
    publish --> npm["publish-npm: 7 packages"]
    publish --> pypi["publish-pypi: 6 wheels"]
    publish --> crate["publish-crate: cargo publish"]
```

**`build`** — six targets, `fail-fast: false` so one broken platform does not hide the
others:

| Target | Asset |
|---|---|
| `x86_64-unknown-linux-musl` | `dev-prune-v<ver>-linux-x64.tar.gz` |
| `aarch64-unknown-linux-musl` | `dev-prune-v<ver>-linux-arm64.tar.gz` |
| `x86_64-apple-darwin` | `dev-prune-v<ver>-darwin-x64.tar.gz` |
| `aarch64-apple-darwin` | `dev-prune-v<ver>-darwin-arm64.tar.gz` |
| `x86_64-pc-windows-msvc` | `dev-prune-v<ver>-windows-x64.zip` |
| `aarch64-pc-windows-msvc` | `dev-prune-v<ver>-windows-arm64.zip` |

Every asset gets a `.sha256` sidecar in `sha256sum` format. **The install scripts refuse
to install without it**, so the sidecars are a contract, not a courtesy — as are the
asset names themselves, which `scripts/install.sh` and `scripts/install.ps1` construct
by hand.

Linux is musl and statically linked on purpose. A glibc build carries a floor: it
refuses to start on any distribution older than the runner that produced it, and does
not run on Alpine at all. A static binary has neither problem, so one asset per
architecture covers every distribution. The workflow proves the result is actually
static with `file` before packaging it, because a silently-dynamic binary would work on
the runner and fail only for users.

**`publish`** — attaches every asset to a GitHub Release whose body is the changelog
section for that version, with the auto-generated commit list appended below it.

**`publish-npm`** — `scripts/npm-prepare.sh` unpacks the assets into the seven packages
and publishes them, platform packages first. Prereleases (any tag containing `-`) go out
under the `next` dist-tag so they never become what `npm install dev-prune` resolves to.

**`publish-pypi`** — `scripts/build_wheels.py` turns the same assets into six wheels and
uploads them via Trusted Publishing.

**`publish-crate`** — `cargo publish --locked`. Skipped for prereleases, because a
crates.io version can never be deleted, only yanked.

Both packaging scripts are exercised by the `packaging` job in CI on every push, against
fabricated assets. Their first real run is therefore not their first run.

The same reasoning covers `aarch64-pc-windows-msvc`, the only release target that is not
a native build on its own runner. CI's `cross` job type-checks it on every push, so the
ARM64 Windows toolchain and `ring`'s native code are known to work before a tag exists
rather than after.

---

## 📝 The changelog contract

`CHANGELOG.md` is the single source for release notes.
`scripts/changelog-section.sh <version>` extracts one version's section, and it is
called from three places: CI (on every push), the release build (before compiling), and
the release publish (as the release body). A release note and a changelog entry that can
drift apart eventually do — this makes them the same text.

The format is [Keep a Changelog](https://keepachangelog.com/) with
[SemVer](https://semver.org/):

```markdown
## [1.1.0] - 2026-09-01

### Added

- `devp run --except <name>` prunes everything **but** the repositories you name. …

### Fixed

- `devp unlink --missing` now also clears the undo list. …
```

Rules the automation depends on:

- The heading is exactly `## [<version>] - <YYYY-MM-DD>`. The extractor matches
  `## [<version>]` as a literal string prefix, so `1.0.0` will not accidentally match
  `1.0.0-rc1`.
- A section ends at the next `## ` heading. Anything between belongs to that version.
- The version must equal the one in `Cargo.toml` by the time you tag.
- Subsections use `### Added` / `### Changed` / `### Fixed` / `### Removed`.

House style for the entries themselves is in [`../CLAUDE.md`](../CLAUDE.md).

Verify a section renders the way you expect before tagging:

```bash
sh scripts/changelog-section.sh 1.1.0
```

---

## 🏛️ Registry reference: who reviews what

A common assumption is that publishing to a package registry involves review. For the
three registries used here, it does not.

| Registry | Human review? | What actually happens |
|---|---|---|
| **npm** | **None.** | `npm publish` makes the version live in seconds. Automated malware scanning runs after the fact and can result in takedown, not a hold. |
| **PyPI** | **None.** | Same: live immediately. Automated checks reject malformed metadata and oversized files; no person looks at the code. |
| **crates.io** | **None.** | Same. Publishing is permanent — a version can be *yanked* (hidden from resolution) but never deleted, and the name is never released. |
| **Homebrew core** | **Yes.** | A PR to `homebrew-core` reviewed by maintainers, with notability requirements (roughly 30+ forks / 30+ watchers / 75+ stars, or a clear equivalent). A **personal tap** has no review at all. |
| **WinGet** | **Yes.** | A PR to `microsoft/winget-pkgs`. Largely automated validation plus a maintainer sign-off; installer URL and hash must match. |
| **Scoop** | Depends. | The `extras` bucket is a reviewed PR; your own bucket is not. |
| **Chocolatey** | **Yes.** | Moderation queue, both automated and human. Historically the slowest of the lot. |
| **AUR** | **None.** | Anyone with an account can upload a PKGBUILD. |

So: npm, PyPI and crates.io are effectively free-for-all — the only gate is owning the
name, and [all three names are free](#name-availability). Publish early to hold them.

The flip side of "no review" is that **nothing is reversible**. crates.io versions are
permanent; npm allows unpublishing only within 72 hours and only if nothing depends on
the package; PyPI lets you delete a release but never reuse that version number. This is
why every publish job is gated behind a successful build of all six platforms, and why
the version/changelog checks run *before* compilation rather than after.

---

## 🦀 Why a Rust binary is welcome on npm and PyPI

Neither registry requires the package to contain JavaScript or Python. Both are, in
practice, general-purpose binary distribution networks with excellent CDNs, and shipping
a compiled CLI through them is a mainstream pattern rather than an abuse of one.

**On npm**, the mechanism is `optionalDependencies` plus the `os` and `cpu` manifest
fields. A thin dispatcher package lists one package per platform; npm evaluates `os`/`cpu`
during resolution, installs the single package that matches the machine, and skips the
other five without an error. The dispatcher's `bin` entry is a ~40-line Node launcher
that `require.resolve`s the real executable and `spawn`s it.

This is what **esbuild**, **Biome**, **SWC**, **Turborepo**, **Prisma** and **Tailwind's
Oxide engine** all do — none of them are JavaScript at the core either.

The alternative — a `postinstall` script that downloads a binary — is what dev-prune
deliberately does *not* do. It breaks `npm ci --ignore-scripts`, breaks offline and
air-gapped installs, breaks every corporate registry mirror, and turns a dependency
install into an outbound network call to GitHub. Shipping the bytes in the tarball has
none of those failure modes.

**On PyPI**, the mechanism is a platform wheel. A wheel is a zip with a metadata
directory; files placed in `<name>-<version>.data/scripts/` are unpacked straight into
the environment's `bin`/`Scripts` directory at install time. No Python is executed, no
compiler is involved, and no build backend is needed. The wheel's platform tag
(`manylinux_2_17_x86_64`, `win_amd64`, `macosx_11_0_arm64`, …) is what makes pip and uv
select the right one.

The definitive proof that this is normal: **`uv` itself is a Rust binary distributed as
PyPI wheels**, and so is **`ruff`**. `pip install uv` downloads a Rust executable.

`scripts/build_wheels.py` tags the Linux wheels as *both* manylinux and musllinux from
the same asset. That is legitimate rather than a trick — the manylinux policy is a
ceiling on which shared libraries a wheel may require, and a statically linked binary
requires none, so it satisfies the policy trivially while also being the literal
musllinux case. One build serves Debian and Alpine users alike.

What this buys, concretely:

```bash
uv tool install dev-prune     # persistent install, no Python project needed
uvx dev-prune status          # run once, nothing left behind
pipx run dev-prune status     # same, via pipx
npx dev-prune status          # same, via npm
```

---

## 🚧 Gated channels not yet wired up

These need a human on the other side, so none of them are in `release.yml`. Each becomes
worth doing at a different point.

**Homebrew.** A personal tap (`Life-Experimentalist/homebrew-tap`) works today and needs
no review: a repository containing a `Formula/dev-prune.rb` that points at the darwin
and linux assets and their sha256 sums, giving
`brew install Life-Experimentalist/tap/dev-prune`. Getting into `homebrew-core` — plain
`brew install dev-prune` — requires meeting the notability bar, so it is a
post-popularity step, not a launch step. Automating the tap bump is straightforward once
the tap exists: an extra release job that rewrites the formula's `url` and `sha256`.

**WinGet.** A PR to `microsoft/winget-pkgs` containing a manifest that points at the
Windows `.zip` assets and their hashes. `wingetcreate update` generates and submits it,
and can run from the release workflow with a PAT. Worth doing once the download URLs
have been stable for a release or two — a rejected manifest is a manual round trip.

**Scoop.** A personal bucket, same shape as the Homebrew tap and same lack of review.

**Chocolatey.** Its moderation queue is slow enough that it is only worth it if Windows
users ask for it specifically; WinGet covers the same audience with less friction.

**Linux distribution packages** (apt/dnf/AUR). The static musl binary means a `.deb` or
`.rpm` would be trivial to build, but they need a signed repository to be worth
installing from. The one-line installer covers the same ground until someone asks.

---

## 🔥 When a release goes wrong

**A build job failed.** Nothing was published — `publish` needs all six. Fix, then either
delete and re-push the tag (safe only if nobody has fetched it) or re-run the workflow
via Actions with the same tag.

**A publish job failed after another succeeded.** Re-run the workflow with the same tag.
The npm job skips what is already on the registry; `cargo publish` will fail on an
already-published version, which is loud and harmless.

**The tag is wrong.** If nobody has fetched it:

```bash
git tag -d v1.0.0 && git push origin :refs/tags/v1.0.0
```

If anyone might have, cut a new patch version instead. A moved tag is a genuinely nasty
failure for anyone who already pulled it.

**A bad version reached crates.io.** `cargo yank --version 1.0.0` stops new dependents
from resolving it. It cannot be deleted, and the version number cannot be reused. Publish
a fixed patch version.

**A bad version reached npm.** `npm unpublish dev-prune@1.0.0` works within 72 hours if
nothing depends on it. Otherwise `npm deprecate dev-prune@1.0.0 "reason"` and publish a
patch. Remember the six platform packages need the same treatment.

**A bad version reached PyPI.** Delete the release from the project page. The version
number is burned permanently — publish a patch.

---

## 🔗 See also

- [Multi-Ecosystem Distribution & Packaging Manual]DISTRIBUTION.md — the user-facing
  view of every install channel
- [GitHub Releases, DIY Manual Install & Source Build]RELEASES_AND_MANUAL_INSTALL.md
- [`../CONTRIBUTING.md`]../CONTRIBUTING.md — development setup and the local gate
- [`../CLAUDE.md`]../CLAUDE.md — repository conventions, including changelog voice