Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Releasing

Releases are automated with release-plz. You never hand-edit versions: when a maintainer manually dispatches the release workflow, release-plz opens or updates a release PR that bumps the shared workspace version, propagates the internal animsmith-* dependency versions, and updates CHANGELOG.md. Merging that PR tags the release, publishes the GitHub Release, and publishes the workspace crates intended for crates.io in dependency order.

The workflow is .github/workflows/release-plz.yml; its behaviour is configured by release-plz.toml. The changelog uses release-plz's default Keep-a-Changelog format, derived from the Conventional Commit history (accepted types live in .commitlintrc.yml).

Repository prerequisite

In GitHub, enable Settings → Actions → General → Workflow permissions → Allow GitHub Actions to create and approve pull requests. The release-pr job's pull-requests: write permission is necessary but does not override that repository setting. If the setting is disabled, release-plz can push its release branch but PR creation fails with HTTP 403, leaving the branch without a pull request.

Per-release flow (steady state)

During release freeze, default new work to the next milestone. Promote only reproducible correctness, safety, public-contract, or documentation blockers, plus genuinely small mutation-strengthening tests; record why shipping is riskier than changing.

  1. Merge feature/fix PRs to main as usual (Conventional Commits).

  2. Perform one release-wide documentation-freshness sweep over the root and crate READMEs, docs/, examples/, and current version/status claims. Preserve clearly historical references. Stage the intended release line in the dependency snippets and current tool.version examples before dispatch:

    cargo run -p animsmith --example stage_release_docs -- --version <next>
    

    That rewrites every claim the release_version_docs workspace test reads, and nothing else: it edits only the located spans, so a historical version on the same page stays. The test accepts exactly the next patch or minor while main still carries the previous manifest version, and the example refuses any other version. On the generated release-plz-* branch the same test requires every current version claim to equal the bumped workspace manifest. Both deliberately ignore CHANGELOG.md, this completed bootstrap, and roadmap history. The dispatch job checks that generated branch itself in strict mode before succeeding, because GitHub does not initially trigger PR workflows for a PR created with the default GITHUB_TOKEN.

  3. When main is ready to release, manually dispatch the release workflow:

    gh workflow run release-plz.yml --ref main
    

    The workflow first runs the shared checks, then release-plz opens or updates the release PR. It computes the next version from the commits since the last release — one shared version across the publishable crates (version_group), so the whole workspace moves together — and writes the changelog. Ordinary pushes to main never create or update this PR. The release-PR job temporarily installs three exact, commit-scoped Git replacements so release-plz can inspect a short historical branch whose manifests did not parse. The preparation refuses any object-identity drift or pre-existing replacement, preserves commit identities and messages, and is removed even when release-plz fails. It is local to that job: the push-triggered publication path always reads canonical repository history. If another change merges to main while the release PR is open, dispatch the workflow again before merging so the version and changelog include it.

  4. Review that PR. If step 2 staged nothing, or staged a version release-plz did not pick, restate the documentation from the bumped manifest on the release PR branch itself and commit what it writes:

    cargo run -p animsmith --example stage_release_docs
    cargo test -p animsmith --test release_version_docs
    

    With no argument the example targets [workspace.package] version, so on a correctly staged branch it reports no changes. The test is the fail-closed check either way: on a release-plz-* branch it fails unless every current version claim equals the bumped manifest.

    The glTF writer records the package version in asset.generator, so regenerate and commit the version-stamped example assets from the release PR branch before merging:

    cargo run -p animsmith --example gen_example_assets
    cargo test -p animsmith --test examples_cookbook
    

    That refresh moves the stamp and nothing else, so the PAYLOAD_IDENTITIES pins in crates/animsmith/tests/examples_cookbook.rs must come through it untouched: the test above digests each asset around its asset.generator string, and a pin that moves means the regenerated animation changed rather than only its version. Do not edit those literals as part of a release.

    The committed documentation visuals under docs/visuals/ are rendered from those same assets, so regenerate and commit them in the same step:

    cargo run -p animsmith --example gen_docs_visuals
    cargo test -p animsmith --test docs_visuals
    

    The isolated Bevy readback probe locks animsmith-core and animsmith-engine at the workspace version, and the engine states that lock's size and SHA-256 in crates/animsmith-engine/src/bevy_readback_lock.txt. Move both package versions in tools/bevy-readback/Cargo.lock to the release, rewrite that file from the lock, and confirm the lock gate agrees:

    just bevy-readback-lock-refresh
    just bevy-readback-lock
    cargo test -p animsmith-engine
    

    Do not hand-edit the identity file: just bevy-readback-lock renders those two lines again from the committed lock and fails on any difference.

    The cookbook drift guard fails if the committed bytes do not match the release version. The example assets, documentation visuals, and Bevy lock are the three mechanical regenerations this step retains. Payload identities protect the example assets and exact-artifact assembly fixture independently of the package version; the visuals and Bevy lock/identity remain exact regenerated artifacts checked by their own gates. There are no manual assembly digest literals to refresh and no counterfactual assembly rebuild to perform.

    When you merge the PR, the release job runs on the resulting push to main, tags the release, creates the GitHub Release, and publishes every crate to crates.io in dependency order (animsmith-core → -gltf/-fbx/-engine/-report → animsmith). The follow-on release_binaries job calls release-binaries.yml, builds CLI archives from the tag, and uploads the archives plus matching .sha256 files to that GitHub Release. The release_pages job dispatches docs-pages.yml on main in the same run, moving the Pages root to the tag just published; see GitHub Pages documentation.

