dev-prune 1.7.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
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
# 🚢 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
- [Channels that still need a human]#-channels-that-still-need-a-human
- [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/readme-banner.png`. It is already exactly the
   1280×640 GitHub asks for, so nothing is rescaled. 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 `CARGO_REGISTRY_TOKEN` in the first list — it is the only long-lived
   credential left, because npm and PyPI both publish over OIDC — 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 section for the version being
   released, 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.2.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.2.0
   ```

8. **Tag and push.**

   ```bash
   git tag -a v1.2.0 -m "v1.2.0" && git push origin v1.2.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
   uvx dev-prune@1.2.0 -V
   cargo install dev-prune --version 1.2.0
   cargo binstall dev-prune@1.2.0
   curl -fsSL https://devprune.vkrishna04.me/install.sh | sh
   ```

   `npx dev-prune@<version> -V` joins this list only once npm publishing is switched on
   `NPM_PUBLISH` is currently `false`, and the `dev-prune` dispatcher that `npx` would
   resolve does not exist on the registry yet.

   `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
   ```

The `dev-prune` name is held on crates.io and PyPI. On npm five of the eight names are
held — the dispatcher and the four Linux and macOS platform packages — and the three
`dev-prune-win32-*` names are not, so `npm install -g dev-prune` works on Linux and macOS
and lands without a binary on Windows. See [Name availability](#name-availability).
"Unclaimed" has a shelf life, so finishing that list is also what secures the name there.

---

## 🔑 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 | *no secret* — Trusted Publishing | `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

There is no npm secret. The `publish-npm` job authenticates with the same OIDC token it
uses for provenance, so nothing long-lived exists in this repository for anyone to steal
and nothing has an expiry date to forget.

That leaves exactly one manual step per package name, once, forever:

1. Create an account on [npmjs.com]https://www.npmjs.com/ and enable 2FA.
2. **Claim each of the eight names by publishing its first version from a workstation.**
   Trusted publishing cannot do this — a trusted publisher is configured *on a package*,
   and a package with no versions has nowhere to hold one
   ([npm/cli#8544]https://github.com/npm/cli/issues/8544, open since September 2025).
   See [Bootstrapping the eight names without a token at
   all](#bootstrapping-the-eight-names-without-a-token-at-all) for the commands.
3. **Add a trusted publisher to each package**: npmjs.com → the package → Settings →
   Trusted Publisher → GitHub Actions → repository `Life-Experimentalist/dev-prune`,
   workflow `release.yml`, environment blank.
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.

Step 2 is the one that bites, and not for the reason you expect. Creating several new
package names from one account in quick succession trips the registry's spam heuristic:
`403 Package name triggered spam detection`. It is a throttle on the rate, not a verdict
on the names — wait and retry, and open a ticket at
[npmjs.com/support](https://www.npmjs.com/support) if it does not clear.

The `publish-npm` job does not refuse to start when a name is missing. It skips that one,
publishes the rest, and names what it skipped — as a `::warning::` on the job and as a
row in the release summary. A partial npm channel is a state this project has actually
been in for a whole release, and holding back the names that do work helps nobody. The
job fails only when *nothing* was published and nothing was already there, which means
the channel does not exist at all rather than being incomplete.

The eight packages the release publishes — the same eight `npm/package.json` names,
which is where this list has to keep agreeing with reality:

```
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
dev-prune-win32-ia32
```

The seven platform packages are published *before* the dispatcher, because npm resolves
the dispatcher's `optionalDependencies` as soon as it exists.

**A `404 Not Found` on the `PUT` means the name does not exist yet**, not that the
network failed. It is what npm answers a credential that cannot see a name — a trusted
publisher that has nothing to attach to, or a granular token scoped to packages that do
not exist. v1.6.0 hit the token version of this. There is no credential that fixes it;
the name has to be claimed first.

Publishes use `--provenance --access public`, 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.

`--access public` is not optional even though these are unscoped names. npm refuses to
generate provenance for a package it cannot confirm is public, and a package with no
published versions has no access setting to read, so the first publish of each name
fails with `EUSAGE ... you must set access to public` without it.

#### Bootstrapping the eight names without a token at all

Publish the first version of each name from a workstation. An interactive `npm login`
session answers the 2FA challenge itself, so no token has to exist even for this:

```sh
gh release download v1.6.0 --dir /tmp/rel
cd /tmp/rel && sha256sum -c ./*.sha256          # publish the CI binaries, not local ones
sh scripts/npm-prepare.sh 1.6.0 /tmp/rel /tmp/npm-dist
sh scripts/npm-publish.sh 1.6.0 /tmp/npm-dist latest --local
```

If this returns `403 Package name triggered spam detection`, the account has created too
many new names too quickly. Nothing about the names is wrong and nothing about the
account is in trouble — wait, retry, and escalate to
[npmjs.com/support](https://www.npmjs.com/support) if hours of retrying do not clear it.

`--local` drops `--provenance`, which is not a preference: the attestation is signed
against a CI OIDC identity and a workstation has none. **The version published this way
carries no provenance badge.** Every later version, published by CI, does.

Re-run the command as often as you like — the `npm view` check skips whatever already
made it to the registry, which matters here because a 2FA code expires partway through
seven publishes more often than not, and because the throttle above tends to let names
through a few at a time.

Always prepare from the downloaded release assets rather than a local `cargo build`.
The npm packages must contain the same executables the tarballs, wheels and installers
ship, and the `.sha256` files next to the assets are what proves it.

#### Why there is no token

**npm removes direct publish access from bypass-2FA tokens in January 2027**, and a
long-lived token that can publish eight packages is the exact thing the 2025
supply-chain attacks abused. Trusted publishing has no such token: the job asks GitHub
for a short-lived OIDC assertion, npm exchanges it for a publish grant, and the same
assertion produces the provenance attestation.

PyPI solves the bootstrap problem with pending publishers, which let a project be
configured before it exists. npm has no equivalent, which is why claiming the names is
still a manual step and why it is the *only* one.

The job also runs `npm install -g npm@latest` before publishing. Exchanging an OIDC
token needs npm 11.5.1 or newer, and the npm bundled with a Node release is whatever
shipped the day that Node was cut — a release that silently loses its authentication to
a Node patch is not one anyone would think to look at.

### 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

As of 2026-08-23, `dev-prune` is published on PyPI and crates.io, which is what holds a
name on those registries. On npm the eight names are being claimed one at a time, against
the registry's new-name throttle. Five exist, including the dispatcher, so the release
can publish a working Linux and macOS channel; the three Windows names are still held by
the throttle:

| Name | npm | PyPI | crates.io |
|---|---|---|---|
| `dev-prune` | **held** (1.6.0) | **held (published)** | **held (published)** |
| `dev-prune-linux-x64` | **held** (1.6.0) | n/a | n/a |
| `dev-prune-linux-arm64` | **held** (1.6.0) | n/a | n/a |
| `dev-prune-darwin-x64` | **held** (1.6.0) | n/a | n/a |
| `dev-prune-darwin-arm64` | **held** (1.6.0) | n/a | n/a |
| `dev-prune-win32-x64` | not yet claimed | n/a | n/a |
| `dev-prune-win32-arm64` | not yet claimed | n/a | n/a |
| `dev-prune-win32-ia32` | not yet claimed | n/a | n/a |
| `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:** `sh scripts/bump-version.sh 1.6.0`. It writes `Cargo.toml` and
   every file that restates the number by hand — the install scripts' offline fallback,
   the site's banner and `llms.txt`, the JSON-LD, `npm/package.json`, the `--json`
   samples in the CLI reference and the docs that quote a whole asset filename — then
   refreshes `Cargo.lock` (the release builds with `--locked` and fails on a stale one)
   and runs `sh scripts/check-version.sh` to prove it got all of them. That check also
   runs on every push and once more inside the release before anything is built, so a
   file the bump script does not yet know about stops the tag rather than shipping.
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.2.0 -m "v1.2.0"
   git push origin v1.2.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.2.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`** — seven 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` |
| `i686-pc-windows-msvc` | `dev-prune-v<ver>-windows-x86.zip` |

Each target also publishes the **same binary again, uncompressed**, under the archive's
name without the extension — `dev-prune-v<ver>-linux-x64`, `dev-prune-v<ver>-windows-x64.exe`
and so on. That is what `devp update --install` downloads: self-update needs one download
and one hash check, and unpacking a tarball inside the binary would mean compiling gzip
and tar into it purely to undo what the packaging step just did. The archives stay because
they are what a human downloads from the release page and what the install scripts fetch.
These names are a contract with `constants::release_asset_name`, which builds them by
hand — a mismatch is not a build failure, it is a self-update that 404s for every user on
release day, so the two are commented as referring to each other and covered by a unit
test.

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.

The archives are also signed with [GitHub build provenance][attest]: the `publish` job
runs `actions/attest-build-provenance`, which exchanges the job's OIDC token for a
Sigstore signature binding each file to this repository, this workflow file and the
commit it was built from. That is the part a checksum cannot do — a sidecar is produced
by whoever produced the archive, so a substituted pair verifies perfectly. Anyone can
check it with no secret and no key:

```bash
gh attestation verify dev-prune-v<ver>-linux-x64.tar.gz --repo Life-Experimentalist/dev-prune
```

Only the archives and the `.vsix` are attested. A sidecar is a checksum of an attested
file, so signing it adds nothing.

[attest]: https://docs.github.com/actions/security-for-github-actions/using-artifact-attestations/using-artifact-attestations-to-establish-provenance-for-builds

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 eight 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 seven 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` and `i686-pc-windows-msvc`, the two
release targets that are not native builds on their own runner. CI's `cross` job
type-checks both on every push, so each Windows toolchain and its dependencies' native
code are known to work before a tag exists rather than after.

`i686-pc-windows-msvc` is the only 32-bit target published, and only for Windows. It
exists for machines with no 64-bit mode — locked-down corporate images, industrial
control PCs — and it is a plain rustup target on the same x64 runner, so it costs one
extra matrix job and nothing else. There is deliberately no 32-bit Linux build:
`i686-unknown-linux-musl` needs a cross musl toolchain to serve a desktop population
that has effectively gone.

---

## 📝 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. `dev-prune` is already held on PyPI and crates.io by the published releases; on
npm it is [still free](#name-availability) until `NPM_PUBLISH` is switched on.

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 seven targets, 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 — once NPM_PUBLISH is switched on
```

---

## 🚧 Channels that still need a human

The files these channels consume are generated by the release. What is left is the part a
person has to do once: create a repository, or open a pull request someone reviews.

**The manifests themselves are automated.** The `packaging` job in `release.yml` runs
`scripts/render-packaging.sh` against the sidecars the release just published and commits
the result to `main`: a Homebrew formula, a Scoop manifest, and the three WinGet manifest
files. All three are the same three facts — a URL, a SHA-256 and a version — in different
syntaxes, and all three install correctly while being a release out of date, which is why
none of them is maintained by hand. Pre-release tags are skipped: `brew install` and
`scoop install` have no notion of a release channel.

Both by-URL installs work today with no repository and no review:

```bash
brew install https://raw.githubusercontent.com/Life-Experimentalist/dev-prune/main/packaging/homebrew/dev-prune.rb
scoop install https://raw.githubusercontent.com/Life-Experimentalist/dev-prune/main/packaging/scoop/dev-prune.json
```

**The named tap and bucket exist and need nothing from you.**
[`Life-Experimentalist/homebrew-tap`](https://github.com/Life-Experimentalist/homebrew-tap)
and [`Life-Experimentalist/scoop-bucket`](https://github.com/Life-Experimentalist/scoop-bucket)
hold one file each — `Formula/dev-prune.rb` and `bucket/dev-prune.json` — and each has a
`sync.yml` workflow that fetches its file from `packaging/` here every morning and commits
it if it changed. So a release propagates to `brew upgrade` and `scoop update` within a
day of the manifests being refreshed, with no credential anywhere: the sync *pulls* a
public file rather than being pushed a private token. To make it immediate, run the
workflow by hand:

```bash
gh workflow run sync.yml --repo Life-Experimentalist/homebrew-tap
gh workflow run sync.yml --repo Life-Experimentalist/scoop-bucket
```

`homebrew-core` — plain `brew install dev-prune`, no tap prefix — has a real notability
bar and stays a post-popularity step.

**WinGet** is submitted by the release, and merged by a person. `winget-pkgs` has no
popularity requirement, but every version is a pull request a Microsoft reviewer signs
off, so "submitted" and "installable" are days apart. The first submission is open as
[microsoft/winget-pkgs#422665](https://github.com/microsoft/winget-pkgs/pull/422665).

The `submit-winget` job renders the manifests from the published sidecars, strips this
repository's licence header, writes each file as UTF-8 with a BOM, and hands the
directory to `wingetcreate submit`, which forks, branches, commits and opens the pull
request. It needs two things set once:

| Where | Name | Value |
|---|---|---|
| Repository variable | `WINGET_PUBLISH` | `true` |
| Repository secret | `WINGET_TOKEN` | A classic PAT with `public_repo`, owned by the account that forked `winget-pkgs` |

It is gated on the variable rather than on the secret because the `secrets` context is
not readable from a job-level `if:`, and a step that returns early paints the job green.
Until the variable is set, the release summary says **not submitted** in as many words.
The token is passed as `WINGET_CREATE_GITHUB_TOKEN` and never as `--token`, because
winget-create's own documentation warns that the flag can put the token in a log.

Pre-releases are excluded: `winget install` has no notion of a channel, so a `-rc1` tag
would become the version everyone gets.

The first pull request from a given account also asks you to sign Microsoft's CLA, once,
by replying to the bot's comment. That is the one part no token can do.

To submit by hand — a re-submission, or a version whose CI run predates the job:

1. Sync your fork:

   ```bash
   gh repo sync VKrishna04/winget-pkgs --branch master
   ```

2. Produce the submission copies. Do not copy `packaging/winget/*` by hand: the header
   strip and the BOM are exactly what a hand copy gets wrong, and the script CI uses is
   the same one:

   ```bash
   sh scripts/render-packaging.sh <version>
   sh scripts/winget-manifests.sh <fork>/manifests/v/VKrishna04/dev-prune/<version>
   ```

3. Validate before opening anything:

   ```powershell
   winget validate --manifest <the version directory>
   ```

   It must print "Manifest validation succeeded" with no warnings. A warning is exit code
   40 and a failure is 41, so neither is silent. `winget install --manifest` is the other
   half of the check, and needs
   `winget settings --enable LocalManifestFiles` from an elevated prompt first.

4. Open the pull request with the title `New version: VKrishna04.dev-prune version <version>`.

**Chocolatey.** No manifest is generated for it. 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.1.0 && git push origin :refs/tags/v1.1.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.1.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.1.0` works within 72 hours if
nothing depends on it. Otherwise `npm deprecate dev-prune@1.1.0 "reason"` and publish a
patch. Remember the seven 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