Contributing · 9. Releases and packaging

A release is a tag push. Everything after that is automated, and three offline gates can block it: a byte-flip tamper test, a custody export-and-verify test, and a syscall-level assertion that the daemon makes no non-loopback network call on a default boot. That last one is the flagship, and section 9.4 documents exactly what it checks.

This is reference material. Cutting a release is a maintainer action; understanding it is how you know what your change has to survive.

9.1 Cutting a release

scripts/cut-release.sh 0.5.53

No leading v. The script:

StepDoes
1Refuses on a dirty working tree, or if a v<version> tag already exists
2Bumps version only within the [workspace.package] section of Cargo.toml
3Runs cargo update --workspace to refresh Cargo.lock, so the Dockerfile's cargo build --locked stays valid
4Commits chore(release): v<version> and creates an annotated tag v<version>
5Does not push. It prints the two commands for you to run
git push origin HEAD
git push origin v0.5.53

The Version sync workflow then fails any v* tag whose [workspace.package].version does not equal the tag. That gate matters because the workspace version is reported by /v1/version, by the MCP initialize handshake, and by .well-known/agent-card, a mismatch is a lie told to every client.

9.2 The release workflow

release.yml triggers on push: tags: ["v*"]. Top-level permissions: {}, every permission is granted per job, never workflow-wide.

The build matrix

TargetRunnerAsset suffixCross-compiled?
x86_64-unknown-linux-gnuubuntu-latestlinux-amd64No
aarch64-apple-darwinmacos-14darwin-arm64No
x86_64-apple-darwinmacos-15-inteldarwin-amd64No
x86_64-unknown-linux-muslubuntu-latestlinux-musl-amd64Yes, build only
aarch64-unknown-linux-gnuubuntu-latestlinux-arm64Yes, build only

macos-13 is retired, and its retirement stranded the v0.4.7, v0.5.0 and v0.5.1 runs. The workflow comments record it.

Two setup quirks worth knowing, both scar tissue:

arduino/setup-protoc@v3 runs authenticated, because anonymous lookups flaked twice during v0.4.9. Note that this is the release path's protoc, not a build requirement, see 1.1.

The cross legs run an explicit rustup target add even though the toolchain action takes a targets: input, because rust-toolchain.toml pins 1.88.0 while the action lands on stable. Without it you get E0463, which is what happened in v0.5.45.

The job graph

JobCheck namePermissionsDoes
buildBuild (<target>)contents: read, id-token: writeTests on native legs, builds, runs the trust gates on linux-amd64, generates the SBOM, packages, signs
releaseCreate Releasecontents: writeMerges artefacts, asserts basename uniqueness, publishes with generated release notes
combine-hashesCombine provenance subjects{}Concatenates the three native legs' subject blocks
provenanceSLSA provenancevia the reusable generatorslsa-framework/slsa-github-generator generic SLSA3 generator, upload-assets: true
update-manifestUpdate manifestGenerates, signs and attaches update-manifest.json plus its .sig and .pem

On the native legs, tests run before the build:

bash scripts/assert-daemon-release-boundary.sh
bash scripts/assert-hosted-bridge-contract.sh
cargo test --locked --workspace

Then, on linux-amd64 only, the three trust-surface gates:

bash scripts/assert-no-phone-home.sh target/release/corecruxd
CORECRUXCTL=target/release/corecruxctl bash scripts/demo-receipt-tamper.sh
CORECRUXCTL=target/release/corecruxctl bash scripts/assert-context-custody.sh

Two deliberate limitations are documented in the workflow, and both are the kind of thing usually left unsaid:

The cross legs are cosign-signed but are not wired into SLSA subjects yet. combine-hashes hard-fails on an empty subject block, so one flaky cross leg would break provenance for the entire release. The stated plan is to fold them in once the cross legs prove stable. That is good first contribution number 9.

The SLSA generator is referenced by tag, not by commit SHA, a documented exception to the repository's pinned-SHA rule, because the generator requires tag refs for its reusable workflows. Everywhere else, third-party actions are pinned to SHAs on the stated grounds that "floating tags are themselves a T.5 supply-chain vector".

Signing

sigstore/cosign-installer is pinned to a commit SHA with cosign-release: 'v2.4.3'. The version pin is not cosmetic: cosign v3.0's sign-blob writes a bundle by default, and that broke v0.4.8's signing.

Per leg, six artefacts are signed: the four binaries, the CycloneDX SBOM, and the release manifest. On linux-amd64 the .deb and install.sh are signed as well.

SBOM

Generated with cargo-sbom 0.10.0, --output-format cyclone_dx_json_1_6, written to dist/crux-daemon-<suffix>.cdx.json. It is generated before packaging, so that the release manifest's checksum list covers it.

Because Cargo.toml:46 sets license = "Apache-2.0" and every member inherits it, the SBOM reports Apache-2.0 for all 28 workspace crates. See 8.5.

9.3 What each release gate asserts

