This guide walks through cutting a Trellis release from the repo: changelog prep, Rust xtask release tooling, the GitHub release gate, CI-created tagging, and publication.
This guide owns Trellis testing and release practice. Rust xtask release ... commands are the current release tooling.
What you need
- push access to the repository
- Deno v2+
- a Rust toolchain (
rustup) - Docker or Podman if you want to locally verify image builds
Feature testing policy
Every feature, bug fix, or refactor must explicitly ask whether it needs integration coverage.
Default to adding or updating integration tests when a change affects:
- public Trellis HTTP or RPC APIs
- NATS subjects, auth-callout, proofs, or transport permissions
- contracts, generated SDKs, manifests, or permission derivation
- service bootstrap, device activation, app identity authority approval, or deployment authority acceptance
- jobs, operations, events, feeds, state, resources, or transfer behavior
- Rust/TypeScript cross-runtime behavior
Unit tests are still appropriate for parser, schema, error, and small pure logic cases. They do not replace integration coverage for externally visible runtime behavior.
Before committing or ending an implementation cycle, run the focused checks that match the files touched. If runtime or public behavior changed, also run the full integration harness:
cargo run --manifest-path xtask/Cargo.toml -- integration run --skip-prepare --keep-workdir If the full harness cannot be run, record the reason and the last focused checks that did run.
Targeted integration loops
For focused local debugging, inspect the current integration inventory and run the smallest matching fixture or coverage slice:
cargo run --manifest-path xtask/Cargo.toml -- integration list fixtures
cargo run --manifest-path xtask/Cargo.toml -- integration list coverage
cargo run --manifest-path xtask/Cargo.toml -- integration run --skip-prepare --keep-workdir --fixture rpc
cargo run --manifest-path xtask/Cargo.toml -- integration run --skip-prepare --keep-workdir --coverage jobs-public-api Fixture runner aliases such as rpc, operations, events, feeds, state, transfer, and resources select both Rust and TypeScript parity fixtures. Use language-specific fixture ids, for example rpc:rust, only when you intentionally want a narrower diagnostic run.
When experimenting with CI sharding, start with a few conservative shards and keep JUnit output per shard:
cargo run --manifest-path xtask/Cargo.toml -- integration run --skip-prepare --fixture admin-api --fixture device-activation --fixture service-approval --fixture app-identity-approval --fixture optional-uses --junit target/integration-auth-admin.xml
cargo run --manifest-path xtask/Cargo.toml -- integration run --skip-prepare --fixture rpc --fixture operations --fixture events --fixture feeds --fixture state --fixture transfer --fixture resources --junit target/integration-runtime-parity.xml
cargo run --manifest-path xtask/Cargo.toml -- integration run --skip-prepare --fixture jobs --fixture health --fixture password-change --junit target/integration-jobs-health.xml
cargo run --manifest-path xtask/Cargo.toml -- integration run --skip-prepare --fixture catalog-authority --fixture catalog-authority-restart --junit target/integration-catalog-authority.xml Keep catalog-authority and catalog-authority-restart together because the restart fixture depends on state created by the authority fixture. Shards are a speed aid only; release verification still requires the full unsharded integration harness until shard equivalence is proven against fixture reports and coverage ids.
Formatting policy
Formatting is part of the normal implementation loop, not just release verification. After editing source, docs, styles, generated-artifact inputs, or workflow files, format the changed files before running type checks and tests.
Use targeted formatting for the files changed by the task:
deno fmt -c js/deno.json <changed js/ts/svelte/json/md/css/svg files>
cargo fmt --manifest-path rust/Cargo.toml --package <crate>
rustfmt --edition 2021 <changed .rs files> rust/tools/generate and rust/xtask are standalone Rust workspaces. When a change touches them, run their formatter checks directly:
cargo fmt --manifest-path rust/tools/generate/Cargo.toml --check
cargo fmt --manifest-path rust/xtask/Cargo.toml --check When generated artifacts are affected, run the relevant prepare command first, then verify generated Rust formatting with:
cargo fmt --manifest-path rust/Cargo.toml --all --check If a full formatter check reports unrelated pre-existing drift, keep the task patch scoped and report that drift separately unless the cleanup is explicitly part of the task.
Release model
Trellis has one release tag for the whole repo, for example vX.Y.Z or vX.Y.Z-rc.1.
That single tag drives:
- GitHub release assets
- npm package publication
- Rust crate publication
- OCI image publication
JSR publication is enabled for the publishable package set.
Release-managed JS packages and Rust crates use one checked-in base version. Stable releases usually bump that checked-in base version. Prerelease tags use a suffix such as -rc.1; they normally keep the checked-in base version unchanged.
The GitHub Release workflow runs the same Rust release tooling used locally:
release prepare --tag <tag>rewrites publishable versions for the tagged buildrelease check-metadatachecks release metadata before expensive verificationrelease write-notesextracts the matchingCHANGELOG.mdsection for GitHub release notes
The normal release path is a release/vX.Y.Z or release/vX.Y.Z-rc.1 branch marker. The workflow prepares the release workspace once, uploads it as an artifact, and reuses it for formatting checks, Rust tests, Deno checks and tests, the full integration harness, npm and JSR package checks, CLI binary builds, image dry-runs, and publish jobs. After the release gate passes, GitHub Actions creates an unsigned lightweight release tag on the marked commit and publishes from the already verified artifacts. If the gate fails, no release tag is created and the same version can be retried.
Release CI intentionally keeps both the pre-release prepare generator bootstrap and the post-release prepare generator pass. Removing the first pass is a follow-up optimization only after a generated-artifact freshness check proves release prepare no longer depends on bootstrapped artifacts.
The Deploy Site to GitHub Pages workflow is separate from release publishing. It builds the released and current guide/console sites, prefers a cached or published trellis-generate binary for speed, and falls back to building trellis-generate from source when the matching release asset is not available yet. This avoids races while a new release tag is still building its CLI assets.
The JSR publish set is:
@qlever-llc/result@qlever-llc/trellis@qlever-llc/trellis-control-planefromjs/services/trellis@qlever-llc/trellis-test@qlever-llc/trellis-sveltefromjs/packages/trellis-svelte/jsr
The workflow runs JSR dry-runs for those packages so package shape issues are caught early, then publishes them after the release gate passes.
The @qlever-llc/trellis-control-plane service package is published directly from js/services/trellis. The source @qlever-llc/trellis-svelte package is still published from its staged JSR package directory because that package shape needs generated or copied publish artifacts.
1. Pick the release
Identify the previous release tag and choose the next version.
Set variables for the release you are cutting:
PREVIOUS_TAG=vX.Y.Z
RELEASE_VERSION=X.Y.Z
RELEASE_TAG="v$RELEASE_VERSION" For a prerelease, include the suffix in RELEASE_VERSION:
PREVIOUS_TAG=vX.Y.Z
RELEASE_VERSION=X.Y.Z-rc.1
RELEASE_TAG="v$RELEASE_VERSION" Examples only:
- stable: previous
vX.Y.Z, new versionX.Y.Z, tagvX.Y.Z - prerelease: checked-in base
X.Y.Z, prerelease versionX.Y.Z-rc.1, tagvX.Y.Z-rc.1
Use stable version numbers without the v prefix when passing --version, --from, or --to. Use tag names with the v prefix when passing --since or --tag.
2. Update the changelog
CHANGELOG.md is the source of truth for both the repo changelog and the GitHub release body.
Review the commits and the actual code changes since the previous release before finalizing the changelog:
/usr/bin/git log --oneline "$PREVIOUS_TAG..HEAD"
/usr/bin/git diff --stat "$PREVIOUS_TAG..HEAD"
/usr/bin/git diff "$PREVIOUS_TAG..HEAD" For exact path-limited release evidence, use direct Git or the unfiltered rtk proxy path. Do not rely on condensed wrapper output for evidence that must be copied into release notes, approvals, or audit records:
/usr/bin/git diff -- <paths>
rtk proxy git diff -- <paths> Use the code review, not just the commit subjects, to verify the changelog is functionally complete and correct. It should cover user-visible changes, migration concerns, fixed user-visible bugs, and notable operational details without becoming a raw commit dump. Generated-file-only churn does not need its own entry, but generated artifacts should point back to a covered source or contract change.
Add a section whose heading matches the release version exactly:
- stable:
## [X.Y.Z] - YYYY-MM-DD - prerelease:
## [X.Y.Z-rc.1] - YYYY-MM-DD
For a stable release, move the relevant Unreleased bullets into the new version section before pushing the release marker.
3. Bump checked-in base versions
First verify that release-managed versions are currently consistent:
cargo run --manifest-path xtask/Cargo.toml -- release check-versions For a new stable base release, bump release-managed JS and Rust manifests together:
CURRENT_BASE_VERSION=<current-base-version>
NEXT_BASE_VERSION=<next-base-version>
cargo run --manifest-path xtask/Cargo.toml -- release bump --from "$CURRENT_BASE_VERSION" --to "$NEXT_BASE_VERSION"
cargo run --manifest-path xtask/Cargo.toml -- release check-versions release bump updates release-managed deno.json, deno.npm.json, and Cargo.toml manifests while preserving file layout. App and demo manifests that intentionally use 0.0.0 are skipped.
Do not run release bump just to add a prerelease suffix like -rc.1. Prerelease suffixes are handled from the tag by release prepare in the publish workflow.
4. Verify release metadata
Check that the changelog has the release section and review files changed since the previous tag:
cargo run --manifest-path xtask/Cargo.toml -- release changelog-check --version "$RELEASE_VERSION" --since "$PREVIOUS_TAG" Run the release metadata verification:
cargo run --manifest-path xtask/Cargo.toml -- release check-metadata --version "$RELEASE_VERSION" --since "$PREVIOUS_TAG" You can preview the GitHub release notes before pushing the release marker:
tmpdir=$(mktemp -d)
cargo run --manifest-path xtask/Cargo.toml -- release write-notes --tag "$RELEASE_TAG" --output "$tmpdir/release-notes.md"
less "$tmpdir/release-notes.md" 5. Optionally run release verification locally
The GitHub release gate is the authoritative release verification pass. If you want local confidence before pushing the release marker, run the same full release verification set locally:
cargo run --manifest-path xtask/Cargo.toml -- release verify --version "$RELEASE_VERSION" --since "$PREVIOUS_TAG" --keep-workdir For emergency focused local verification only, --skip-integration skips the full integration harness and prints that release verification is incomplete. A release is not ready until the full integration harness passes in the GitHub release gate.
The command runs this sequence and prints each command before executing it:
cargo run --manifest-path xtask/Cargo.toml -- release check-metadata --version "$RELEASE_VERSION" --since "$PREVIOUS_TAG"
cargo run --manifest-path xtask/Cargo.toml -- prepare
deno fmt -c js/deno.json --check
cargo fmt --manifest-path rust/Cargo.toml --all --check
cargo fmt --manifest-path rust/tools/generate/Cargo.toml --check
cargo fmt --manifest-path rust/xtask/Cargo.toml --check
deno check -c js/deno.json js/packages/trellis/index.ts js/packages/trellis-svelte/src/index.ts js/packages/trellis-svelte/src/context.svelte.ts js/packages/trellis-test/index.ts js/services/trellis/main.ts
deno test -c js/deno.json -A
cargo test --manifest-path rust/Cargo.toml --workspace
cargo test --manifest-path rust/tools/generate/Cargo.toml
cargo test --manifest-path rust/xtask/Cargo.toml
cargo test --manifest-path xtask/Cargo.toml
cargo run --manifest-path xtask/Cargo.toml -- integration run --skip-prepare --keep-workdir For focused local Deno loops after a single prepare, use the prepared partitions instead of rerunning generation before every slice:
deno task -c js/deno.json prepare
deno task -c js/deno.json test:prepared:packages
deno task -c js/deno.json test:prepared:service:catalog-state
deno task -c js/deno.json test:prepared:service:auth
deno task -c js/deno.json test:prepared:ui-tools
deno task -c js/deno.json test:prepared:packaging The prepared partitions are for faster focused iteration. Keep deno test -c js/deno.json -A in the release confidence set until the partition set has been proven equivalent to the broad Deno suite.
Also run generated-artifact preparation when contracts, generated SDKs, runtime surfaces, or release packaging can be affected:
cargo run --manifest-path xtask/Cargo.toml -- prepare
cargo run --manifest-path rust/xtask/Cargo.toml -- prepare If the guide site changed, also run:
deno task -c docs/deno.json check If packaging changed, add the relevant package or image smoke checks before committing. For example:
cargo run --manifest-path xtask/Cargo.toml -- build --workspace --release
deno task -c js/deno.json build:npm
(cd js/packages/result && deno publish --dry-run --allow-slow-types --allow-dirty)
(cd js/packages/trellis && deno publish --dry-run --allow-slow-types --allow-dirty)
(cd js/services/trellis && deno publish --dry-run --allow-slow-types --allow-dirty)
(cd js/packages/trellis-test && deno publish --dry-run --allow-slow-types --allow-dirty)
(cd js/packages/trellis-svelte/jsr && deno publish --dry-run --allow-slow-types --allow-dirty)
docker build -f js/services/trellis/Containerfile .
docker build -f js/apps/console/Containerfile . If the full integration harness cannot be run, record why and list the focused checks that did run.
6. Review the release commit
Make sure the release commit includes only the intended release preparation, typically:
CHANGELOG.md- release-managed version bumps for a stable release
- generated artifacts refreshed by
xtask prepare, if applicable - docs, release tooling, or workflow fixes that must ship with the release
Then commit the release preparation:
git status
/usr/bin/git diff -- CHANGELOG.md js rust docs .github/workflows
git add CHANGELOG.md js rust docs .github/workflows
git commit -m "Prepare $RELEASE_TAG release" Adjust the files and commit message to match what actually changed.
7. Push the release marker
Push the release commit to the matching release branch. The branch name is the release marker and must be release/$RELEASE_TAG:
git push origin HEAD:refs/heads/release/$RELEASE_TAG For example, RELEASE_TAG=v0.9.0-rc.1 pushes release/v0.9.0-rc.1.
The Release workflow fails early if the final release tag already exists. If the release gate fails, fix the release commit and push the release branch again; the version is not consumed because no tag has been created.
After the release gate passes, GitHub Actions creates an unsigned lightweight tag for $RELEASE_TAG on the marked commit and publishes:
- the GitHub release with changelog-backed notes
- npm packages
- crates.io crates
- GHCR images
- JSR packages
The unified Release workflow publishes in gated stages:
resolve-releasederives the final release tag from therelease/v*branch and rejects already-used tagsprepare-releaserewrites publishable versions, verifies release metadata, generates SDK artifacts, writes release notes, and uploads the prepared workspaceverify-format,verify-rust,verify-js,verify-integration,build-cli, andverify-imagesvalidate the release before publicationcreate-release-tagcreates the release tag after the release gate passesgithub-releasepublishes CLI archives and checksums firstpublish-crates,publish-npm, andpublish-imagespublish downstream artifacts after the GitHub release existspublish-jsrpublishes the JSR package set after the GitHub release exists
The workflow is idempotent where practical: npm and crates.io publish steps skip versions that already exist. A registry publish is still not an atomic transaction across registries, so if a late publish step fails, inspect the workflow logs and rerun the workflow after fixing the underlying registry or workflow issue.
8. Verify the published release
Check that the GitHub release body matches the intended changelog section and that the expected artifacts were attached.
Then verify the downstream publishes you care about:
- npm packages have the expected version and dist-tag
- crates.io shows the expected crate versions
- GHCR images have the expected tags such as
$RELEASE_TAG,latest, orrc - the CLI archive checksums are present and match the published artifacts
- JSR packages have the expected version
9. Prepare for the next cycle
After publication, keep the release/$RELEASE_TAG branch. It remains the
durable base for hotfixes against that released line.
After a stable release, start the next development cycle by adding or reopening the Unreleased section in CHANGELOG.md.
If you want checked-in base versions to immediately move to the next line, do that in a separate follow-up change:
OLD_BASE_VERSION=<released-base-version>
NEW_BASE_VERSION=<next-base-version>
cargo run --manifest-path xtask/Cargo.toml -- release bump --from "$OLD_BASE_VERSION" --to "$NEW_BASE_VERSION"