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

Packaging & distribution

rolter ships three ways.

The unified rolter binary dispatches to both planes via subcommands:

rolter gateway --config rolter.toml     # data plane
rolter control --database-url postgres://…   # control plane + UI host

The standalone rolter-gateway / rolter-control binaries remain available.

cargo

cargo install rolter            # unified launcher (from crates.io)
# or from source:
cargo install --path crates/rolter

uv (PyPI wheel via maturin)

The wheel bundles the compiled rolter launcher so Python users can install the CLI with uv. pyproject.toml uses the maturin backend (bindings = "bin", manifest-path = crates/rolter/Cargo.toml).

Each release publishes five wheels plus a source distribution:

artifactbuilt on
manylinux…x86_64ubuntu-latest, target: x86_64
manylinux…aarch64ubuntu-latest, target: aarch64
macosx…arm64macos-latest (Apple Silicon, native)
macosx…x86_64macos-latest, cross-compiled target: x86_64-apple-darwin
win_amd64windows-latest
.tar.gz (sdist)ubuntu-latest, command: sdist

The macOS x86_64 wheel is cross-compiled rather than built on an Intel runner — the macOS SDK carries both architectures, so it needs no extra runner. The sdist is the fallback for anything with no matching wheel: without one, pip install rolter fails outright on an unlisted platform instead of building from source. verify-parity asserts all six are present for the version, so a silently missing platform fails the release rather than reaching a user.

uv tool install maturin       # one-time
uvx maturin build --release   # build a wheel into target/wheels/
uv tool install rolter        # once published to PyPI

Docker

Multi-stage docker/Dockerfile builds the Rust binaries and the Bun-built UI, then assembles a slim runtime:

docker build -f docker/Dockerfile -t rolter:dev .
docker compose -f docker/docker-compose.yml up -d          # full stack with postgres/redis/clickhouse

Release pipeline

Releases are fully automated from Conventional Commits. Two workflows do the work, and the handoff between them is the part worth understanding.

merge to master
      │
      ▼
release-plz.yml ── release-pr ──►  "Release PR" (version bump + changelogs)
      │
      │ (that PR is merged)
      ▼
release-plz.yml ── verify ──► release ──►  crates.io publish
                                           tag v{version}
                                           github release
      │
      │ workflow_dispatch -f tag=v{version}      ← dispatch-artifact-release
      ▼
release.yml
  │
  ├─ gate ──── verify-external-checks (ci-ok + CodeQL green for the tagged sha)
  │
  ├─ build ─── build-wheels  (5 wheels + sdist)
  │            build-image   (per arch, pushed as untagged digests)
  │
  ├─ smoke ─── smoke-wheels  (install each wheel, run `rolter --version`)
  │            smoke-image   (run each image digest, check its version)
  │
  ├─ publish ─ publish-pypi    (trusted publishing, OIDC)
  │            publish-docker  (assemble tag manifests: GHCR + Docker Hub)
  │
  └─ check ─── verify-parity  (all channels serve {version})

The stages are a barrier, not decoration. Every publish job depends on every build and smoke job, so a release is all-or-nothing: a failed wheel can no longer leave container images published against a version that has nothing on PyPI. Before this split, publish-docker did not depend on build-wheels at all, and exactly that partial release was possible.

Two properties make the barrier real:

  • Images are built as untagged digests (push-by-digest). A digest nobody can resolve by tag is not a release; publish-docker only assembles the :{version} and :latest manifests once everything else has passed, from those same digests — so the image is never rebuilt.
  • Nothing is published untested. The smoke stage installs each wheel and runs each image digest, asserting rolter --version matches the tag, while the artifacts are still private. The wheel install uses --no-index, so it can only resolve from the freshly built dist/ and can never pass by silently pulling an older rolter from PyPI.

Each build is its own job, so a single flaky platform can be re-run on its own without re-publishing anything that already succeeded.

Why the explicit dispatch

release.yml also has a push: tags trigger, but it never fires for a real release. release-plz creates the tag with the repository GITHUB_TOKEN, and GitHub suppresses downstream workflow events for token-created refs to prevent recursive runs. workflow_dispatch is the documented exception — it always creates a run, even from the GITHUB_TOKEN — so release-plz.yml ends with a dispatch-artifact-release job that calls gh workflow run release.yml -f tag=vX.Y.Z. That job holds actions: write and nothing else, and it fails if the dispatch produces no run.

