ci(release): productionize the crates.io release pipeline - #69
Merged
Conversation
`release-plz.toml` set `git_only = true`, whose documented effect is that "the package will not be published to any cargo registry (`cargo publish` is skipped)". Tagging and the GitHub release would still have happened; publishing never would, which is why none of the three crate names exists on crates.io and why `.github/SECURITY.md` still says nothing is published. `git_only` and `publish` cannot both be true for a package, so `git_only` is deleted rather than contradicted, and `publish = true` takes its place. Alongside it, the settings a release needs to be evidence rather than ceremony. `publish_all_features` because `cargo publish` verifies by rebuilding its own tarball and would otherwise compile a fraction of a thirty-feature crate. `semver_check` because a pre-1.0 break should reach the reviewer as a list rather than a surprise -- with the two things it does not buy written next to it. `dependencies_update`, `release_always` and `semver_check` state values that are already the current behaviour, which is worth the lines here: each is a decision someone would otherwise have to rediscover. All three crates get a `[[package]]` block: release-plz decides "already released" by looking for the tag, so an untagged crate would replay its whole history into the next release's notes. Only `kynos` announces -- the three versions are always equal and cut from one commit, so three GitHub releases would be three copies of one announcement. Claude-Session: https://claude.ai/code/session_014BstCpBbWJ3RYVuJp3oXVp
Release-plz's default parser routes everything that is not `feat` or `fix` to one "Other" heading. Counted on the changelogs already generated for 0.1.0, that is 268 of 486 entries across the three crates -- more than half the release notes under a heading that says nothing -- and twelve of those entries are merge commits reproduced verbatim, branch name and all, because pull requests here are merged rather than squashed. The public record of a release is not a git log. Breaking commits are the part worth getting right. 32 of the 46 in this history are `refactor!:`, a type this config skips, and `protect_breaking_commits` alone does not rescue them into a useful place: git-cliff returns on the first parser that matches, so a protected commit caught by a skip rule keeps no group and surfaces under a heading named after its raw type -- the "Refactor" section the skip rules exist to avoid. So the parser list carries an explicit breaking arm, placed after `feat`, `fix`, `perf` and `docs` so those keep their own groups, and the flag stays on as the backstop for a skip rule someone adds later. Simulated over all 609 commits on master, the result is 132 Added, 140 Documentation, 73 Fixed, 2 Performance, 32 Other -- the 32 being exactly the breaking refactors -- and 231 skipped. `.versionrc` records this same intent for standard-version, a tool this repository has never run. This is that intent in the file release-plz actually reads, with one departure: `docs` is shown rather than hidden, because rustdoc is this framework's contract surface. This only affects newly generated entries, and nothing is merged yet. Claude-Session: https://claude.ai/code/session_014BstCpBbWJ3RYVuJp3oXVp
`.versionrc` is standard-version and commitizen configuration. Neither has ever run here: convco is invoked only as `convco check`, and release-plz reads `release-plz.toml`. It has therefore been describing a changelog nothing generates, and describing it wrongly -- its mapping hides `docs` and puts `perf` under Other, neither of which matches what release-plz actually emitted. `.github/dependabot.yml` named it as the thing that files `chore(deps)` commits, so that comment now names the parsers that really do. Claude-Session: https://claude.ai/code/session_014BstCpBbWJ3RYVuJp3oXVp
All three crates would have published with a blank page: no README, no keywords, no categories, so nothing to read and nothing to find them by. And no licence text -- `license = "MIT"` puts an SPDX string in the index, while MIT requires the notice to travel with the copy, and Cargo does not lift the workspace `LICENSE` into a member's tarball. Each crate gets its own README rather than a link to the repository one, because crates.io resolves relative links against the package's directory: every `docs/...` link in the root README would resolve under `crates/kynos/` and 404. `readme` is named explicitly so a deleted file is a manifest error rather than a silently blank page. The `LICENSE` files are real copies, not symlinks. `cargo package` on a checkout without symlink support archives the link text, which would turn the notice into the string `../../LICENSE` without failing anything. Every category slug is checked against the live registry. `web-programming::http-server` exists as a subcategory of `web-programming`; the other seven are top-level. `swagger` is deliberately not a keyword despite being the highest-traffic term here -- OpenAPI 3.0 is permanently out of scope, so claiming it would be the kind of misdescription this framework exists to prevent. `[workspace.package]` is untouched. `homepage` would be redundant with `repository`, `documentation` is auto-linked to docs.rs when unset, and an inherited `readme` would resolve against the workspace root -- outside every package. Claude-Session: https://claude.ai/code/session_014BstCpBbWJ3RYVuJp3oXVp
Without `[package.metadata.docs.rs]`, docs.rs builds a crate at its default features. For `kynos` that publishes a framework with no TLS, no codec beyond JSON, no scalar format, no static assets and no escape hatch -- most of the surface `docs/` describes. For `kynos-openapi` it publishes the 3.1 model alone, since every 3.2 field is `#[cfg]`-gated. For `kynos-macros` it leaves `assets!` undocumented. `all-features` rather than an explicit list: a list is a second copy of the feature table that drifts, and `full` is not the right list anyway -- it deliberately excludes `unchecked` and `assets-fs`, whose items are public API. It is also exactly what `mise run docs:check` already builds with warnings denied, so what docs.rs publishes is what CI proved. No `rustdoc-args` and no `#![cfg_attr(docsrs, ...)]`. The per-item feature badges need rustdoc's unstable `doc_cfg` gate, which is a nightly-only commitment across three crates and a separate decision. The comment records why the older `feature(doc_auto_cfg)` spelling must not be reintroduced: rustc removed that gate in 1.92.0, merged into `doc_cfg`, so it would fail the docs.rs build with E0557 rather than degrade. Claude-Session: https://claude.ai/code/session_014BstCpBbWJ3RYVuJp3oXVp
Nothing in this repository ever built the artifact that gets published. Every task builds the working tree, so a file the code needs but the archive omits is invisible to all of them -- and the first thing that would notice is `cargo publish` itself, mid-release. `cargo package --workspace` writes each member's `.crate` and rebuilds it from the extracted tarball. `--workspace` rather than per-crate runs because none of the three is on crates.io yet, so a per-crate run cannot resolve `kynos-openapi = "0.1.0"`; multi-package packaging stages the archives in a local registry and resolves each member against the ones before it, which is the same mechanism and the same order `cargo publish` will use. `--all-features` because the verify step at default features compiles a fraction of a thirty-feature crate. The loop in front asserts each archive carries its `LICENSE`, and only that: `readme` in each manifest already makes a missing README an error `cargo package --list` itself raises, while nothing at all points at `LICENSE` -- and MIT requires the notice to travel with the copy. The member names come from `cargo metadata`, so a fourth crate is covered the day it is added rather than the day someone remembers the list. Not added to `mise run check`: three from-scratch all-feature builds belong with `features:check`, `features:targets` and `msrv:check`, which this file already keeps in CI rather than in the pre-handoff loop. Claude-Session: https://claude.ai/code/session_014BstCpBbWJ3RYVuJp3oXVp
`publish:check` is only a gate if something runs it. This is the first job in the repository's history that builds what crates.io will receive rather than what the working tree happens to contain. It restores no Cargo cache: `cargo package` verifies by extracting each `.crate` and building it under `target/package/<crate>/target`, which `Swatinem/rust-cache` neither keys nor prunes, so caching it would spend the repository's cache budget on three extracted source trees for a job that rebuilds from a pristine extraction by design. The composite action's input description now covers that second reason to leave the key empty, since until now it meant "runs no Cargo build". Claude-Session: https://claude.ai/code/session_014BstCpBbWJ3RYVuJp3oXVp
Four things this workflow could not do. It could not publish: no `id-token: write` and no registry token, so even with `git_only` gone the release step had no credential. It now mints an OIDC token for crates.io Trusted Publishing, which release-plz exchanges itself, reuses across all three crates and revokes when the run ends. No `CARGO_REGISTRY_TOKEN` is read here, deliberately and permanently: release-plz skips the exchange whenever a registry token is present in the environment, so wiring one through -- even as a fallback for the one publish trusted publishing cannot do, the first -- would let an empty or forgotten secret silently disable it. That publish happens from a workstation instead. It could not get the release pull request tested. A pull request opened with `secrets.GITHUB_TOKEN` cannot start a run unattended: since June 2026 GitHub creates the runs in an approval-required state a human has to click through, and before that it created none. Either way PR #17 has sat with zero checks -- the one pull request whose merge publishes to crates.io was the only one in the repository nothing had ever tested. Both jobs now authenticate as a GitHub App, via `client-id` rather than the action's deprecated `app-id`. It compiled on a toolchain no other job used. `dtolnay/rust-toolchain` was unpinned and floating, while every CI job builds on the mise-pinned 1.97.1; with `publish_all_features`, `cargo publish`'s verify is a full all-features build, so this job is a compiler-visible gate and belongs on the same toolchain as the rest. It granted more than it needed and pinned less. Permissions now default to nothing and each job asks for what it uses -- `contents: read` is enough because release-plz pushes commits, branches and tags through the GitHub API, which is the documented condition for `persist-credentials: false`. The action is pinned to a commit rather than the moving `v0.5` tag: this is the one step in the repository that holds a publishing credential. Both jobs also refuse to run outside `getkono`, so a fork's master cannot attempt to publish these crates. Claude-Session: https://claude.ai/code/session_014BstCpBbWJ3RYVuJp3oXVp
The pipeline can now publish, but half of a first release happens outside the repository and none of it was written down: the GitHub App and its secrets, the `release` label, the eleven abandoned release branches, and the first publish crates.io requires a human to perform because Trusted Publishing cannot create a new crate. `releasing.md` records that setup in order, the loop it leaves behind, and the two things the pipeline does not check -- cargo-semver-checks comparing default features only and failing open, and the changelog's skip rules, which are safe only because the breaking arm and `protect_breaking_commits` between them keep breaking commits listed. Two ordering constraints are called out because getting either wrong is expensive: the App has to exist before this workflow reaches `master`, or both jobs fail on every push; and the tags for the hand-published 0.1.0 have to be pushed by hand, because release-plz filters already-published packages out before tagging and every later changelog is generated from the commits since the previous tag. `nfr.md` is corrected to match. `cargo-semver-checks` moves off the not-yet-present list and its two rows become `partial`, a status the legend now defines. The row that claimed releases were "gated" on semantic-version correctness now says what actually happens: a verdict in the release pull request body. The archive-builds-from-a-pristine- extraction guarantee is new and `enforced`. `docs/README.md` gains the row and marks the runbook as the one document here that is normative at no heading. Claude-Session: https://claude.ai/code/session_014BstCpBbWJ3RYVuJp3oXVp
Both jobs minted a GitHub App token and used it in place of `secrets.GITHUB_TOKEN`, for one reason: a pull request opened with the workflow token cannot start workflow runs unattended, so the release pull request -- the one whose merge publishes to crates.io -- arrives with no checks against it. Release PR #17 has demonstrated that since 2026-08-10. That App does not exist, and the repository has no secrets at all, so as written both jobs failed at the token step on every push to `master`. Rather than make an App a precondition of merging this, the jobs now authenticate with `secrets.GITHUB_TOKEN` and the missing CI becomes a step of the loop: `docs/releasing.md` has a human approve the held runs, or close and reopen the release pull request so the runs are attributed to a person, before reading the changelog. `contents` returns to `write` on both jobs and `pull-requests` to `write` on `release-pr`, because the workflow token is now what pushes the branch, opens the pull request, writes the tags and cuts the release. `permissions: {}` still denies everything neither job names. Trusted Publishing is untouched. The OIDC token is minted from `id-token: write`, not from the credential in `GITHUB_TOKEN`, so crates.io authentication does not depend on this change -- and no `CARGO_REGISTRY_TOKEN` is read here, still deliberately and permanently. Repository-wide Actions permission to create pull requests is recorded in both files as not being an alternative: it grants the right to open the pull request, not the ability of that pull request to trigger runs. Claude-Session: https://claude.ai/code/session_01UEeW8gsbHK5PH5dztw6bBo
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
An audit of the release pipeline, and the work it turned up. The short version:
the pipeline was configured, in writing, never to reach crates.io.
release-plz.tomlsetgit_only = true. Release-plz documents that field as:Everything downstream was consistent with that, and none of it was going to
surface on its own:
kynos,kynos-macros,kynos-openapion crates.ioid-token: writeorCARGO_REGISTRY_TOKENinrelease-plz.ymlsecrets.GITHUB_TOKEN-authored PRs cannot start runs unattendedrelease-plz-*remote branchesreadme/keywords/categorieson any cratecrates/*/README.md,crates/*/LICENSE[package.metadata.docs.rs]What changed
Nine commits, deliberately one pull request:
publish = truelanding onmasterahead of the workflow that carries the credential would queue a failing release
run.
ci(release): stop skipping cargo publish—git_onlydeleted (it andpublishare mutually exclusive),publish_all_features,semver_check,dependencies_update,pr_labels, and a[[package]]block per crate.Tagging all three matters: release-plz decides "already released" by looking
for the tag, so an untagged crate replays its whole history into the next
release's notes. Only
kynosannounces — the three versions are always equal.ci(release): keep the changelog to what a reader is looking for—measured against the changelogs already generated on PR chore: release v0.1.0 #17, 268 of 486
entries land under one "Other" heading, twelve of them merge commits
reproduced verbatim. Breaking commits are the part worth getting right: 32 of
the 46 in this history are
refactor!:, a skipped type, andprotect_breaking_commitsalone does not rescue them anywhere useful —git-cliff returns on the first matching parser, so a protected commit caught
by a skip rule keeps no group and surfaces under a
Refactorheading. Hencean explicit breaking arm, placed after
feat/fix/perf/docsso thosekeep their groups. Simulated over all 609 commits on
master: 132 Added, 140Documentation, 73 Fixed, 2 Performance, 32 Other (exactly the breaking
refactors), 231 skipped.
chore: delete the release config no tool reads—.versionrcisstandard-version config that nothing has ever run, describing a changelog
format release-plz does not emit.
build: give each crate the metadata crates.io renders— per-crateREADME, LICENSE copy,
keywords,categories(every slug checked against thelive registry). LICENSE copies rather than symlinks:
cargo packageon acheckout without symlink support archives the link text, silently reducing the
MIT notice to the string
../../LICENSE.build: document every feature on docs.rs—all-features = true, whichis exactly what
mise run docs:checkalready builds with warnings denied.build: package every crate the way crates.io will receive itandci: gate every push on the packaged artifact building— apublish:checktask and a
Packagejob. Nothing here has ever built the archive rather thanthe working tree; the first thing that would have noticed a missing file was
cargo publishitself, mid-release.ci(release): publish through trusted publishing and the pinned toolchain—
permissions: {}by default,id-token: writefor crates.io TrustedPublishing, the repository's
own pinned toolchain instead of an unpinned
dtolnay/rust-toolchain@stable,the action pinned to a commit SHA, and both jobs scoped to
getkonoso a forkcannot try to publish.
docs: write the release runbook—docs/releasing.md, plusnfr.mdcorrections so it does not claim more than the pipeline buys.
ci(release): authenticate release-plz with the workflow token— addedafter review. Both jobs minted a GitHub App token, which made an App that does
not exist a precondition of merging; they now use
secrets.GITHUB_TOKEN, withcontents: writeback on both jobs andpull-requests: writeonrelease-pr.Trusted Publishing is unaffected — the OIDC token comes from
id-token: write,not from that credential.
Two decisions worth challenging
No
concurrencyblock on the release job. GitHub keeps at most one pendingjob per concurrency group and cancels the older when a newer arrives — even with
cancel-in-progress: false— so grouping it would let a third push tomasterdiscard the release queued by the second. Release-plz documents the same. The PR
job keeps its group, where a superseded run loses nothing.
No
#![cfg_attr(docsrs, feature(doc_auto_cfg))]. rustc removed that gate in1.92.0 (merged into
doc_cfg, rust-lang/rust#138907), so adding it would failthe docs.rs build with
E0557rather than degrade. Per-item feature badges wouldneed the unstable
doc_cfggate across all three crates — a separate decision.Validation
mise run publish:checkmise run checkpanic:checkhit a transientcargo: command not foundfrom a mise toolchain race on one run; re-run alone it exits 0master[changelog]list groups exactly the 32 breaking refactors into "Other", leavingfeat!/fix!in Added/Fixedcargo metadatapython3 -c "import tomllib; tomllib.load(...)"onrelease-plz.tomlpackagejob present; both release jobs passsecrets.GITHUB_TOKENand neither reads a repository secretcmpof the threeLICENSEcopies against the roottar tzfofkynos-0.1.0.crateLICENSEandREADME.md, does not shipreferences/features:checkwas not run: no feature flag and no#[cfg]changed, and itrewrites manifests in place, which does not mix with the
cargo packagerunsabove.
Before merging — nothing
This needs no secret and no App. Both jobs authenticate with
secrets.GITHUB_TOKEN, which every run already has, so merging this is safe withthe repository exactly as it stands today.
It buys that at one cost, recorded in
docs/releasing.mdas a step of the looprather than as a footnote: GitHub does not let a pull request opened with the
workflow token start workflow runs unattended, so the release pull request
arrives with no checks and a human has to approve the held runs, or close and
reopen it, before reading the changelog. Release PR #17 has sat at zero checks
since 2026-08-10 for exactly this reason — and note that repository-wide Actions
permission to create pull requests, which this repository already grants, does
not change it: it permits opening the pull request, not that pull request
triggering runs. Closing the gap later means an App or a fine-grained PAT in
place of
secrets.GITHUB_TOKENin both jobs.Everything else is post-merge: the
releaselabel, deleting the 11 abandoned branches,publishing 0.1.0 from a workstation (Trusted Publishing cannot create a new
crate) and pushing its three tags by hand, registering Trusted Publishers for all
three crates, and updating the two statements that still say nothing is
published.
No
CARGO_REGISTRY_TOKENsecret is used, now or later. Release-plz skips theOIDC exchange whenever a registry token is present in the environment, so wiring
one in for the single publish trusted publishing cannot do would let an empty or
forgotten secret silently disable it for every release afterwards.
Reported, not fixed
crates/kynos/tests/metaschema.rsreads../../references, andtests/pipeline.rs/tests/derives.rsinclude_str!into../../kynos-macros/src/lib.rs— all outside the package. Confirmednon-blocking:
cargo package's verify builds lib and bins only. They will shipin the
kynostarball and fail for anyone runningcargo testagainst avendored copy. Separate defect, separate PR.
https://claude.ai/code/session_014BstCpBbWJ3RYVuJp3oXVp