Skip to content

Latest commit

 

History

History
897 lines (697 loc) · 44.5 KB

RELEASING.md

File metadata and controls

897 lines (697 loc) · 44.5 KB

🚢 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.


dev-prune — gigabytes back, nothing you can't rebuild


📋 Contents


🚀 Launch day: the first release

Every release after the first is one tag push. 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.

    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:

    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/github-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.

    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:

    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:

    curl -fsSL https://devprune.vkrishna04.me/install.sh | head -5
  5. Add the credentials — npm, PyPI and crates.io. 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:

    gh secret list; gh variable list

    The registries need nothing in the first list: npm, PyPI and crates.io all publish over OIDC, so the only long-lived secrets left are the marketplace tokens. You want NPM_PUBLISH, CRATES_PUBLISH and PYPI_PUBLISH in the second. Empty variable 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:

    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:

    & "$env:ProgramFiles\Git\bin\sh.exe" scripts/changelog-section.sh 1.2.0
  8. Tag and push.

    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:

    npx dev-prune@1.2.0 -V
    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

    Run the npx line on Windows as well as on one Unix machine. It is the only check that exercises a platform package rather than the dispatcher, and the Windows three are the ones that were missing until 1.8.0.

    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:

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

The dev-prune name is held on crates.io, PyPI and npm, and all eight npm names now exist, so npm install -g dev-prune works on every platform. Getting there took a rename: the registry refused each dev-prune-win32-* spelling as a new name, and the three became dev-prune-windows-* in 1.8.0. See Name availability.


🔑 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 no secret, Trusted Publishing CRATES_PUBLISH = true Job reports skipped
VS Code Marketplace + Open VSX VSCE_PAT and OVSX_PAT secrets VSIX_PUBLISH = true Job reports skipped
GitHub Pages (site) Automatic — Settings → Pages → "GitHub Actions" Site does not deploy

Every row but one belongs to release.yml and fires on a v* tag. The marketplaces are the exception: the VS Code extension has its own version, its own changelog and its own workflow, and it ships on a vscode-v* tag — see releasing the VS Code extension. The credential setup is here because there is one place to put credentials, not because a binary release touches that channel.

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 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, open since September 2025). See 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.

All four are done. The eight names exist, each has a trusted publisher pointing at this workflow, and NPM_PUBLISH is true; what follows is the record of how, and what to repeat if a ninth package name is ever added.

Step 2 is the one that bites, and not for the reason you expect. A new name can come back 403 Package name triggered spam detection, which reads like a rate limit and is not one. Three names were refused across a week of retries — dev-prune-win32-x64, dev-prune-win32-arm64 and dev-prune-win32-ia32 — while the four Linux and macOS names, published from the same script in the same run, went through untouched. Renaming those three to dev-prune-windows-x64, dev-prune-windows-arm64 and dev-prune-windows-x86 cleared it on the first attempt. So read the message as being about the name: try a different spelling before waiting, and open a ticket at npmjs.com/support only if every spelling is refused.

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-windows-x64
dev-prune-windows-arm64
dev-prune-windows-x86

The three Windows names do not spell out npm's win32/ia32 platform values, and the other four do. That is not an oversight: those three names were refused as new names, and the ones that replaced them follow the release assets instead. os and cpu inside each package still carry npm's values, which is what npm actually resolves against.

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:

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, suspect the name before the account. A week of retrying never cleared it for the three dev-prune-win32-* names; changing the spelling cleared it immediately. Try a different name first, and escalate to npmjs.com/support only if the account cannot create any new name at all.

--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 a refusal above can apply to some names and not others.

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

Trusted Publishing, like PyPI: no token is stored anywhere. The rust-lang/crates-io-auth-action step in release.yml trades the job's OIDC token for a crates.io publish token that expires about thirty minutes later.

  1. Sign in at crates.io with GitHub.

  2. On the crate page: Settings → Trusted Publishing → Add and fill in:

    Field Value
    Repository owner Life-Experimentalist
    Repository name dev-prune
    Workflow filename release.yml
    Environment crates-io

    These are matched exactly against the OIDC token GitHub mints, so the workflow filename and the environment: crates-io block in release.yml are load-bearing: renaming either breaks publishing until crates.io is updated to match.

  3. Set the repository variable CRATES_PUBLISH to true. GitHub creates the crates-io environment itself the first time the job runs; add a required reviewer to it if you want a human approval gate before each crates.io upload.

  4. Once a release has published this way, delete the old CARGO_REGISTRY_TOKEN secret and revoke the token on crates.io; nothing reads it any more. Turning on Require trusted publishing in the crate's settings then closes the token door entirely.

VS Code Marketplace and Open VSX

This channel is what makes ext install VKrishna04.dev-prune resolve — in two galleries, because the editors dev-prune writes rules files for are split between them. VS Code and the forks that use Microsoft's gallery read one; VSCodium, Cursor, Windsurf and the rest read Open VSX. Publishing to only one is how an extension ends up "available" for half its audience.