Without it the pipeline half-works in the worst way: the GitHub release and crates.io advance while no wheel is ever built, and every job stays green. That is how v0.0.6 through v0.0.10 shipped while PyPI sat on 0.0.5 (#903). scripts/check-release-handoff.sh (a merge gate in quality.yml and a prek hook) asserts the wiring is still in place.

Which tag the dispatch carries

releases, the output release-plz hands back, contains a tag for every published crate — not only the one with git_tag_enable. The crates.io-only members get a derived <crate>-v<version> string that was never pushed to git. Only rolter-gateway is configured with git_tag_name = "v{{ version }}", so resolve release tag selects that entry by package name; an empty result means nothing was released on this push and the dispatch job is skipped.

Taking the first tagged entry instead is what broke v0.0.11 (#1026): the array starts with rolter-core, the dispatch carried rolter-core-v0.0.11, and release.yml failed at checkout on a ref that does not exist — so v0.0.11 went to crates.io and GitHub Releases with no wheel, and PyPI stayed on 0.0.10. The step now also rejects any resolved tag that is not vX.Y.Z, so a bad ref fails before it is dispatched rather than halfway through the artifact build.

Parity gate

verify-parity runs with always() at the end of release.yml and asserts that every enabled channel actually serves the tagged version: the GitHub release exists, crates.io has rolter {version}, PyPI has it (when PYPI_PUBLISH_ENABLED is true), and GHCR has the manifest (when DOCKER_PUBLISH_ENABLED is true). A skipped or failed publish turns the run red instead of quietly leaving a channel behind.

Publishing gates

GateEffect
verify-external-checksci-ok and CodeQL recorded success for the tagged commit; fail-closed
RELEASE_REQUIRED_CHECKS repo variableexact check-run names the gate above requires (comma-separated)
PYPI_PUBLISH_ENABLED repo variablemust be "true" or the PyPI publish is skipped
DOCKER_PUBLISH_ENABLED repo variablemust be "true" or the image publish is skipped
pypi environmentPyPI trusted publishing via OIDC; no long-lived token is stored

Wheels are built with maturin-action but uploaded with pypa/gh-action-pypi-publish: maturin upload is deprecated and slated for removal (PyO3/maturin#2334). The publisher identity PyPI matches on is the repository, workflow filename and environment — not the tool — so the swap is transparent to the trusted-publisher config, and it adds PEP 740 attestations on the id-token grant the job already holds.

The gate is asserted, not re-run

release.yml does not run quality.yml itself. It asserts that the tagged commit already passed it, by requiring ci-ok among the check-runs recorded for that SHA. That is deliberate, and it is what makes the gate correct:

A local reusable workflow (uses: ./…) always checks out the caller’s ref. On a workflow_dispatch the caller ref is master, while build-wheels checks out inputs.tag — so a re-run verified master and shipped the tag (#988). It passed, and told you nothing about what was being packaged. Threading the tag into quality.yml fixes that but makes the shared workflow check out an arbitrary dispatch-supplied ref in a default-branch context, whose caches trusted runs later restore — cache poisoning, and CodeQL flags it.

Asserting settles both. Every commit on master carries a ci-ok check-run from ci.yml, and release-plz re-runs the gate on the release commit before tagging, so a tagged commit is verified by construction. The assertion binds to the tagged SHA — which re-running never did — costs no duplicate 20-minute run, and checks out nothing.

Because release-plz dispatches the moment it finishes tagging, ci-ok is often still running for that commit. A pending check is therefore expected, not a failure: the job waits up to 45 minutes for a verdict, fails immediately on a real non-success, and fails closed if a required check never appears.

RELEASE_REQUIRED_CHECKS holds exact check-run names, so it rots whenever a scanner is renamed or reconfigured — and since the gate is fail-closed, a stale name silently blocks every release instead of failing at the source. This bit rolter once already: the variable still named the CodeQL default setup jobs (Analyze (rust), …) after the repo moved to advanced setup (codeql (rust), …), so no release could publish even with a working tag dispatch. If the gate reports “required check … not found”, compare it against the check-run names the job log prints and update the variable.

Releasing a tag by hand

For a backfill, or if a dispatch was lost, run the artifact half yourself from the default branch:

gh workflow run release.yml -f tag=v0.0.10
gh run watch "$(gh run list --workflow=release.yml --limit 1 --json databaseId -q '.[0].databaseId')"

The upload uses --skip-existing, so re-running a tag that partly published is safe. Verify the result:

curl -s https://pypi.org/pypi/rolter/json | jq -r .info.version
uv tool install rolter && rolter --version

Backfill policy

Only the current release is backfilled to PyPI. The versions the broken handoff skipped (v0.0.6 – v0.0.9) stay unpublished: they are superseded pre-1.0 releases, uv tool install rolter / pip install rolter resolve to the latest version regardless, and publishing them retroactively would put four versions on the index that no user ever pinned, dated years after their tags. Anyone who needs one of them can build from the tag or cargo install rolter@0.0.x from crates.io, which has the complete series.