Release bundle
A release is published from one flat directory, the release bundle, built by
cargo xtask release assemble out of the artifacts the producer jobs
uploaded. The assembler recomputes every digest from bytes and writes the
bundle only when every check passed; the publisher uploads exactly the files
listed in the bundle’s inventory. Implemented in crates/xtask/src/release*.rs;
the signed catalog it builds is described in the
runtime catalog and the per-row evidence it verifies in
release attestation.
Release policy
Section titled “Release policy”packaging/release-policy.json (schema 1, unknown members refused) is the one
source of the expected inventory:
catalog_validity_days: days from the commit time until the catalog expires (365).archives: every{component, target}a release contains.clianddaemonforaarch64-apple-darwin,x86_64-unknown-linux-gnuandx86_64-unknown-linux-musl, andrelayforx86_64-unknown-linux-gnu. The archive name ispohunek-<component>-<version>-<target>.tar.gz, the namespackaging/stage-archiveproduces.sdk_packages:protocol,sdkandtestkit, shipped aspohunek-ts-<name>-<version>.tgz.
The official runtime packages are the runtime-packages/* directories and the
attested rows are compat/matrix.json; neither is repeated in the policy.
Inputs
Section titled “Inputs”--inputs is a flat directory of downloaded producer artifacts under their
existing names: every policy archive and SDK tarball with its .sha256 file,
one pohunek-runtime-<runtime>-<version>.tar.zst per official runtime (the
name carries the core release version, not the package’s own version) and one
attestation-<runtime>-<target>.json per matrix row. The assembler refuses, in
this order and naming only the file class and name:
- an input set that differs from the expectation: a missing file, a second file that repeats an expected slot (for example another package archive of a runtime), an unexpected file, a subdirectory or a link;
- a
.sha256file that is not<64 hex> <name>or does not match the bytes; - a daemon archive that cannot be safely unpacked (member paths leaving the
tree, links and special files, repeated members, member count and size
limits) or whose
MANIFESTdoes not match its unpacked bytes, names another component, version or target, recordssigning unsigned-development, or whoseruntime-catalog-anchor.jsondiffers from--anchorbyte for byte; - any matrix row whose attestation does not equal the one recomputed from the
unpacked binaries (binary-set digest), the package archive, the repository’s
lock, the commit and the target (
compat verify).
Catalog rules
Section titled “Catalog rules”sequence = major << 40 | minor << 20 | patch, so it orders like the version. A version component of2^20or more is refused. The sequence is never read from the environment.expires_at = commit time + catalog_validity_days * 86400. The commit time is also the reference time for signing and for verifying against the anchor, so no wall clock is read and the same inputs give the same bytes.core = "=<version>"for every entry;release.binary_setscovers all daemon targets of the policy, each digest computed from the unpacked archive.- An entry’s
platformsare exactly the targets with an attested matrix row. These are the Linux glibc and musl targets, and Apple Silicon (aarch64-apple-darwin). - Each attestation digest is the SHA-256 of the attestation file bytes.
- The catalog is signed with
--key-file(never printed) and must verify against--anchor.
Layout
Section titled “Layout”The bundle holds the policy archives (CLI and relay unchanged), the SDK
tarballs, their .sha256 files, runtime-catalog.json, every package archive,
every attestation and release-inventory.sha256. Each daemon archive keeps the
producer tree and adds runtime/runtime-catalog.json, plus, for the packages
its target attests, runtime/packages/<runtime>.tar.zst and
runtime/attestations/<runtime>-<target>.json. Its MANIFEST is rewritten with
the producer’s signing state and minimum macOS version through
packaging/write-manifest, and the archive and its .sha256 are regenerated
with packaging/archive stamped by the commit time, so a rerun is
byte-identical.
Inventory
Section titled “Inventory”release-inventory.sha256 lists every file of the bundle except itself as
<64 hex> <name> (sha256sum -c format), names without directories, sorted.
It is the exact list the publisher uploads. cargo xtask release verify-inventory --dir <bundle> --anchor <file> [--now <seconds>] requires
the directory to hold exactly the listed regular files with the listed
digests, requires every daemon archive to ship exactly the --anchor file
(otherwise a trust-anchor mismatch), verifies the top-level catalog against
that anchor (at --now, default the current time), and requires each daemon
archive to carry that catalog, the binary set the catalog names for its
target and exactly the packages and attestations the catalog lists for that
target, byte-identical to the top-level files. The anchor comes from the
caller, never from the bundle: a bundle built by someone else ships its own
anchor and a catalog signed under it. Each top-level package archive must
match the digest of that verified catalog before it is decompressed and read,
so a tampered package is refused as a package-archive mismatch without
reaching the decoder. The release workflow passes the anchor it assembled
with (packaging/runtime-catalog-anchor.json, or the rehearsal’s throwaway
anchor).
Failure behavior
Section titled “Failure behavior”The bundle is built in a hidden sibling directory of --output and renamed
into place at the end. --output must not exist (an empty directory is
accepted). On any refusal nothing is left behind.
Archive smoke
Section titled “Archive smoke”packaging/smoke-archive takes a final daemon archive from the bundle, the
staged upstreams (<stage>/<runtime>/ with STAGE.sha256) and the prebuilt
release consumer executable, and runs the consumer once per shipped package in
a network- and PID-isolated namespace (unshare through a user namespace, or
sudo -n unshare with a setpriv drop; no un-isolated fallback). The runtimes
are the runtime/packages/*.tar.zst the archive carries; a shipped package
without a staged upstream fails. scripts/release-workflow/run-smoke stages
every runtime of compat/matrix.json once and keeps an untouched copy, and
before each Linux daemon archive restores every runtime from that copy at the
same absolute path and checks it with cargo xtask compat verify-stage: a
consumer may write into the stage it ran from (a provisioned Python upstream
writes bytecode and state files), and the smoke refuses a stage that differs
from its manifests. Each consumer report must name the catalog
entry’s package digest and the SHA-256 of the archive’s own binaries. On macOS,
the Apple Silicon archive runs under sandbox-exec as a throwaway local user
created per run (random uid 40000-59999, via dscl); creating it needs
passwordless sudo -n, and without it the smoke fails closed after archive and
stage validation. The procedure and its limits are in
docs/development.md (“Archive smoke”).
Release workflows
Section titled “Release workflows”Three workflow files build and publish a release; the structure below is pinned
by scripts/tests/test_release_workflow.py.
| File | Role |
|---|---|
release.yml |
Tag trigger, the quality gate, the macOS jobs, two calls and the single publish job. |
release-build.yml |
Reusable producers: Linux archives, one job over runtime-packages/*, SDK tarballs. |
release-evidence.yml |
Reusable evidence: rows, provenance, assembly and signing, bundle provenance, archive smoke, verdict. |
ci.yml calls the two reusable files with mode: rehearsal (jobs
release-rehearsal-build, release-rehearsal); release.yml calls them with
mode: release.
Producers upload workflow artifacts only: build-<component>-<target>,
package-archives, sdk-release-assets and the macOS macos-signed-<component>.
The package job also uploads package-evidence, the builder evidence of every
archive (cargo xtask package build --evidence-dir); no release job downloads
it, so it is never a release input or asset.
The evidence workflow plans the rows from compat/matrix.json with jq; every
row extracts the binaries from the daemon ARCHIVE of its target, stages the pinned
upstream (with npm, or for a lock whose upstream.kind is provisioner, such
as Hermes, with the repository script it names, for which the rows and
smoke jobs install Python and uv) and runs the consumer suite, then
compat attest writes
attestation-<runtime>-<target>.json. The row also writes the wall clock and
outcome of the consumer run as a one-testcase JUnit report
(matrix-junit-<runtime>-<target> artifact, uploaded also when the run fails);
it is timing evidence only, never a release input. attest attests every producer artifact;
assemble verifies every input with gh attestation verify (signer workflow
release-evidence.yml, signer and source digest equal to the release commit,
source ref equal to the tag, GitHub-hosted runners only), collects the flat input
directory and runs cargo xtask release assemble with the commit time from
git log, then verify-inventory with the same --anchor. attest-bundle attests every file of the
bundle, smoke runs packaging/smoke-archive on every Linux daemon archive
with the isolation strategy a probe step proved available, and on the Apple
Silicon archive under sandbox-exec on macos-15, and verdict fails
unless exactly the jobs of the mode succeeded (a skipped job counts as success
to a caller). The bundle is the artifact release-bundle.
publish is the only job with contents: write. It needs every producer and
the evidence call, downloads only release-bundle, and uses gh and coreutils
only: it checks the bundle against release-inventory.sha256 (exactly the listed
files plus the inventory, every versioned name carrying the tag’s version),
verifies each file’s provenance, requires the tag to resolve to the run’s commit
and no release of the tag to exist (the releases listing includes drafts), creates
a draft with gh release create --draft --verify-tag, uploads the bundle, compares
asset names, digests and sizes read back from the API with the bundle, publishes
with gh release edit --draft=false, re-checks the tag binding before and the
assets after, and deletes the draft it created if any step fails. No other job
creates, edits or attaches to a release, so a visible release is complete or absent.
Trust and permissions
Section titled “Trust and permissions”- Every workflow default token is
permissions: {}; each job widens it tocontents: read, exceptpublish(contents: write) and the two attest jobs. - The OIDC scopes (write access to
id-tokenandattestations) exist only onattestandattest-bundle, which download artifacts, check checksums and attest; they check out nothing and run no repository code, and are skipped in a rehearsal. The caller jobs (evidenceinrelease.yml,release-rehearsalinci.yml) grant the same scopes because a called workflow cannot hold a scope its caller lacks. - The signing seed is the secret
CATALOG_SIGNING_KEY_CIof the environmentrelease(deployment policy: tagsv*), read only by the signing step of theassemblejob, which exists only in release mode and restores no cache.scripts/release-workflow/assemblewrites it to a 0700 tmpfs directory with mode 0600, removes it from the environment before the first program starts and shreds it on exit. The key id handed to xtask is the root ofpackaging/runtime-catalog-anchor.jsonthat carriespackaging/catalog-trust/ci.pub, so a seed of any other root (the offline primary) is refused, and the catalog must verify against the anchor. - A rehearsal has no secret, no environment and no provenance. The daemon
archives are staged with a throwaway anchor written over
packaging/runtime-catalog-anchor.jsonin the build job’s own checkout, andassemble-rehearsalsigns with the key derived fromsha256("rehearsal <run id> <commit>"); it refuses a tag, so a rehearsal key never signs anything a release trusts and nothing is published. It runs the full policy minus the targets it does not build (the macOS archives). - Every action is pinned to a commit; untrusted values reach scripts through
env:, never through expression interpolation.
What a rehearsal cannot show, and only a tag run does: signing with the production
key against the production anchor, gh attestation verify against real
attestations, the macOS jobs, and the draft/publish steps of publish.