ScriptAsserts
assert-no-phone-home.shNo non-loopback network egress on a default boot. Section 9.4
assert-daemon-release-boundary.shThirteen required distribution files exist; the CUDA and GPU exclusion boundary holds; the package-script artifact markers are present; the basename guard passes both a positive and a negative fixture; and a package smoke test runs when target/release binaries exist. Also runs PR-time in Lint
assert-hosted-bridge-contract.shFour targeted test runs: crux-router hosted_bridge, crux-router credit_ledger, crux-mcp pro_hosted_token_lists_hosted_gated_tools, crux-mcp hosted_gated_tool_capability_uses_hosted_backend
assert-release-asset-basenames.shNo two files in the release-assets directory share a basename, because GitHub's release-asset namespace is flat. It finds, basenames, sorts and requires uniq -d to be empty
assert-context-custody.shThe custody exit test: seed a receipt, export, verify ok=true with a zero exit, tamper one byte inside memory.cruxpack, then require ok=false with a hash mismatch and a non-zero exit. Offline; needs only corecruxctl and jq. Also runs PR-time in Test
demo-receipt-tamper.shverify-store catches an on-disk byte flip. Offline. Also runs PR-time in Test

The thirteen files assert-daemon-release-boundary.sh requires: LICENSE, NOTICE, content/LICENCE-CONTENT.md, TRUST-CONTRACT.md, README.md, config.example.env, config.example.yaml, docs/release-packaging.md, content/MANIFEST.json, content/README.md, packaging/install.sh, scripts/assert-release-asset-basenames.sh, scripts/package-daemon-release.sh.

scripts/assert-layer1-acceptance.sh exists in scripts/ but is not invoked by any workflow. It is either a dead gate or one somebody forgot to wire. Do not assume it runs.

9.4 assert-no-phone-home.sh, exactly

This is the Tier-1 egress assertion, threat reference T.5, and it is the single most load-bearing trust gate in the release. Here is precisely what it does, so you can judge for yourself what it proves.

Two modes, auto-selected. Force one with NO_PHONE_HOME_MODE.

strace mode: preferred, and what CI uses

Setup. The binary is copied into a fresh mktemp -d that is never a git checkout. That is deliberate: the git-based update-posture probe in update.rs is a documented exception for repo-checkout deploys, and putting the binary somewhere with no git means the probe has nothing to fetch, "exactly the binary-install shape we distribute" (assert-no-phone-home.sh:21).

Boot. The daemon starts under a scrubbed environment, env -i with only PATH, HOME, CORECRUXD_AUTH_MODE=off, the data directory, and three loopback host and port pairs (assert-no-phone-home.sh:145), traced by:

strace -f -qq -e trace=connect,sendto,sendmsg,sendmmsg -o "$TRACE" ./corecruxd

Critically, it runs with default update-check environment, deliberately not disabled. The comment at assert-no-phone-home.sh:143 states the reason in six words: "the assertion must hold with defaults."

Exercise. Wait for /readyz for up to 30 s, then idle 15 s, NO_PHONE_HOME_IDLE_SECS, specifically to catch background dial-outs that a fast test would miss. Then exercise /v1/version and /healthz under trace, and wait two more seconds.

Judge. assert-no-phone-home.sh:193:

VIOLATIONS="$(grep -E 'sin6?_family=AF_INET6?' "$TRACE" 2>/dev/null \
  | grep -Ev 'inet_addr\("127\.[0-9.]+"\)' \
  | grep -Ev 'inet_pton\(AF_INET6, "::1"' \
  || true)"

Any AF_INET or AF_INET6 connect, sendto, sendmsg or sendmmsg whose target is not 127.0.0.0/8 or ::1 is egress, and it exits 1 as a release blocker. AF_UNIX and AF_NETLINK are local by definition and are not matched.

netns mode: the fallback

Boots the daemon inside an unprivileged network namespace that has only loopback (unshare -r -n) and asserts it becomes fully ready and serves /readyz, /healthz and /v1/version.

This proves offline-first functionality. It cannot observe egress attempts, inside the namespace they simply fail. The script's own header says so. If you are using this mode to satisfy yourself about egress, you are using the wrong mode.

Exit codes and ports

ExitMeans
0Pass
1Egress detected, or the daemon failed to boot offline. Release blocker
2Prerequisites missing: no curl, or neither strace nor unshare plus ip

Ports: NO_PHONE_HOME_HTTP_PORT 24800, NO_PHONE_HOME_GRPC_PORT 24807, NO_PHONE_HOME_MCP_PORT 24801.

It cannot run PR-time. Those fixed ports collide with leftover daemons on the shared self-hosted runner, so it runs only on the ephemeral ubuntu-latest release leg. That is a real coverage gap between merge and release, and it is stated in the script's own CI wiring note (assert-no-phone-home.sh:33).

9.5 What ships in a release bundle

Per docs/release-packaging.md, each target bundle from scripts/package-daemon-release.sh contains:

Contents
corecruxd-<target>, the canonical daemon
crux-<target>: a user-facing alias, the same binary
corecruxctl-<target>
crux-hook-<target>
LICENSE, NOTICE, and TRUST-CONTRACT.md
README.md and config.example.yaml
content/MANIFEST.json, content/CONTENT-README.md, content/LICENCE-CONTENT.md
RELEASE-MANIFEST-<target>.txt, SHA-256 checksums keyed by the flat public asset basenames

content/CONTENT-README.md is renamed during staging so its basename stays distinct in GitHub's flat asset namespace. The shared licence and documentation files are not signed per leg; their .sig names would collide when the matrix artefacts merge, but the release manifest's SHA-256 list transitively covers them, and that manifest is signed.

docs/release-packaging.md is refreshingly explicit about what the boundary script does not prove: hosted backend behaviour, GPU or CUDA acceleration, production deployment safety, Trivy results, cosign signatures, SLSA provenance, or runtime configuration on an operator host. Read that list before you cite the gate as evidence of something.

9.6 The Docker release path

docker.yml, job Build and Push Docker Image.

StepBehaviour
Tag policypython3 scripts/check-docker-tag-policy.py enforces the immutable release-tag policy first
BuildBuilds locally with push: false
ScanTrivy scans the local image. SARIF is uploaded to the Security tab
PushOnly if there are no CRITICAL vulnerabilities
Signcosign keyless signs and attests the pushed image

The CRITICAL gate can be skipped only through a manual workflow_dispatch with a structured waiver. When skip_trivy_gate=true, all three of trivy_waiver_owner, trivy_waiver_expires (YYYY-MM-DD) and trivy_waiver_reason are required. The waiver records the commit SHA, run ID and image reference, and is uploaded as a 90-day artifact. docs/release-packaging.md states the principle: "The skip is logged in the run, never silent."

Floating aliases such as :latest move only after the complete Release workflow succeeds, via a workflow_run trigger and a separate Promote accepted release aliases job that "never checks out or executes code from the release ref".

9.7 The changelog, and its current gap

CHANGELOG.md follows Keep a Changelog 1.1.0 and Semantic Versioning. The cadence rule is its own, at CHANGELOG.md:8:

"weekly rolling builds are cut from main; versioned releases ship every 4–8 weeks. Every versioned release gets human-readable notes here, if you tag a release, you write its entry."

That rule is not currently being kept. The newest released heading is ## [0.5.38] - 2026-07-10 at CHANGELOG.md:30. The workspace is at 0.5.52 (Cargo.toml:45). Fourteen versions have shipped with no changelog entry, directly contradicting the file's own stated rule. There is no version-heading gate in CI to catch it, Version sync checks that a tag matches the workspace version, not that the changelog has a matching heading.

For a contributor, two practical consequences:

Do not use CHANGELOG.md to work out what changed between 0.5.38 and 0.5.52. Use git log and the GitHub release notes, which softprops/action-gh-release generates automatically with generate_release_notes: true.

Still add your [Unreleased] entry. The [Unreleased] section is current and maintained; it is the per-release headings that have fallen behind. See 7.8.

Adding a CI gate that fails a v* tag with no matching changelog heading would be a small, well-scoped contribution that closes this hole permanently.

9.8 SDK release lifecycle

docs/sdk-release-lifecycle.md governs @cuecrux/client (npm, sdks/typescript/) and corecrux-client (PyPI, sdks/python/).

RuleDetail
SDK versions are decoupled from the daemon versionA daemon v* tag builds and packages both SDKs but never receives registry-write permission. Both workflows declare permissions: contents: read at workflow level, with the comment "Build/test runs never receive registry-write OIDC permission"
Publishing requires its own tagAn explicit sdk-python-vX.Y.Z or sdk-typescript-vX.Y.Z tag matching the package version exactly
API version pinningEach SDK declares the API version it was generated and tested against, pinned to the daemon's /v1/openapi.json at the release commit. If regeneration produces no diff, no SDK release happens
Pre-1.0 semanticsBoth are 0.x: minor may break, patch is safe
Support windown−1. Deprecations ship as warnings one release before removal
Publish integrity (T.5)Target state, gated: npm --provenance with Trusted Publishing (OIDC) replacing NPM_TOKEN; PyPI pypa/gh-action-pypi-publish OIDC replacing PYPI_TOKEN and twine. Not shipped today

9.9 The update channel

docs/update-channel.md. The posture is stated as non-negotiable, and it is the reason section 9.4's gate exists:

PrincipleMeaning
Notification, never automatic actionNothing updates in the background. An upgrade is always an explicit operator command that re-verifies artifacts
Opt-in checkThe daemon makes no outbound connections by default. The proof is scripts/assert-no-phone-home.sh
No trackingThe manifest is a static GitHub Releases file fetched with a plain GET, no query parameters, no instance id, no version in the URL. The document's own words: "We cannot distinguish a check from a download, and that is the point"

The manifest schema is crux.update_manifest.v2, published at https://github.com/CueCrux/Crux/releases/latest/download/update-manifest.json, generated by scripts/generate-update-manifest.sh from the already-published, cosign-verified release manifests, and signed like every other artefact.

Sources