Supported CLI archive targets live in release-targets.json. scripts/release-targets.py renders the generated workflow matrix block in .github/workflows/release-binaries.yml and the install table in docs/cli.md. After changing release targets, run scripts/release-targets.py write and then just release-packaging so the generated docs and workflow matrix stay in sync.

Post-release verification

Treat workflow success as the start of verification, not the final claim. Before closing the milestone:

  1. Confirm the push-triggered release-plz.yml run completed successfully at the release-PR merge commit, including every platform build and the release asset upload. Confirm the release tag resolves to the frozen, audited release PR head; with merge commits this is normally the release commit, not the merge commit itself.

  2. Verify every publishable package exists at the expected version on crates.io and is not yanked. The crates.io API may return HTTP 403 to an anonymous default client, so use a descriptive curl -A user agent when querying it.

  3. Compare the GitHub Release assets with release-targets.json: expect one archive and one .sha256 file per target. Download them, run sha256sum -c, inspect .tar.gz contents with tar -tzf, and inspect .zip contents with python3 -m zipfile -l so verification does not depend on unzip being installed. Before closing the milestone, run --version from at least one archive that is native to the verification host, confirm the output names the expected release version, and retain that comparison. If no release target is native to that host, perform the same execution, expected-version comparison, and evidence retention on a fresh VM or runner for one supported target. If gh release download leaves the destination empty, fetch each release asset by id through gh api -H 'Accept: application/octet-stream' repos/<owner>/<repo>/releases/assets/<id> instead.

  4. Check docs.rs separately. A crate appearing in the docs.rs queue means it was accepted, not rendered; report it as queued until the versioned pages or build record resolve successfully.

  5. Confirm the Pages deployment followed the release. The release_pages job dispatches docs-pages.yml, so the composing run appears in Actions as Pages documentation triggered by workflow_dispatch. Check the published routing record rather than the run alone:

    curl -fsSL https://mmannerm.github.io/animsmith/BUILD-INFO.txt
    

    It must report Release root: vX.Y.Z for the tag just published and Development subtree: main. If it still names the previous tag, run the manual recovery dispatch from GitHub Pages documentation and re-check.

  6. Re-query the live bug list and release milestone. Close the milestone only when it has zero open issues, then update the roadmap ledger with the shipped date and bounded scope.

crates.io publishing uses Trusted Publishing (GitHub OIDC): the release job holds id-token: write and release-plz mints a short-lived token itself — there is no long-lived CARGO_REGISTRY_TOKEN.

The publish step is idempotent: a re-run skips versions already on the registry. If publishing fails partway through, use Re-run failed jobs on that original push-triggered workflow run. A new manual dispatch creates or updates the next release PR; it does not retry publishing.

Version-bump policy

Configured in release-plz.toml:

  • feat bumps the minor, fix/perf the patch, even on 0.x (features_always_increment_minor = true, porting the old cliff.toml bump rule).
  • Breaking changes follow semver — on 0.x that is a minor bump (0.1.0 → 0.2.0), not a major. This is the one place the old cliff.toml differed: it forced breaking → major (→ 1.0.0), but release-plz has no equivalent setting. If you want to go to 1.0.0, bump the version explicitly in the release PR.
  • Only feat/fix/perf/revert appear in the changelog and release notes; chore/ci/docs/style/refactor/test/build and merge commits are skipped ([changelog].commit_parsers) — they still count toward whether a release is warranted.

Schema identities

Output and measurement schemas use immutable protocol URNs, independent of package releases. Do not rewrite $id during a release. A breaking contract gets a new URN and schema file. scripts/check-schema-id.sh checks that each schema, the CLI, and docs/output.md reference the same identities; repository links are only retrieval locations.

