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.
- Launch day: the first release
- The one-time setup
- Cutting a release
- What the workflow does
- The changelog contract
- Registry reference: who reviews what
- Why a Rust binary is welcome on npm and PyPI
- Channels that still need a human
- When a release goes wrong
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:
-
Create the repository as
Life-Experimentalist/dev-prune, public. Public is not cosmetic:npm publish --provenancefails 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. -
Push
main. CI runs immediately — ten jobs, includingcargo 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 -
Turn on Pages: Settings → Pages → Source → GitHub Actions. Then point DNS at it — a
CNAMErecord fordevpruneatLife-Experimentalist.github.io— and tick Enforce HTTPS once the certificate is issued.site/public/CNAMEalready 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 404means Pages has never been enabled. Once it is, that returns the live URL and the certificate state. -
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 -
Add the credentials — npm, PyPI and crates.io. Each channel needs a secret and its
*_PUBLISHvariable; 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_PUBLISHandPYPI_PUBLISHin the second. Empty variable output means nothing is configured, and the release will reach the GitHub release page only. -
Check the changelog date.
CHANGELOG.mddates 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. -
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,
shis not onPATHbut 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
-
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. -
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 | shRun the
npxline 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 binstallis 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-cleanupand read the log.The last one is the shell installer. Its Windows counterpart is a different script and a different one-liner —
curlin PowerShell is an alias forInvoke-WebRequestand 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.
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.
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:
- Create an account on npmjs.com and enable 2FA.
- 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.
- Add a trusted publisher to each package: npmjs.com → the package → Settings →
Trusted Publisher → GitHub Actions → repository
Life-Experimentalist/dev-prune, workflowrelease.yml, environment blank. - Set the repository variable
NPM_PUBLISHtotrue. Without it the publish job does not run at all, and the release reportsskippedfor 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.
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 --localIf 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.
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.
-
Create an account on pypi.org and enable 2FA.
-
Go to Your projects → Publishing → Add a new pending publisher and fill in:
Field Value PyPI Project Name dev-pruneOwner Life-ExperimentalistRepository name dev-pruneWorkflow name release.ymlEnvironment name pypiThese four values are matched exactly against the OIDC token GitHub mints. The workflow filename and the
environment: pypiblock inrelease.ymlare load-bearing — renaming either breaks publishing until PyPI is updated to match. -
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. -
Set the repository variable (not secret)
PYPI_PUBLISHtotrue: 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.
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.
-
Sign in at crates.io with GitHub.
-
On the crate page: Settings → Trusted Publishing → Add and fill in:
Field Value Repository owner Life-ExperimentalistRepository name dev-pruneWorkflow filename release.ymlEnvironment crates-ioThese are matched exactly against the OIDC token GitHub mints, so the workflow filename and the
environment: crates-ioblock inrelease.ymlare load-bearing: renaming either breaks publishing until crates.io is updated to match. -
Set the repository variable
CRATES_PUBLISHtotrue. GitHub creates thecrates-ioenvironment 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. -
Once a release has published this way, delete the old
CARGO_REGISTRY_TOKENsecret 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.
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.
- Visual Studio Marketplace. Create the publisher
VKrishna04at marketplace.visualstudio.com/manage — it must match thepublisherfield ineditors/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 secretVSCE_PAT. - 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 secretOVSX_PAT. - Set the repository variable
VSIX_PUBLISHtotrue.
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.
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.
cargo fmt --all -- --check && cargo clippy --all-targets --all-features -- -D warnings && cargo test --allCI runs all of this on every push, so the only reason to run it locally is to avoid tagging something that will fail.
Then:
-
Bump the version:
sh scripts/bump-version.sh 1.6.0. It writesCargo.tomland every file that restates the number by hand — the install scripts' offline fallback, the site's banner andllms.txt, the JSON-LD,npm/package.json, the--jsonsamples in the CLI reference and the docs that quote a whole asset filename — then refreshesCargo.lock(the release builds with--lockedand fails on a stale one) and runssh scripts/check-version.shto 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. -
Write the changelog entry. See the changelog contract. CI fails if
CHANGELOG.mdhas no section matching the version inCargo.toml, so this is enforced, not merely encouraged. -
Commit and push to
main. Wait for CI to go green. -
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.
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.
-
Bump
versionineditors/vscode/package.json. Nothing derives it fromCargo.tomland nothing should — the two version numbers are unrelated. -
Write the entry in
editors/vscode/CHANGELOG.md, under a## [<version>] - <YYYY-MM-DD>heading. The same changelog contract applies, and the samescripts/changelog-section.shextracts it — the section becomes the release body. -
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.
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"]
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-pruneOnly 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.
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, so1.0.0will not accidentally match1.0.0-rc1. - A section ends at the next
##heading. Anything between belongs to that version. - The version must equal the one in
Cargo.tomlby 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.0A 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.
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 npmThe 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.jsonThe 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-buckethomebrew-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:
-
Sync your fork:
gh repo sync VKrishna04/winget-pkgs --branch master
-
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>
-
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 --manifestis the other half of the check, and needswinget settings --enable LocalManifestFilesfrom an elevated prompt first. -
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.
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.0If 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.
- Multi-Ecosystem Distribution & Packaging Manual — the user-facing view of every install channel
- GitHub Releases, DIY Manual Install & Source Build
../CONTRIBUTING.md— development setup and the local gate../CLAUDE.md— repository conventions, including changelog voice
