Release Pipeline
Mero TEE ships two coupled artifact families from one monorepo, both frozen GCP
TDX images sealed behind dm-verity: the mero-tee node image and the
mero-kms KMS image (merotee-kms-<profile>-<version>), which runs as one
cluster per release. Each has its own release workflow, but they share a version
number and a measurement contract: the KMS image bakes in the node image’s
measurements, and publishes its own for merod to pin. This page traces the real
pipeline: bump → build → measure → sign → publish → catalog.
Version coupling
Section titled “Version coupling”The KMS version and the node-image version move together. The release version
sync guard (release-version-sync-guard.yaml → scripts/policy/check_release_version_sync.sh)
fails a PR unless all three agree:
mero-kms/Cargo.tomlpackage.versionmero-tee/versions.jsonimageVersion- the
mero-kmsentry inCargo.lock
versions.json merodVersion is decoupled — it pins the calimero-network/core
merod tag baked into the node image.
KMS release — release-kms.yaml
Section titled “KMS release — release-kms.yaml”Workflow name Release mero-kms (the name is part of the Sigstore identity
merod checks, so it does not change). Triggers: push to master touching
mero-kms/, Cargo.toml, Cargo.lock, playbook-kms.yml, the mero-kms Ansible
role, scripts/attestation/**, scripts/release/kms/** or the workflow itself; plus
workflow_dispatch. It publishes mero-kms-v<version> only from a push to master,
and only when that tag does not exist yet. merod accepts a policy only when a
push-triggered run signed it, so a manual dispatch builds nothing; re-run a failed
release’s jobs instead, which keeps the original push event.
-
prepare— resolve the version and the release tags (scripts/release/kms/get-version-info.sh). -
node-policy— wait for the counterpart node releasemero-tee-v<version>and fetch itspublished-mrtds.json(wait-for-node-release.sh). Those are the measurements the KMS will release keys to, so the file’s Sigstore bundle must verify againstrelease-node-image-gcp.yamlonmaster(verify-node-policy.sh): editing the GitHub release does not change them. -
build-binary— build themero-kmsbinary (x86_64) from the release commit. -
kms-image(matrix over the three profiles) — build the KMS image with mkosi (ROLE=kms,scripts/release/mkosi/build-image.sh), frommero-tee/playbook-kms.yml’s roles: the binary, the profile’s node allowlist frompublished-mrtds.jsonbaked into/etc/mero-kms/kms.env(extract-node-allowlist.shtakes only 96-hex measurements and known TCB statuses, so no value can add a line or an entry tokms.env), the same lockdown as the node image, and an EROFS root behind dm-verity. The allowlist is part of the image and so of its measurements. The image ismerotee-kms-<profile>-<version>in familymerotee-kms-<profile>, the names mdma looks up, and the release keeps it.Then measure it (
run-cluster-probe.sh): boot it as a two-replica cluster on real TDX VMs (the same check as a branch build withrole: kms). The bootstrap replica generates a root, the second joins, both must report the same transport key, and Intel Trust Authority verifies both quotes. Their MRTD and RTMR0–3 are the KMS measurements the release publishes. The bootstrap replica’s RTMR1–3 must also be the onescompute_rtmr.pycomputed from the build before it booted. An image that is not proven this way is deleted.On
debuganddebug-read-only, check the KMS ships its logs: the bootstrap replica boots withlogs-endpointandobservability-tokenpointed at a throwaway receiver VM, and the job fails unless the receiver sees a push carrying that token (the log-auth check, as in the node release). Neither key is measured.locked-read-onlyskips it: it ships logs only over https, and the receiver is plain http.The probe measures each joiner before waiting on its join. Now and then a TD on GCE comes up with a different RTMR0 while MRTD and RTMR1–3 are equal, and the bootstrap rightly refuses it the root. The probe then prints the event log events that differ (
tdx_event_log.py diff), deletes that VM and starts another, as the runbooks replace a replica that cannot join. It fails only if no joiner inMAX_JOINER_ATTEMPTS(3) measures like the bootstrap. The measurements themselves are never relaxed. -
release-metadata— assemble (assemble-release-assets.sh): the per-profile attestation policy (kms_allowed_*from the measurement,node_allowed_*from the node release), the compatibility map, the release manifest, the checksums and the SBOM (Syft). Sign every asset with cosign keyless (Sigstore / Fulcio / Rekor), producing.sig,.pemand.bundle.jsonsidecars, verify the signatures, and publish the GitHub releasemero-kms-v<version>.
Agents in the KMS release
Section titled “Agents in the KMS release”Private agents ride the KMS release, best effort.
Alongside the node-policy wait, agent-image builds the reference agent image
for each profile: mero-agent-gate plus the reference agent, named
merotee-agent-<profile>-<version>. The reference agent is mero-bot when
versions.json pins meroBotVersion: the job downloads that release’s bundle
with the MERO_BOT_RELEASE_TOKEN secret (mero-bot is a private repository) and
checks it against its sha256 in build-inputs.lock.json. Without a pin, it is
the stub agent. It boots that image once with
ephemeral-store=true and has Intel Trust Authority measure it. On debug and
debug-read-only that VM also gets the two log keys, and the job fails unless
it pushed its logs with its token, as the KMS image must (the
log-auth check). A measured
profile’s KMS image then also bakes that agent allowlist (kms_agent_policy_file,
with the node allowlist’s TCB statuses, held to the same shapes by
extract-node-allowlist.sh --check and the mero-kms role). Release-metadata publishes it as
kms-agent-attestation-policy.<profile>.json, signed like the other assets.
If an agent build or measurement fails, nothing else changes: that profile’s KMS
image is built node-only, exactly as before agents, and the release goes on.
kms-image and release-metadata check their real dependencies’ results
explicitly for this. Every profile gets an agent image. None bakes a
provisioner: each running agent is claimed by its first owner
(owner claim), so no repository
variable or secret decides who sets an agent’s secrets.
After a successful release, Post-release agent e2e
(post-release-agent-e2e.yaml) proves the chain on fresh TDX VMs:
- It boots a one-replica KMS from the release image, and the agent image with a blank data disk pointed at it.
- merod verifies that KMS against the signed release policy, and the KMS
releases the agent’s disk key under the
mero-agent/prefix. - It verifies the gate against the signed agent policy, claims the agent with a throwaway owner key made on the runner, provisions a secret, and checks a second key is refused.
- It resets the agent and checks the disk reopens with the same signing key and owner, the owner provisions again, and the second key is still refused.
It runs for every profile the release published an agent policy for, and skips the others.
KMS release assets
Section titled “KMS release assets”Uploaded to the mero-kms-v<version> release, each trust asset with .sig,
.pem and .bundle.json Sigstore sidecars:
kms-attestation-policy.<profile>.json(one per profile) and the profile-lesskms-attestation-policy.json(thelocked-read-onlyone, which merod fetches by default). It pins the KMS bykms_allowed_mrtd,kms_allowed_rtmr0..3andkms_allowed_tcb_statuses, and itsmerod_config_pathistee.kms.attestation.kms-compatibility-map.json— the KMS release → node release, the per-profile images, policy URLs and hashes.kms-release-manifest.json,kms-checksums.txt(SHA-256 of the binary archives),kms-binaries-sbom.spdx.json,kms-rekor-index.json, andkms-trust-bundle.tar.gz(all of the above, for offline transfer).kms-agent-attestation-policy.<profile>.json, for each profile whose KMS image serves agents: the agent allowlist that image bakes in, which a provisioner or account owner verifies an agent’s quote against.
The KMS images themselves live in the images project, named
merotee-kms-<profile>-<version>; MDMA deploys clusters from them.
Node-image release — release-node-image-gcp.yaml
Section titled “Node-image release — release-node-image-gcp.yaml”Workflow name Release mero-tee. Triggers: push to master touching
mero-tee/versions.json; plus workflow_dispatch.
-
preflight— a release gate that resolves the target merod version and verifies thecalimero-network/corerelease tag (and its required tarball assets) actually exists, so the image build is blocked until core ships. ReadsimageVersionfromversions.json. -
profile_pipeline(matrix over the three profiles) — the measurement step, which boots a real TDX VM:- Build the image with mkosi (
mero-tee/mkosi-image, mdma#480) throughscripts/release/mkosi/build-image.sh, as root: an EROFS root behind dm-verity, booted as a UKI, from the pinned archive snapshot. The build checks the UKI’s command line, the image’s files (GCE’s disk-naming rule andgoogle_nvme_idamong them) and that files keep their owners, and computes RTMR1–3 from the disk and UKI before anything boots (compute_rtmr.py). Two builds of one commit give the same image (see Build the images with mkosi).create-gce-image.shcreates it under the name, family and description mdma finds images by (release-image-name.sh). - Create an ephemeral firewall rule and a
c3-standard-4Intel TDX VM from the new image, with the same 200 GBpd-balanceddata disk (device namedata) mdma attaches to every node at creation. The disk is part of the machine RTMR0 measures: an image measured without it publishes an RTMR0 no mdma node has, and the KMS refuses every node’s key request. The staging probe, which the post-release check uses, boots the same shape. - Collect TEE info and a TDX quote from the node’s admin API, then verify the quote externally against Intel Trust Authority.
- On
debuganddebug-read-only, check the node ships its logs: it boots withlogs-endpointandobservability-tokenpointed at a throwaway receiver VM, and the job fails unless the receiver sees a push carrying that token (the log-auth check). Neither key is measured.locked-read-onlyskips it: it ships logs only over https, and the receiver is plain http. The debug profiles share its vector role, config and token chain. - Compare the quote’s RTMR1–3 with the computed ones; a difference fails the
release. The computed registers (
computed-rtmrs-<profile>.json) and the build evidence go out with the attestation artifacts. - Emit
mrtd-<profile>.json(MRTD + RTMR0–3), a provenance record, and the attestation artifacts.
- Build the image with mkosi (
-
cleanup_attestation_resources— tear down the ephemeral VMs and firewall rules, the log-auth receivers among them. -
publish_mrtds— assemblepublished-mrtds.json(per-profile measurements) withassemble-published-mrtd-payload.sh, generate the SBOM, cosign-sign the assets (.sig+.pem+.bundle.json), and publish the GitHub releasemero-tee-v<version>.
Dispatching from a branch builds and measures, and publishes nothing.
Publishing runs only on master. Off master, images are named
…-<version>-d<run number>-<attempt> in a separate merotee-ubuntu-questing-<profile>-dev
family (release-image-name.sh). Such a build never replaces an existing image,
and the image is deleted at the end of the run.
mdma’s dispatcher resolves images by exact name and exact family, so it never
boots a branch image. Before this, a branch dispatch at the current
imageVersion replaced the released image of the same name.
Branch builds — node-image-mkosi.yaml
Section titled “Branch builds — node-image-mkosi.yaml”Manual only (workflow_dispatch), and it publishes nothing. It builds one
image (role: node, kms or agent) for one profile from the dispatched commit,
as the releases do, boots it on TDX and runs that role’s probe:
- node: the staging probe.
- kms: a two-replica cluster, with the node allowlist of a published node
release (
node_release_tag) baked in, verified and extracted as the release does. It checks that both replicas come to hold the root and report the same transport key, and that Intel Trust Authority verifies both quotes with identical measurements. - agent: Intel Trust Authority, then a throwaway secret provisioned with a
throwaway owner key. The agent is the stub, or with
agent: mero-botthe mero-bot releaseversions.jsonpins (needs theMERO_BOT_RELEASE_TOKENsecret).
It then compares the quote’s RTMR1–3 with the values computed from the build.
With log_auth_check on a debug profile, it also runs the releases’
log-auth check for any
role; it is the way to try that check on a KMS or agent image from a branch.
Its image carries a suffix and its own family, so it can never be mistaken for
a release, and it is deleted afterwards unless keep_image is set. A KMS that
serves an agent is built only by the release. See
Build the images with mkosi.
Node-image release assets
Section titled “Node-image release assets”Uploaded to mero-tee-v<version> (each with .sig, .pem and .bundle.json
sidecars; releases before the bundles were added carry only .sig and .pem).
The tag points at the commit the run built the image from, the same commit
release-provenance.json records as commit_sha. Releases up to 2.3.78 were
tagged at the default branch’s tip when the upload ran instead, which is a later
commit if master moved during the build (mero-tee-v2.3.78 is on 8612ba4, its
image was built from 08f6344); trust commit_sha for those.
published-mrtds.json— expected MRTD/RTMR0–3 for each profile. A namespace whose TEE policy trusts signed releases has its admitters download this file and its.bundle.jsonand verify the signature against theRelease mero-teeworkflow identity before comparing a joining TEE’s quote with it.release-provenance.jsonnode-image-gcp-release-sbom.spdx.jsonnode-image-gcp-checksums.txt
Compatibility catalog
Section titled “Compatibility catalog”compatibility-catalog.json is the source of truth for which KMS and node-image
versions pair. It is rebuilt by update-compatibility-catalog.yaml on every
release: published event (and on demand), by enumerating published
mero-kms-v* tags and pairing each with the identically-versioned node tag:
{ "schema_version": 1, "releases": [ { "version": "2.3.75", "kms_tag": "mero-kms-v2.3.75", "node_image_tag": "mero-tee-v2.3.75", "kms_policy_url": "https://github.com/calimero-network/mero-tee/releases/download/mero-kms-v2.3.75/kms-attestation-policy.json", "node_policy_url": "https://github.com/calimero-network/mero-tee/releases/download/mero-tee-v2.3.75/published-mrtds.json" } ]}Which token writes releases
Section titled “Which token writes releases”Every step that creates or edits a GitHub release uses
secrets.RELEASE_TOKEN || secrets.GHCR_PUSH_TOKEN || secrets.GITHUB_TOKEN:
the asset uploads, Enforce release notes body update in Release mero-kms,
and Upsert umbrella version release links in both release workflows. Steps
that only read releases (smoke tests, the auditor, the catalog) keep the
Actions github.token.
The umbrella steps used github.token until 2.3.75. From 2.3.74 on that token
was refused (HTTP 403: Resource not accessible by integration on
POST /releases) while the uploads, on the release token, still worked, so the
component releases (mero-tee-v<ver>, mero-kms-v<ver>) were published but
the run went red and the umbrella <ver> index release was never created. The
component releases are what merod and MDMA read; the umbrella is an index.
A re-run replays the workflow as it was at its commit, so it does not pick up this fix. For a release cut before it (2.3.74, 2.3.75), create the umbrella once by hand, with a token that can write releases:
GH_TOKEN=<release token> GITHUB_REPOSITORY=calimero-network/mero-tee \ VERSION=2.3.75 TARGET_COMMIT=<release commit> \ bash scripts/release/node-image-gcp/upsert-umbrella-release-links.shGuards and audits
Section titled “Guards and audits”| Workflow | Trigger | What it enforces |
|---|---|---|
release-version-sync-guard.yaml |
PR / push to master | KMS Cargo.toml, versions.json, and Cargo.lock versions agree. |
docs-update-guard.yaml |
PR | Changes under .github/workflows/, scripts/, or mero-tee/** ship with a docs/** / README.md / CHANGELOG.md update. |
ci-kms.yaml |
PR / push (KMS paths) | cargo fmt + clippy -D warnings + tests on mero-kms. |
security-audit.yaml |
Weekly (Mon 04:00) / PR | cargo audit against the advisory database. |
release-auditor.yaml |
Weekly (Mon 03:30) / dispatch | Re-verifies recent releases’ assets and cosign signatures. |
post-release-mero-tee-node-e2e.yaml |
workflow_run after a successful Release mero-tee run on master of this repository (push or dispatch) |
Boots fresh TDX VMs and checks their measurement candidates are covered by published-mrtds.json — without waiting on the KMS. |
When a post-release probe fails
Section titled “When a post-release probe fails”The post-release workflow runs after publication, triggered by
workflow_run. A workflow_run result is attached to nothing a release
consumer reads — there is no pull request, it does not appear on the release
page, and it sets no commit status on the tag. Left alone, a failure against a
published artifact is one red row in the Actions tab.
So it ends with a report_failure job that opens a GitHub issue
naming the release, the failing job, and the run
(scripts/ci/report_post_release_failure.py). Repeat failures for the same
release comment on the existing issue instead of opening another, matched on a
marker in the issue body rather than on its title.
A replica without its root (policy_not_ready)
Section titled “A replica without its root (policy_not_ready)”A KMS replica that has not yet generated (bootstrap) or joined its cluster’s
root answers /get-key with 503 policy_not_ready; /attest and /health
still answer, and /health reports clusterRootReady: false.
Next steps
Section titled “Next steps”- Configuration reference — the versions and env vars this pipeline consumes.
- Runbooks — deploying and probing a published release.
- Policy management — how the attestation policy governs key release.