Skip to content

ci(release): productionize the crates.io release pipeline - #69

Merged
justin13888 merged 11 commits into
masterfrom
ci/crates-io-release
Aug 28, 2026
Merged

ci(release): productionize the crates.io release pipeline#69
justin13888 merged 11 commits into
masterfrom
ci/crates-io-release

Conversation

@justin13888

@justin13888 justin13888 commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

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.toml set git_only = true. Release-plz documents that field as:

The package will not be published to any cargo registry (cargo publish is
skipped).

Everything downstream was consistent with that, and none of it was going to
surface on its own:

Evidence Before
kynos, kynos-macros, kynos-openapi on crates.io none exist
git tags zero
repository Actions secrets / environments zero / zero
id-token: write or CARGO_REGISTRY_TOKEN in release-plz.yml neither
status checks on release PR #17 0secrets.GITHUB_TOKEN-authored PRs cannot start runs unattended
abandoned release-plz-* remote branches 11, from 10 hand-closed PRs
readme / keywords / categories on any crate absent
crates/*/README.md, crates/*/LICENSE absent
[package.metadata.docs.rs] absent
CI job building the published artifact none

What changed

Nine commits, deliberately one pull request: publish = true landing on master
ahead of the workflow that carries the credential would queue a failing release
run.

  • ci(release): stop skipping cargo publishgit_only deleted (it and
    publish are 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 kynos announces — 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, and
    protect_breaking_commits alone 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 Refactor heading. Hence
    an explicit breaking arm, placed after feat/fix/perf/docs so those
    keep their groups. Simulated over all 609 commits on master: 132 Added, 140
    Documentation, 73 Fixed, 2 Performance, 32 Other (exactly the breaking
    refactors), 231 skipped.
  • chore: delete the release config no tool reads.versionrc is
    standard-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-crate
    README, LICENSE copy, keywords, categories (every slug checked against the
    live registry). LICENSE copies rather than symlinks: cargo package on a
    checkout without symlink support archives the link text, silently reducing the
    MIT notice to the string ../../LICENSE.
  • build: document every feature on docs.rsall-features = true, which
    is exactly what mise run docs:check already builds with warnings denied.
  • build: package every crate the way crates.io will receive it and
    ci: gate every push on the packaged artifact building — a publish:check
    task and a Package job. Nothing here has ever built the archive rather than
    the working tree; the first thing that would have noticed a missing file was
    cargo publish itself, mid-release.
  • ci(release): publish through trusted publishing and the pinned toolchain
    permissions: {} by default, id-token: write for crates.io Trusted
    Publishing, 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 getkono so a fork
    cannot try to publish.
  • docs: write the release runbookdocs/releasing.md, plus nfr.md
    corrections so it does not claim more than the pipeline buys.
  • ci(release): authenticate release-plz with the workflow token — added
    after 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, with
    contents: write back on both jobs and pull-requests: write on release-pr.
    Trusted Publishing is unaffected — the OIDC token comes from id-token: write,
    not from that credential.

Two decisions worth challenging

No concurrency block on the release job. GitHub keeps at most one pending
job 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 to master
discard 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 in
1.92.0 (merged into doc_cfg, rust-lang/rust#138907), so adding it would fail
the docs.rs build with E0557 rather than degrade. Per-item feature badges would
need the unstable doc_cfg gate across all three crates — a separate decision.

Validation

Command Result
mise run publish:check pass — all three crates package and rebuild from their own tarballs
mise run check pass — all ten tasks. panic:check hit a transient cargo: command not found from a mise toolchain race on one run; re-run alone it exits 0
parser simulation over all 609 commits on master the [changelog] list groups exactly the 32 breaking refactors into "Other", leaving feat!/fix! in Added/Fixed
cargo metadata all three manifests parse with the new metadata
python3 -c "import tomllib; tomllib.load(...)" on release-plz.toml parses; every field checked against release-plz's config reference
YAML load of both workflows anchors resolve; package job present; both release jobs pass secrets.GITHUB_TOKEN and neither reads a repository secret
cmp of the three LICENSE copies against the root identical
tar tzf of kynos-0.1.0.crate ships LICENSE and README.md, does not ship references/

features:check was not run: no feature flag and no #[cfg] changed, and it
rewrites manifests in place, which does not mix with the cargo package runs
above.

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 with
the repository exactly as it stands today.

It buys that at one cost, recorded in docs/releasing.md as a step of the loop
rather 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_TOKEN in both jobs.

Everything else is post-merge: the release label, 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_TOKEN secret is used, now or later. Release-plz skips the
OIDC 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.rs reads ../../references, and
tests/pipeline.rs / tests/derives.rs include_str! into
../../kynos-macros/src/lib.rs — all outside the package. Confirmed
non-blocking: cargo package's verify builds lib and bins only. They will ship
in the kynos tarball and fail for anyone running cargo test against a
vendored copy. Separate defect, separate PR.

https://claude.ai/code/session_014BstCpBbWJ3RYVuJp3oXVp

`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
@justin13888
justin13888 merged commit 1188e66 into master Aug 28, 2026
7 checks passed
@justin13888
justin13888 deleted the ci/crates-io-release branch August 28, 2026 17:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant