DocsOperate
#Releasing cratefield-* to crates.io
The pipeline is wired; the only things left are one-time crates.io setup steps that need a human with the owner account. This page is the exact list (issue #15). Everything here is for the owner — the credential-gated steps below run from this repository's CI only once it is configured. The one exception that needs nothing: the crates.io resolve workflow already runs daily with no credentials, checking the published set on its own (see "The published set" below).
#How the pipeline works
.github/workflows/release.yml runs under workflow_run, once CI on a main commit has gone green — never on the push itself, so a release cannot begin on a commit that failed cargo test:
release-plz release-prcollects the conventional commits since the last release and opens (or updates) a release PR: per-crate version bumps, per-crateCHANGELOG.md, rewritten workspace dependency requirements. Becausedocs/COMPATIBILITY.mdis generated from those versions, the workflow then regenerates it on the release PR's branch (only withRELEASE_PLZ_TOKEN, since aGITHUB_TOKENpush starts no CI run), so the matrix drift check passes without a hand edit. In the same commit it refreshes theCargo.lockof every crate outside the workspace (tools/out-of-workspace-crates.sh --refresh-locks): the version bumps leave those behind, and thestandalone cratejobs build them with--locked.- Merging that release PR puts CI on the merge commit, and the run that passes triggers
release-plz release: it publishes every crate whose version is not yet on crates.io — in dependency order (cratefield-corefirst) — and tags<crate>-v<version>with a GitHub release per crate.
Configuration lives in release-plz.toml (per-crate versioning, conventional-commit changelogs, publish = false for examples/* and the CLI acceptance crate).
cratefield-tables is no longer held back: its first publish (0.1.1) was done by hand, and cratefield-manifest and cratefield-cli depend on it, so it is in the ordered list in step 2 below ahead of both. Its trusted publisher (step 3) is still to be enabled; until it is, the release run cannot publish it over OIDC.
cratefield-tables-api carried publish = false until its routes were built. They are, the facade's tables-api feature re-exports it as cratefield::tables_api, and a generated venture with a [tables] section calls tables_api::router — so the facade cannot publish without it. Its first publish (0.1.0) is manual, after cratefield-manifest and before the facade in step 2 below, and its trusted publisher (step 3) is enabled after that.
cratefield-adapter-colonizer and cratefield-module-crm are newer than the crates.io setup and are publishable, but neither has ever been published, so neither can have a trusted publisher yet. Both are in the ordered list in step 2 below; both need their first manual publish (step 2) and their trusted publisher (step 3) before the release run can publish them. cratefield-text-guard and cratefield-text-diff (issue #836) are in the same position. They, along with cratefield-tables-api above, are what tools/release-preflight.sh currently fails on — CRATES_IO_READY must stay unset until they are done, or the round half-publishes before it reaches them.
cratefield-mcp (issue #160) carries publish = false as well: it is new, nothing depends on it, and publishing it is the remaining human step ADR 0021 records — until the crates.io setup below exists, a publishable crate would only make the release run fail authentication over OIDC. When that step lands its first publish is manual, the same as every other new crate: add it to the ordered list in step 2 below, then enable its trusted publisher and drop the publish = false entry.
cratefield-push-auth, cratefield-adapter-fcm, cratefield-adapter-webpush, cratefield-push-wiring, cratefield-i18n, cratefield-module-privacy, cratefield-module-notifications and cratefield-auth-client were held back with publish = false during their first-publish window. None carries the flag any more, all eight are on crates.io, and release-plz publishes them like any other crate. In particular, nothing holds back cratefield-push-auth (#489).
What does not constrain the order: cratefield-module-privacy is a dev-dependency of module-email-signup, module-waitlist and module-notifications, and those are path-only by the rule in step 2 below, so they are stripped from the packaged manifests. Those three published while module-privacy did not exist on crates.io, which is the proof. Only the facade's real (optional) dependency on it constrains anything.
fz push (issue #184) adds three more edges into cratefield-cli, and the ordered list below already satisfies all of them: it depends on cratefield-push-auth and cratefield-adapter-webpush not optionally (fz push vapid keygen mints the key with the same signer the adapters present, and fz push inspect-subscription validates with the Web Push adapter's own rules), and on cratefield-runtime-native behind the optional push-send feature — which still has to resolve on crates.io when the CLI is packaged, for the reason the facade's optional dependencies do.
Authentication is trusted publishing: the workflow exchanges the GitHub Actions OIDC token (id-token: write) for a short-lived crates.io token. No CARGO_REGISTRY_TOKEN is stored anywhere.
Both steps are switched off until the owner setup below is done, so an unfinished setup leaves main green with an annotation instead of a red X on every merge.
Step 1 runs only with a token GitHub lets create pull requests. The built-in GITHUB_TOKEN usually may not: the API answers "GitHub Actions is not permitted to create or approve pull requests" (403) unless the repository setting allows it. Two ways to fix it, and the first is better:
- A
RELEASE_PLZ_TOKENsecret — a fine-grained PAT or GitHub App token with contents: write and pull requests: write on this repository. It sidesteps the setting, and the release PR it opens runs CI, which aGITHUB_TOKEN-authored PR never does (GitHub suppresses workflow triggers on those, so version bumps would merge unverified). - The repository setting — Settings → Actions → General → Workflow permissions → tick Allow GitHub Actions to create and approve pull requests, then set the repository variable
ACTIONS_MAY_OPEN_PRStotrueso the workflow knows. Note the setting also lets any workflow in this repository approve PRs.
Step 2 runs only when the repository variable CRATES_IO_READY is true. A crate's trusted publisher cannot be configured before the crate exists, so until the first manual publish every release run would fail on authentication and leave main red. release-pr always runs; it needs no registry credentials.
The workflow also takes a manual run (Actions → Release → Run workflow) with a dry_run checkbox, on by default, so the pipeline can be exercised without publishing.
#Dependent crates do not reliably follow a breaking bump
Assume a breaking bump of cratefield-core does not release its dependents, and check that it did. release-plz normally cascades: when it releases a crate it rewrites that crate's version requirement in every manifest it manages, and each crate whose requirement it rewrote earns a patch bump and is published in the same release PR. Nothing configures this behaviour — there is no option to turn the cascade on, off, or up, and no option to make a breaking bump propagate as anything larger than a patch.
The cascade is keyed on that rewrite, which makes it easy to suppress by accident. release-plz asks whether a requirement has to change in order to admit the version being released, and reads "no change needed" as "this dependent does not need a release". A requirement of "0.5" already admits 0.5.0, so releasing core as 0.5.0 against a workspace table that already reads "0.5" rewrites nothing and publishes nothing but core. That is how core reached 0.5.0 on crates.io while 23 published dependents still required ^0.4 (issue #462): the requirement in the root Cargo.toml had been edited by hand ahead of the release that was meant to move it, so the release round that should have followed never ran.
So bump one place, not two. Set the new version in crates/<name>/Cargo.toml and leave the [workspace.dependencies] requirement in the root Cargo.toml alone — moving it is release-plz's job, and moving it first is what costs the dependent round.
A cascaded patch bump is not enough, and the floor is what a lockfile keeps. A cascaded dependent is republished as 0.2.0 → 0.2.1, built on the new core, but the root table still reads version = "0.2", so every crate published after it carries the requirement cratefield-auth-client ^0.2 — whose lowest published version, 0.2.0, still requires core ^0.6. A venture with an existing lockfile keeps 0.2.0, so it resolves two copies of core, and every type in core is an identity per copy. So when a dependency takes a breaking minor and the cascade moves its dependents as patches, raise each internal requirement in [workspace.dependencies] to the first version built on the new core — "0.2.1", not "0.2" — and make that edit in the release PR, where the new versions are already computed, or after the dependency is published. Never ahead of it: that is the hand edit the paragraph above is about, and it suppresses the cascade for everything behind the edit. The same hand edit is the only repair when a crate's version and its requirement have already diverged: bump each of them in the release PR, or publish them in the dependency order given in step 2 below.
A cascaded dependent gets a patch bump even when the dependency broke. release-plz does not inspect code to work out whether a dependent re-exports a type that changed, so a crate whose public surface exposes a core type can ship a breaking change as a patch release, which consumers pinned with a caret take silently. COMPATIBILITY.md does not catch this either: it is generated from the workspace manifests and checked in CI for drift, so it records the ranges this tree declares, not the ranges the published crates actually carry.
Every in-tree build resolves internal dependencies through path, so only the index can catch it. cargo update -Z minimal-versions cannot help either: the published floor is never read, and the flag drags every third-party crate to its floor as well. Two CI jobs read the sparse index instead. tools/dependency-floors.sh (the internal dependency floors job) takes every internal requirement a publishable crate declares, finds the lowest non-yanked published version inside that range — the floor a stranger's resolver is free to pick — and fails when that floor's own requirements sit in a different caret bucket from this tree's, naming the dependent and the version to raise the requirement to. The crates.io resolve workflow resolves the whole published set together, which catches a set that cannot be resolved at all but not a set that resolves to the wrong floor.
#Owner setup (once)
Step 0 is about GitHub; the rest need the crates.io account that will own the cratefield-* names.
- Let the release PR be opened. Either add a
RELEASE_PLZ_TOKENsecret (preferred, and the release PR then runs CI) or tick the repository setting and add the variableACTIONS_MAY_OPEN_PRS=true, as described above. Verify with Actions → Release → Run workflow (leavedry_runchecked): the release PR appears. A crate's trusted publisher can only be configured after the crate exists, so the very first release of each crate is manual:
- Create a scoped token, on an account with a verified email. Sign in to crates.io → Account settings → API Tokens → New token. Scope: Publish new crates. This token exists only for the first publishes; revoke it afterwards. crates.io refuses to publish from an account whose email address is unverified, and it refuses at upload, after packaging and the verification build have both succeeded:
error: failed to publish cratefield-core v0.3.0 to registry at https://crates.io
Caused by:
the remote server responded with an error (status 400 Bad Request):
A verified email address is required to publish crates to crates.io.A --dry-run cannot catch this, because a dry run stops before the upload. Check https://crates.io/settings/profile first: the address must be present and confirmed through the mail crates.io sends.
- First-publish each crate manually, in dependency order, from a checkout of
mainat the version being released:
export CARGO_REGISTRY_TOKEN=... # the scoped token from step 1
cargo publish --dry-run -p cratefield-core # then without --dry-run
cargo publish -p cratefield-mail-templates # needs only core; before every module that sends mail
cargo publish -p cratefield-module-device-auth # needs only core
cargo publish -p cratefield-module-orgs # needs core and mail-templates
cargo publish -p cratefield-tables # before the manifest and the CLI
cargo publish -p cratefield-introspect # before the CLI (its `postgres` feature)
cargo publish -p cratefield-manifest # before the CLI and the facade
cargo publish -p cratefield-tables-api # needs core, tables and manifest; before the facade
cargo publish -p cratefield-i18n # before module-notifications
cargo publish -p cratefield-text-guard # needs nothing in the workspace
cargo publish -p cratefield-text-diff # needs nothing in the workspace
cargo publish -p cratefield-kms
cargo publish -p cratefield-adapter-anthropic
cargo publish -p cratefield-adapter-openai-compatible
cargo publish -p cratefield-adapter-cloudflare-saas
cargo publish -p cratefield-adapter-postgres
cargo publish -p cratefield-module-email-signup
cargo publish -p cratefield-adapter-sqlite
cargo publish -p cratefield-testing # before module-privacy and auth-client (versioned dev-dep)
cargo publish -p cratefield-module-privacy # before the facade
cargo publish -p cratefield-module-waitlist
cargo publish -p cratefield-module-webhooks # before the facade
cargo publish -p cratefield-module-wallets # before the facade
cargo publish -p cratefield-module-guardrails # before the facade
cargo publish -p cratefield-module-sealed # after kms; before the facade
cargo publish -p cratefield-push-auth # before adapter-apns, -fcm, -webpush and the CLI
cargo publish -p cratefield-adapter-github-app # after push-auth; before the facade
cargo publish -p cratefield-adapter-classifier-llm
cargo publish -p cratefield-adapter-colonizer
cargo publish -p cratefield-adapter-github-issues
cargo publish -p cratefield-adapter-owlpost
cargo publish -p cratefield-adapter-polar
cargo publish -p cratefield-adapter-resend
cargo publish -p cratefield-adapter-stripe
cargo publish -p cratefield-adapter-turnstile
cargo publish -p cratefield-adapter-typesafe
cargo publish -p cratefield-adapter-webhook-tracker
cargo publish -p cratefield-adapter-jira
cargo publish -p cratefield-adapter-linear
cargo publish -p cratefield-adapter-telegram # before module-telegram and the facade
cargo publish -p cratefield-auth-client # before module-notifications and the runtimes
cargo publish -p cratefield-oauth-client # before module-linkedin (which is held back)
cargo publish -p cratefield-module-connections # needs core and oauth-client
cargo publish -p cratefield-adapter-workers-ai
cargo publish -p cratefield-module-changelog
cargo publish -p cratefield-module-owlpost # needs core and adapter-owlpost; before the facade
cargo publish -p cratefield-module-cms
cargo publish -p cratefield-module-crm
cargo publish -p cratefield-module-telemetry
cargo publish -p cratefield-ui
cargo publish -p cratefield-secrets
cargo publish -p cratefield-signer # needs secrets; before the facade
cargo publish -p cratefield-adapter-turnkey # needs the signer; before the facade
cargo publish -p cratefield-adapter-apns
cargo publish -p cratefield-adapter-fcm # before push-wiring and the facade
cargo publish -p cratefield-adapter-webpush # before push-wiring, the CLI and the facade
cargo publish -p cratefield-module-notifications # needs i18n and auth-client
cargo publish -p cratefield-module-telegram # needs adapter-telegram and module-notifications; before the facade
cargo publish -p cratefield-push-wiring # before the runtimes and the CLI
cargo publish -p cratefield-client-ts # before the CLI (`fz client-ts`)
cargo publish -p cratefield-import-supabase # before the CLI (its `import-supabase` feature) and the facade
cargo publish -p cratefield-runtime-native
cargo publish -p cratefield-runtime-cloudflare
cargo publish -p cratefield-cli
cargo publish -p cratefield # the facade, lastEvery publishable crate, and the order is the dependency order: --dry-run for a crate whose upstream cratefield-* dependencies are not on crates.io yet resolves against the registry and fails until those are published. The package CI job (tools/package-check.sh) fails if this list misses a publishable crate, lists one that is not publishable, or puts a crate before one of its cratefield-* dependencies, so a new crate has to be added here in a valid position. Every line runs: the facade became packageable once cratefield-tables-api became a versioned workspace dependency (Release run 37257238269 failed on its path-only one; UNPACKAGEABLE in the script is empty now). The runtimes and the CLI became packageable in 0.6.0 (#558), when auth-client, client-ts and introspect became versioned workspace dependencies. An older version of this list put cratefield-auth-client after the two runtimes that depend on it; it now comes before them.
cargo publish resolves dev-dependencies against crates.io too, which is why internal dev-dependencies in this workspace are declared path-only rather than workspace = true (see the note in the root Cargo.toml). Cargo strips a path-only dev-dependency from the packaged manifest, so cratefield-ui can dev-depend on the unpublished module-hello example, and the adapter-postgres -> module-* -> testing -> adapter-postgres dev cycle never has to be broken with --no-verify. If you ever find yourself reaching for --no-verify, a dev-dependency has grown a version. Two already have: cratefield-module-privacy and cratefield-auth-client dev-depend on cratefield-testing through workspace = true, so that requirement ships, and cratefield-testing has to be published before either of them. The order check counts versioned dev-dependencies for that reason.
- Enable trusted publishing per crate. For each published crate: crates.io → crate page → Settings → Trusted publishing → add repository
Cratefield/harness, workflowrelease.yml, environment (leave empty). From then on the release workflow publishes that crate with OIDC and no token. - Switch publishing on. Repository → Settings → Secrets and variables → Actions → Variables → New repository variable: name
CRATES_IO_READY, valuetrue. Until this exists the release step is skipped and the run says so in an annotation. Verify with a manual dry run: Actions → Release → Run workflow, leavedry_runchecked; the release step should now execute and report what it would publish. That run first runstools/release-preflight.sh, which asks crates.io whether every crate in the publishable set exists yet and fails, naming each crate that does not, so a first publish still pending shows up as a list of names in the dry run rather than as a half-published round on the first real one (issue #719). It proves only that each crate exists: one published by hand whose trusted publisher (step 3) is not enabled yet passes it and still fails mid-round. - Revoke the token from step 1.
- Add the team owner. On each crate page → Owners → add a GitHub team from the
Cratefieldorg (the org that owns this repository), so a second human can recover the crates.
If trusted publishing ever needs to be bypassed temporarily: create a token with the Update crates scope and add it as the GitHub Actions secret CARGO_REGISTRY_TOKEN — release-plz picks it up automatically and skips OIDC. Remove the secret when done.
Two traps in the workflow itself, both commented where they bite:
- The action is pinned to an exact version (
release-plz/[email protected]). It moved fromMarcoIeni/release-plz-actionand publishes no floating major tag, so@v0does not resolve and the job never starts. - Its
dry_runinput is tested with[[ -n ... ]], so any non-empty value turns a dry run on, the stringfalseincluded. The workflow passes an empty string for a real release; do not "simplify" it tofalseor nothing will ever be published.
#Prerelease flow (0.x, -rc.N)
While pre-1.0 a minor bump may break (that is what the caret ranges in COMPATIBILITY.md guard). To cut a prerelease, e.g. 0.2.0-rc.1:
- On a branch, set the candidate versions (
cargo set-version -p cratefield-core 0.2.0-rc.1fromcargo-edit, or by hand) and land the change onmainwith a message likechore(release): prepare cratefield-core 0.2.0-rc.1. - The next
release-plz releaserun publishes any version that is not on crates.io yet — including therc. (Verify the release run's logs; if release-plz skipped it, publish thercmanually as in step 2 above — same trusted publishing / token rules.) - Consumers opt in explicitly: prerelease versions never match a caret range, so a venture must pin
cratefield-core = "=0.2.0-rc.1"while testing. - The final
0.2.0follows the normal release-PR flow; therccommits appear in its changelog.
#The published set: what runs on its own, what is still by hand
One check of the published set now runs on its own: the crates.io resolve workflow (.github/workflows/crates-io-resolve.yml running tools/crates-io-resolve.sh). It resolves this repository's crates the way a stranger does: every publishable crate that exists on crates.io is added — no version pin, no path override — to a fresh project in a temp directory outside this workspace; the resolution is asserted to hold one copy of each in-repo crate (cargo tree --duplicates; two copies of one of ours is the expected cratefield_core::Module, found cratefield_core::Module failure downstream); and the result is compiled (cargo check). A crate whose first publish is still pending is skipped with a notice, not failed on — a pending first publish is a gap being filled (Owner setup above), not a broken published set. It also fails when the workspace names a version the registry has never published (issue #708): the published set resolves fine while that stands, it is just older than this tree.
It runs daily on a schedule, after a real release (the Release workflow calls it once a publishing run actually happened, dry runs excepted), and on manual dispatch. The schedule is the point: crates.io can become incoherent without a single commit here — one publish that half-succeeds is enough — and only a scheduled run is both guaranteed to notice and guaranteed to rerun after it. It is deliberately not on push or pull_request: a broken published set should redden the release pipeline, not every unrelated pull request.
This retires the old one-shot advice to re-run cargo add cratefield-core from an empty project after the first release (issue #15 acceptance): the workflow now does that for every published crate, together, every day.
The package itself is checked on every push and pull request, by the package job in .github/workflows/ci.yml running tools/package-check.sh with no credentials:
- Publish-order drift: the first-publish list in step 2 above must name exactly the publishable set (
publishunset in the crate's manifest and not held back inrelease-plz.toml), each crate after its normal and buildcratefield-*dependencies and its versioned dev-dependencies, and each only once. UNPACKAGEABLEstays honest: each crate the script lists as unable to package must still failcargo packagefor the path-only dependency it records; once one packages, the job fails until its entry is removed.- Leak deny-list:
cargo package --listfor every publishable crate, failing if a packaged path is undertarget/or isBRIEF.md, a*-BRIEF.mdsuch asBUILD-BRIEF.md,PROGRESS.mdor a.env*file. - Verification build: one multi-package
cargo packageover every publishable crate not inUNPACKAGEABLE. Cargo overlays the freshly packaged crates as a local registry, so unpublished versions resolve, and builds each from its extracted tarball. That is the only build that notices a fileinclude_str!reads but the crate'sincludelist leaves out (#499): every other build reads the source tree.
tools/package-check.sh --no-verify runs everything but the verification build, for a fast local check that takes seconds once cargo's index is warm.
The internal requirements' floors are checked by tools/dependency-floors.sh (the internal dependency floors job, and the second step of the crates.io resolve workflow): each requirement a publishable crate declares, against the lowest published version inside it, so a requirement that still admits a version built on the previous core fails before a venture's lockfile can (#712).
Still manual: the credentialed publish itself. Every first publish is cargo publish with the scoped token (step 2 above); only after that does the release run publish a crate on its own, over OIDC.
Mirrored from docs/RELEASING.md at b6f50cd. The harness is the source of truth: a correction goes there first.