It belongs to release-extension.yml, not to the binary release, so nothing here fires on a v* tag.

  1. Visual Studio Marketplace. Create the publisher VKrishna04 at marketplace.visualstudio.com/manage — it must match the publisher field in editors/vscode/package.json. The token is an Azure DevOps personal access token, not a GitHub one: sign in at dev.azure.com, User settings → Personal access tokens → New token, Organization: all accessible organizations, scope Marketplace → Manage, and nothing else. Azure caps the lifetime, so this one expires and the release will fail loudly when it does — that is the intended failure, and re-issuing takes a minute. Save it as the repository secret VSCE_PAT.
  2. Open VSX. Sign in at open-vsx.org with GitHub, agree to the publisher agreement, and create an access token under your profile. Claim the namespace once, from any machine that has the token: npx ovsx create-namespace VKrishna04 -p <token>. Save the token as the repository secret OVSX_PAT.
  3. Set the repository variable VSIX_PUBLISH to true.

Both marketplaces read the version from editors/vscode/package.json and ignore the tag entirely, so the workflow refuses a vscode-v* tag that disagrees with the manifest rather than producing a release page named one thing and two listings named another. Re-publishing a version that is already up stays green — re-running a release after the other gallery broke must not turn into a failure — but it writes a warning naming the file to bump.

Name availability

As of 2026-08-24 every name this project needs is held. dev-prune is published on PyPI, crates.io and npm, and all seven npm platform packages exist alongside the dispatcher, so the release publishes a complete npm channel with nothing skipped.

Claiming the last three took a rename rather than patience. npm's new-name throttle refused each dev-prune-win32-* spelling with E403 — Package name triggered spam detection while the four Linux and macOS names published from the same script in the same run went through, so the refusal tracked the name and not the payload. The dev-prune-windows-* spellings were accepted first try.

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-windows-x64 held (1.7.0) n/a n/a
dev-prune-windows-arm64 held (1.7.0) n/a n/a
dev-prune-windows-x86 held (1.7.0) 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

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

    git tag -a v1.2.0 -m "v1.2.0"
    git push origin v1.2.0

That is the release. A tag matching v[0-9]* triggers everything below. The pattern requires the digit because the extension tags as vscode-v<version>, which a bare v* would also match — and this workflow would then try to build a CLI release out of it.

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.

Releasing the VS Code extension

The extension is a separate product on a separate cadence, so it is a separate release. The CLI ships often and the extension rarely; riding along with every v* tag meant republishing an identical .vsix over itself most of the time, and shipping an extension fix meant cutting a CLI release with nothing in it.

  1. Bump version in editors/vscode/package.json. Nothing derives it from Cargo.toml and nothing should — the two version numbers are unrelated.

  2. Write the entry in editors/vscode/CHANGELOG.md, under a ## [<version>] - <YYYY-MM-DD> heading. The same changelog contract applies, and the same scripts/changelog-section.sh extracts it — the section becomes the release body.

  3. Commit and push to main, then tag:

    git tag -a vscode-v0.4.0 -m "vscode-v0.4.0"
    git push origin vscode-v0.4.0

release-extension.yml packages the .vsix (running vscode:prepublish, so the bundled schema comes from that commit), attests it, publishes a release, and pushes to both marketplaces when VSIX_PUBLISH is on.

That release is deliberately not marked latest, and that is load-bearing rather than cosmetic. Every installed copy of dev-prune reads releases/latest to decide whether it is out of date, and takes the version out of the tag name; a vscode-v0.4.0 sitting there would leave every devp update in the world unable to compare, for as long as that release stayed newest. So releases/latest is always the CLI, and the extension fallback in devp setup walks the release listing for the newest vscode-v* instead.


⚙ What the workflow does

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 --> hygiene["hygiene: bait-string sweep of every executable"]
    hygiene --> publish["publish: GitHub Release"]
    publish --> npm["publish-npm: 7 packages"]
    publish --> pypi["publish-pypi: 6 wheels"]
    publish --> crate["publish-crate: cargo publish"]
Loading

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

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

Only the archives are attested here. A sidecar is a checksum of an attested file, so signing it adds nothing. The .vsix is attested the same way, by its own workflow.

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.

hygiene — greps every executable that will ship — the raw binaries, plus everything unpacked from the Windows zips, devpw.exe included — for the strings an antivirus reads as dropper tooling: PowerShell obfuscation flags, Defender-exclusion cmdlets, process-injection API names. The list and the rule for changing it live in scripts/check-binary-hygiene.sh. Unlike the VirusTotal submission later in the workflow, this job gates the release, because a bait string in a shipping binary is a build defect to fix, not a reputation problem to wait out.

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 with SemVer:

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

Verify a section renders the way you expect before tagging:

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 held on all three by the published releases, npm included.

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.resolves the real executable and spawns 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:

uv tool install dev-prune@latest   # installs or updates, 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

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

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

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 submission under review is microsoft/winget-pkgs#422809, opened for 1.6.0. The 1.5.0 submission before it, #422665, was closed rather than amended: validation had flagged the first-run self-copy, and the answer to that was a release, not an edit to a manifest.

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:

    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:

    sh scripts/render-packaging.sh <version>
    sh scripts/winget-manifests.sh <fork>/manifests/v/VKrishna04/dev-prune/<version>
  3. Validate before opening anything:

    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:

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