ADR 0026: Rust-native releases and runtime assets
Canonical Quirl project documentation synced from docs/decisions/0026-rust-native-releases-and-runtime-assets.md.
- Status: Accepted
- Date: 2026-08-18
- Extends: ADR 0021, ADR 0024, and ADR 0025
- Supersedes: ADR 0025's checked-in/raw-
mainmodel distribution decision; model identity, validation, and retrieval policy remain unchanged
Context
Quirl needs one release policy that can be exercised locally and orchestrated by CI without reimplementing version selection, changelog generation, packaging, checksums, manifests, or Homebrew rendering in workflow YAML. It also needs to distribute a completion database and command-retrieval model without increasing the executable or forcing first-prompt network access.
These operations cross several failure boundaries: a release may fail after some platform builds but before publication; a tag or published byte may already exist; a runtime download may be truncated, corrupt, incompatible, or interrupted; and updating the separate Homebrew tap requires authority that the Quirl repository token does not have. Mutable identities or partially installed files would turn those failures into ambiguous releases or broken local state.
Decision
Rust owns release policy
The existing xtask crate is the single source of release behavior. Its typed
release, assets, and homebrew commands own SemVer planning from
Conventional Commits, release preparation, verification, packaging,
aggregation, checksums, release and asset manifests, provenance, and formula
rendering. GitHub Actions invokes those Rust commands and transfers their
outputs; it does not duplicate their rules in YAML or shell.
Mutating commands are dry runs unless an explicit --write flag is present.
Machine-readable contracts carry a schema version and reject unknown fields.
Filesystem reads, retained bytes, collections, subprocesses, and waits have
explicit ceilings. Writes use same-directory staging, content synchronization,
atomic replacement, and cleanup on failure.
One immutable candidate identity
A release candidate is the tuple of version and clean Git commit. Every native binary reports both values, and packaging rejects a binary that does not report the planned version or candidate commit. The tag, source archive, package, manifest, checksums, provenance, release notes, completion database, and model manifest all name that same tuple.
Publication fails closed when a tag or release already exists. Automation must never move a tag, replace an uploaded asset, or rebuild bytes under an existing version. A failed unpublished candidate can be fixed and replanned. A failure after any public byte or tag exists requires a new version; recovery may delete an empty draft release only when no immutable tag or asset was published.
Packages are reproducible for identical inputs: entries have stable names and ordering, metadata is normalized, and timestamps derive from the candidate commit rather than wall time. Aggregation verifies every platform record, rejects duplicates and unexpected targets, and derives checksums and manifests from the exact package bytes it copies.
Runtime assets remain separate
The completion SQLite database and retrieval model are not embedded in the binary and are not installed by Homebrew. Version 0.1 stores them as immutable, versioned GitHub Release assets beside an asset manifest. The manifest records the clean candidate commit and source epoch; each entry contains a logical name, format or schema version, exact byte length, SHA-256 digest, immutable URL, and Quirl compatibility requirements.
The CLI owns a provider-neutral asset manager. GitHub Releases is the first URL provider, not a special case in installation or validation. A future S3 bucket or mirror supplies another manifest/provider implementation while retaining the same admitted asset identity and install transaction.
Interactive startup never waits for an asset or network. Missing assets leave Quirl in a documented degraded mode and can schedule bounded background work after startup or on first use. A single explicit lock/state machine coalesces concurrent requests. Downloads use a private temporary file, no shell, explicit redirect/size/connect/body/deadline limits, and observable cancellation. Quirl validates length, digest, manifest compatibility, file format, and private file shape before an atomic install. The previous valid asset remains in place until that validation succeeds, and partial files are removed.
Transient failures use capped exponential backoff with bounded jitter and a bounded persisted retry record. Later launches may retry after the recorded deadline. Integrity, compatibility, and unexpected-file failures are permanent for that manifest identity and require an explicit update or retry; they do not loop forever. Status, retry, and update commands expose actionable diagnostics. Tests inject transport behavior and never require public network access.
Publication and cross-repository authority
The publication environment receives only contents: write; pull-request jobs
receive no release or asset credentials. Asset-producing jobs that need private
inputs use separate protected environments and narrowly scoped secrets, never
fork pull requests. Logs contain identities and digests, not credentials or
signed download URLs.
Updating niklas-heer/homebrew-tap always uses a branch and pull request. The
preferred credential is a GitHub App installation token scoped to contents and
pull requests for that repository. A fine-grained personal access token with
the same single-repository permissions is the documented fallback. Neither
credential can publish Quirl releases. Owners rotate the App private key or PAT
independently, update the protected environment secret, verify a dry run, and
revoke the previous credential.
Release preparation uses a separate App installed only on Quirl with the same contents and pull-request permissions so its generated PR commit triggers the normal CI workflow. Both App tokens are minted only after credential-free Rust generation and verification succeeds, in separate protected jobs or steps; neither private key is available to pull-request code.
Failure model and invariants
- A partial build cannot be aggregated because the complete target set and candidate tuple are checked before any release manifest is committed.
- A partial publication cannot change an existing public identity; all existence conflicts stop the workflow.
- A partial runtime download cannot replace a valid installed asset; staging is private and replacement follows full verification.
- A stalled or malicious source cannot grow memory, disk, retries, redirects, or wall time without reaching a configured limit and cancellation point.
- Loss of GitHub, the asset host, credentials, or the Homebrew tap leaves Quirl usable from existing bytes and source, and leaves already published releases unchanged.
- Provider changes cannot weaken identity checks: byte length, SHA-256, compatibility, and format admission are provider-independent.
Consequences
- Local and hosted release behavior is reviewable and testable as Rust rather than distributed across scripts and YAML.
- Release bytes are immutable; corrections consume a new version instead of silently altering an existing installation source.
- Fresh installations can start immediately without the larger data assets, while offline installations retain explicit status and manual recovery.
- Initial publication depends on GitHub Releases, but storage migration does not require changes to asset installation or compatibility policy.
- Repository owners must configure protected release, release-preparation, and tap environments plus the two repository-scoped App installations before the first publication.