The crate READMEs are included in the crates published to crates.io. During pre-1.0 development, those READMEs intentionally link deeper repository docs to latest main with github.com/mmannerm/animsmith/blob/main/... or /tree/main/... URLs. That means an older published crate can send readers to newer source docs. For now this is accepted so reference docs stay simple while the API is still settling; the machine-readable JSON schema remains protected separately by scripts/check-schema-id.sh.

GitHub Pages documentation

GitHub Pages publishes two deliberately different snapshots through the official artifact deployment workflow in .github/workflows/docs-pages.yml. The site root, https://mmannerm.github.io/animsmith/, is rebuilt from the latest published release tag. https://mmannerm.github.io/animsmith/dev/ is rebuilt from protected main at the same deployment, so contributors can link to unreleased work without relabeling it as released documentation.

The workflow source is the default branch; pull requests only retain a rendered preview artifact and never deploy. The release root is selected from GitHub's latest published release, rather than from an assumed version prefix. Its mdBook executable and its scripts/build-docs-site.py both come from that selected tag, so a new site shape on the default branch never rewrites — or breaks — an already released documentation tree; the /dev/ subtree separately uses the default branch's own pin and build script. If no published release exists, do not enable Pages deployment: publish the first release first, then dispatch the workflow as shown below.

A successful release publication dispatches that workflow itself: the release_pages job in .github/workflows/release-plz.yml runs gh workflow run docs-pages.yml --ref main --repo "${GITHUB_REPOSITORY}" with the repository GITHUB_TOKEN as soon as release-plz reports a published release. That job checks nothing out, so gh cannot infer the repository and the flag is explicit; the recovery command below is the same dispatch run from a clone, where --repo is unnecessary. The dispatch is required, not decorative. GitHub creates no workflow run for an event produced with the repository GITHUB_TOKEN, so the workflow's own release: published trigger stays silent for an automated release — it still covers a release published by hand — while workflow_dispatch is one of the two documented exceptions to that recursion guard. The push that merges the release PR also runs the workflow, but it runs before the release exists and would leave the root on the previous tag; the shared pages-<ref> concurrency group cancels that earlier run when the post-publication dispatch starts.

Recover a missing or stale deployment with the same dispatch from a clone of the repository — after a failed release_pages job, or a release published by hand:

gh workflow run docs-pages.yml --ref main

scripts/check-pages-release-trigger.py, run by scripts/check-github-community-files.sh, fails the build if that dispatch, its actions: write permission, or the workflow_dispatch trigger it targets disappears, or if deployment stops being gated on an eligible published release.

docs/README.md is the canonical task index and the site's only routing authority. Its Category column — Part or Part › Group — generates the mdBook parts, group chapters, and their order during clean staging; do not add a hand-maintained SUMMARY.md, generated HTML, or symlink-based publication tree. docs.rs remains the Rust API reference and must remain prominent in the Pages index and README. Animation-pack reports are nested from the canonical table in docs/reports/README.md, and built README.md chapters receive compatibility routes for mdBook's README.html rewrites. Generated source-reference routes are pinned to the selected release tag at the release root and to main below /dev/.

The site's presentation is tracked rather than configured at deploy time. docs/site/ holds the theme override mdBook applies — stylesheet, fonts, and favicons — staged outside the published source so nothing in it becomes a page, and the current build script refuses a checkout that does not track its stylesheet. Because each release root is built by its own tag's script, release tags predating docs/site/ still publish with mdBook's default theme and their own navigation; do not patch a tag to add either. Retiring or renaming a published route requires its docs/site/redirects.toml entry in the same change, so an existing external link keeps resolving, and a redirect whose target is missing fails the build instead of shipping a dead route. Run just docs-check before changing any Pages source; DEVELOPMENT.md, "GitHub Pages preview", owns the local commands and what they verify.

If a future release needs version-pinned README links, update the READMEs, this section, and scripts/check-package-inventory.sh in the same release-oriented PR. Do not add release-time rewriting without a mechanical check that proves the packaged README links and the release tag agree.

Bootstrapping a newly added crate

Trusted Publishing cannot create a crates.io package. Generate the release PR, freeze its exact head, and run the full release gates before bootstrapping a new workspace crate. Do not change the release PR after any package from that head has been published.

If the new crate verifies against the internal dependency versions already on crates.io, manually publish only the new crate with a token carrying publish-new. If it consumes internal APIs introduced by the same release, first manually publish the required existing workspace crates from the frozen release head in dependency order with publish-update, then publish the new crate with publish-new. Always let Cargo perform its ordinary package verification; --no-verify is not a bootstrap mechanism.

