Release process
This is the single maintainer-facing reference for how a release happens: what
you click, what .github/workflows/release.yml does step by step, what repo
configuration it needs, and how to recover from a failure partway through. It
supersedes the old root-level release-token-bypass.md (folded in below as
its own section).
Triggering a release
The workflow is workflow_dispatch-only — it has no push:/tag trigger, so it
never runs on its own. In the Actions UI, run Release and pick a version
bump (patch / minor / major). It always runs from main (an explicit
"Require main" step rejects any other ref, even if you pick one from the
dropdown). The version number is never typed by hand: the bump you pick is
applied to whatever Cargo.toml currently says, and that derived version then
drives the commit, the tag, and the GitHub Release, so the three can never
drift apart. The very first release (no v* tag exists yet) ignores the
chosen bump and ships the current Cargo.toml version as-is.
What the release job does, step by step
- Mint GitHub App token (conditional). If repo variable
RELEASE_APP_IDis set, mints a short-lived GitHub App installation token, used further down to push as the App instead of the defaultGITHUB_TOKEN. See "GitHub App bypass for a protectedmain" below for why and how to set this up. Skipped entirely when the variable is empty. - Checkout with full history (
fetch-depth: 0, needed for tag-based version math and for git-cliff to walk commits) and the token from step 1 (orGITHUB_TOKENas a fallback) so the later push carries the right identity. - Require main — fails fast if the workflow was dispatched from anything
other than
refs/heads/main. - Preflight — require
CRATES_IO_TOKEN— fails fast, before any of the slower work below, if theCRATES_IO_TOKENrepo secret isn't set. - Determine version — parses the current
versionfromCargo.toml. If a priorv*tag exists, applies the chosen bump (major/minor/patch) to it; otherwise (first release) keeps the currentCargo.tomlversion unchanged. Exposesversion,tag(v<version>) andprev_tagas step outputs. - Verify tag does not exist — refuses to proceed if
v<version>is already tagged. - Bump version —
cargo set-versionwrites the computed version intoCargo.toml/Cargo.lock. A no-op on the first release. - Auto-fill empty
[Unreleased]from git log — manualCHANGELOG.mdentries always win. Only when the## [Unreleased]section inCHANGELOG.mdhas no real bullets does this step generate one viagit-cliff --config cliff.toml, walking commits sinceprev_tag(or full history on the first release) and bucketing them by commit-message prefix percliff.toml's rules (feat/add→ Added,fix/bug→ Fixed,remove/delete/drop→ Removed,refactor/change/update/... → Changed,doc/chore/test/style→ skipped, everything else falls back to Changed). Fails the run if there is nothing release-worthy to put there. - Extract release notes — curates the (now non-empty)
[Unreleased]body down to only the### Headersections that have at least one real bullet, dropping placeholder-lines, and writes the result to$RUNNER_TEMP/release-notes.md— deliberately outside the working tree so it neither dirties it (which would abortcargo publish) nor ends up packaged into the crate. - Promote
[Unreleased]inCHANGELOG.md— renames the curated## [Unreleased]heading to## [<version>] - <date>, leaves a fresh empty[Unreleased]above it, and rewrites the Keep-a-Changelog reference links (compare link on subsequent releases, tag link on the first one). - Commit version bump + changelog — commits
Cargo.toml,Cargo.lockandCHANGELOG.mdlocally (not pushed yet, so the next step can verify against a clean tree). - Verify the crate publishes (dry run) —
cargo publish --locked --dry-run, catching build/packaging/metadata errors before the irreversible step below. - Publish to crates.io —
cargo publish --locked, retried up to 3 attempts on transient failures. An "already uploaded"/"already exists" response from cargo (a prior run that published but failed before tagging) is treated as success, so a re-run can still proceed to tag + Release. - Tag and push — only after the crate is live: tags
v<version>and pushes the commit + tag tomainatomically (git push --atomic), so a rejected push can never advance the branch while dropping the tag (or vice versa). - Publish GitHub Release — creates (or, on retry, edits) the GitHub
Release for the tag, using the curated notes file from step 9. Retried up
to 3 attempts; if it still fails, the job error tells you to finish it by
hand with
gh release create <tag> --notes-file <notes>and explicitly not to re-run the workflow, since a re-run would bump to the next version from the now-updatedmainand strand this release.
What the build-artifacts job does
Strictly downstream (needs: release) of the job above — it never bumps,
publishes to crates.io, tags, or creates the Release; it only builds and
attaches assets to the Release the release job already created. It fans out
across a fail-fast: false matrix of seven targets:
x86_64-pc-windows-msvc,aarch64-pc-windows-msvc(Windows)x86_64-unknown-linux-gnu,aarch64-unknown-linux-gnu(Linux glibc; the aarch64 leg cross-compiles withgcc-aarch64-linux-gnu)x86_64-unknown-linux-musl(static Linux, dependency-free binary; built onubuntu-latest)aarch64-unknown-linux-musl(static Linux, dependency-free binary; built natively on theubuntu-24.04-armhosted runner instead of cross-compiling, since apt has noaarch64-linux-muslcross-gcc package)aarch64-apple-darwin(macOS, Apple Silicon)
For each target, the job:
- Checks out the exact tagged commit (
ref: needs.release.outputs.tag), so the binary embeds the released version. - Builds
cargo build --release --locked --target <triple>(installing a cross linker first for the aarch64-glibc leg; the two musl legs each installmusl-toolsfor their own native architecture instead). - Packages the binary,
build.rs's generated shell completions and man pages, and aschema/directory (schema.json+events.jsonl, copied verbatim from the trackedfixtures/schema/v1/— notbuild.rsoutput, so the fixture stays the single source of truth) into a per-target archive namedprocesskit-cli-v<version>-<triple>(.zipon Windows via7z,.tar.gzelsewhere viatar). - Computes a
<archive>.sha256checksum right next to the archive (sha256sumon Linux/Windows-Git-Bash;shasum -a 256fallback on macOS, which ships BSD tools withoutsha256sum). - Uploads the archive + checksum to the Release with
gh release upload --clobber— idempotent, so a re-run of this job replaces rather than duplicates the assets. - Records a signed SLSA build-provenance attestation for the archive via
actions/attest-build-provenance, kept last in the leg since the archive and checksum are already on the Release before it runs; a downloader verifies it withgh attestation verify <archive> --repo ZelAnton/ProcessKit-CLI.
Because contents: write/id-token: write/attestations: write are
re-declared at the job level here (a job-level permissions: block replaces,
rather than merges with, the top-level contents: write), the release job
above is unaffected and keeps only the top-level permission it already had.
What the package-manifests job does
This final job waits for the release and every archive-matrix leg to settle. It still runs when a leg failed only in its final provenance-attestation step, because the archive and checksum were already uploaded; a missing required checksum instead fails generation honestly. The job never publishes to an external package repository. It:
- Checks out the exact release tag and downloads the Release's archive
.sha256sidecars. - Runs
scripts/generate_package_manifests.py, which accepts only a SemVer release and verifies that every sidecar names the exact archive whose URL is being embedded. - Produces the three-file
ZelAnton.ProcessKitCLIwinget manifest, an architecture-aware Scoopprocesskit-cli.json, and a Homebrewprocesskit-cli.rbformula for macOS Arm64 plus Linux x86_64/Arm64. The Linux formula deliberately uses the static musl archives rather than inheriting the release runner's glibc floor, for both architectures. - Syntax-checks the JSON and Ruby output, packages the complete directory as
processkit-cli-v<version>-package-manifests.tar.gz, and checksums that bundle. - Attaches the individual manifests, bundle, and bundle checksum to the
existing GitHub Release with
--clobberidempotence.
Winget retains its external microsoft/winget-pkgs review. Scoop and Homebrew
receive ready-to-copy files for an account-owned bucket/tap, but those separate
repositories are not mutated and need no credentials in this project. This
keeps all external channel availability out of the crate/tag/Release critical
path.
Required repository configuration
CRATES_IO_TOKEN(secret) — required. Publishing to crates.io fails the preflight check immediately if it's missing.RELEASE_APP_ID(variable) +RELEASE_APP_PRIVATE_KEY(secret) — optional. Only needed oncemainis protected by a ruleset that would otherwise reject the release commit/tag push (see the next section). UntilRELEASE_APP_IDis set, the "Mint GitHub App token" step is skipped and the push falls back to the defaultGITHUB_TOKEN— fine whilemainis unprotected.- The
GITHUB_TOKENused to create/edit the GitHub Release and to upload build artifacts and package manifests is the default one GitHub Actions provides; no setup needed beyond the job-levelpermissions:blocks already in the workflow.
Recovering from a failure partway through a release
The steps are ordered specifically so that the one truly irreversible action —
publishing to crates.io — happens before anything is pushed or tagged, and
so build-artifacts only ever adds optional, re-buildable assets on top of an
already-complete release:
- Failure before "Publish to crates.io" (version bump, changelog
generation/promotion, dry-run publish, etc.): nothing has left the runner.
Just re-run the workflow from the same
bumpinput; the earlier local commit is discarded with the job. - Failure during/after "Publish to crates.io" but before "Tag and push":
the crate version is live on crates.io but
mainhasn't moved and no tag exists yet. Re-run the workflow — the "Publish to crates.io" step treats an "already uploaded"/"already exists" response as success and proceeds to tag and push, so this is safe and does not attempt a duplicate publish. - Failure during "Tag and push": the atomic push means either both the
commit and the tag landed on
main, or neither did — never a half state. If neither landed, re-run as above. If it actually did land but the step still reported failure (e.g. a flaky follow-up check), inspectmainand thev*tags before re-running to avoid a wasted crates.io no-op. - Failure during "Publish GitHub Release": crates.io is published and the
tag is pushed —
cargo install processkit-cliandcargo add processkit-clialready work. Per the job's own error message: finish the Release by hand withgh release create <tag> --notes-file <notes>and do not re-run the workflow, since a re-run would bump from the already-advancedmainand ship the next version, stranding this one. - Failure in
build-artifacts(one or more matrix legs): the release itself (crate + tag + GitHub Release) is unaffected — this job never bumps/publishes/tags/creates the Release.cargo install processkit-cliand the GitHub Release page both already work; only the prebuilt archive for the failed target(s) is missing. Either re-run just that job (the upload is--clobber-idempotent and the attestation is regenerated), or build it by hand (cargo build --release --target <triple>, package it with the completions/man/schema/trees + checksum it the same way the job does — see "What thebuild-artifactsjob does" above for the exact contents — thengh release upload <tag> <archive> <archive>.sha256 --clobber). - Failure in
package-manifests: the crate, tag, Release, and prebuilt archives are already published. Repair or upload any missing checksum sidecar, then re-run this job; generation is deterministic from the tagged script plus those sidecars, and every upload uses--clobber. Do not trigger a new release merely to repair these distributor inputs.
GitHub App bypass for a protected main
.github/workflows/release.yml pushes the release commit (the version bump +
promoted CHANGELOG.md) and the v<version> tag straight to main. Once you
protect main with a rule that requires pull requests, that direct push is
rejected — for every actor except those on the rule's bypass list.
You cannot put the built-in github-actions[bot] on a bypass list (it is a
system actor, not an addressable App), and a personal access token expires and
ties the push to a human. The supported path is a GitHub App: the workflow
mints a short-lived installation token (auto-revoked, no rotation), pushes as
the App, and the App sits in the ruleset's bypass list.
When main is not protected, none of this is needed — the workflow falls
back to the default GITHUB_TOKEN and the push just works. Set this up only
once you turn on PR-required branch protection.
One-time setup
-
Create a GitHub App (Settings → Developer settings → GitHub Apps → New GitHub App). Minimal config:
- Repository permissions → Contents: Read and write (to push the commit
- tag). Nothing else is required.
- No webhook needed (uncheck Active).
- It can be private to your account/org; it does not need to be public.
- Repository permissions → Contents: Read and write (to push the commit
-
Generate a private key for the App (App settings → Private keys → Generate a private key) and download the
.pem. -
Install the App on the target repository (App settings → Install App → pick the repo).
-
Add the credentials to the repo (repo Settings → Secrets and variables → Actions):
- Variable
RELEASE_APP_ID= the App's numeric App ID. - Secret
RELEASE_APP_PRIVATE_KEY= the full contents of the.pem(including the-----BEGIN/END-----lines).
The workflow's "Mint GitHub App token" step is guarded by
if: ${{ vars.RELEASE_APP_ID != '' }}, so until the variable exists the step is skipped and the push uses the default token. - Variable
-
Add the App to the branch-protection bypass list. Use a repository ruleset (repo Settings → Rules → Rulesets), which — unlike the older "branch protection rules" screen — supports a bypass list:
- Target branch
main, enable Require a pull request before merging. - Under Bypass list, add your App (it appears once installed).
The App can now push directly to
main; everyone else still goes through a PR. - Target branch
Verifying
Dispatch the release workflow (Actions → Release → Run workflow → pick a
bump). The Mint GitHub App token step should run (not skip), and the Tag
and push step should push the Release v<version> commit and tag to main
without a protection error. If the push is rejected, re-check that the App is
installed on the repo and is actually listed in the ruleset's bypass list, and
that RELEASE_APP_ID / RELEASE_APP_PRIVATE_KEY are set on the repo (not the
org, unless the App is org-owned).
This is ordinary setup documentation: it applies only if
mainis protected by a ruleset that would otherwise reject the release workflow's tag push.