Add the repository's release-plz.yml Trusted Publisher to the new package, then merge the unchanged release PR. release-plz skips the exact versions already published from that head and publishes the remaining workspace crates in dependency order. Record every manual package publish, the exact release-PR commit, and the Trusted Publisher setup in the release audit.

If the release PR must change after a manual publish, stop and reconcile the already public immutable package rather than pretending the replacement head has the same bytes.

One-time bootstrap

This repository starts its public release history from a clean slate: the pre-publication development tags (v0.1.0–v0.7.0, never on crates.io) were deleted on 2026-07-04, so the first crates.io publish, the first GitHub Release, and CHANGELOG.md all begin together at the 0.1.0 in Cargo.toml.

The entire first 0.1.0 release is manual — crates.io publish, the v0.1.0 tag, the GitHub Release, and the initial CHANGELOG.md. Two constraints force this, and they compose:

  • Trusted Publishing cannot publish a crate that does not yet exist, so the first crates.io publish of each crate must use a token.
  • release-plz release only acts on unpublished packages. Once 0.1.0 is on crates.io it will (correctly) no-op — so it will not create the first v0.1.0 tag/Release for you. The manual v0.1.0 tag is also the baseline release-plz needs to compute the next version.

So automation begins at 0.2.0; 0.1.0 is done by hand, once:

  1. cargo login with a token from https://crates.io/settings/tokens (scope: publish-new + publish-update).

  2. Generate and commit the changelog first — before publishing. release-plz update compares the local crates against the registry to find unreleased changes, so it only produces the 0.1.0 changelog while the crates are still unpublished. Run it now (its Keep-a-Changelog format matches every later release):

    release-plz update          # writes CHANGELOG.md
    git add CHANGELOG.md && git commit -m "chore(release): 0.1.0"
    git push
    
  3. Publish the workspace from that release commit, in dependency order (each dependent crate resolves against the crate just published):

    for crate in animsmith-core animsmith-gltf animsmith-fbx animsmith-report animsmith; do
      cargo publish -p "$crate"
    done
    

    animsmith-core should pass cargo publish --dry-run first; the dependent crates can only fully verify once their animsmith-* dependencies exist in the index.

  4. After each crate is accepted, docs.rs queues its documentation. Check each crate's landing page, source listing, and feature metadata. The library crates (animsmith-core, animsmith-gltf, animsmith-fbx, animsmith-engine, and animsmith-report) must also have successful rustdoc builds; their manifests set documentation links and [package.metadata.docs.rs] so pure-Rust crates get Linux/macOS/Windows pages, while the C-dependent animsmith-fbx uses the Linux default target. The published animsmith package is intentionally bin-only: docs.rs has no library target to document and its cargo rustdoc --lib failure is an accepted, mechanically guarded exemption. Verify its docs.rs landing/source/features pages only.

  5. On crates.io, for each publishable crate: Settings → Trusted Publishing → add publisher — repository mmannerm/animsmith, workflow release-plz.yml, no environment.

  6. Tag the release commit and publish the GitHub Release from the 0.1.0 changelog section (release-plz won't create it — 0.1.0 is already published — so the notes are extracted from that same section):

    git tag v0.1.0 && git push origin v0.1.0
    gh release create v0.1.0 --title v0.1.0 \
      --notes-file <(awk '/^## \[0\.1\.0\]/{f=1;next} /^## \[/{f=0} f' CHANGELOG.md)
    

    Then dispatch the binary packaging workflow against main so the manually created release gets the same archives and checksums as later automated releases:

    gh workflow run release-binaries.yml --ref main -f tag=v0.1.0
    
  7. Arm the release automation. Both the manually dispatched release-pr job and the push-triggered release job are gated on vars.RELEASE_PLZ_ARMED, so the whole flow stays inert until this is set — no release PRs and no publish attempts before the manual 0.1.0 above:

    gh variable set RELEASE_PLZ_ARMED --body true
    

After the bootstrap, every subsequent release (0.2.0+) starts with the manual workflow dispatch and then goes through the release-plz PR flow above — no manual cargo publish, no manual version edits, one repo-level vX.Y.Z tag and Release per version.

Known caveat: CI on the release PR

PRs opened with the default GITHUB_TOKEN do not trigger on: pull_request workflows, so the release-plz PR will not get its own full shared-check matrix of its own. The manual dispatch runs that matrix against pre-bump main, then checks out the exact generated branch and runs the focused release-version documentation guard in strict mode. The generated lockfile, changelog, and assets still require review, and the post-merge checks job runs the full matrix again before publishing. If branch protection also requires a full-matrix check attached to the release PR itself, give the release-pr job a PAT or GitHub App token via the release-plz token input instead of secrets.GITHUB_TOKEN.