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. -
build-binary— build themero-kmsbinary (x86_64) from the release commit. -
kms-image(matrix over the three profiles) — build the KMS image. Packer withimage_role = "kms"runsmero-tee/playbook-kms.yml: the binary, the profile’s node allowlist frompublished-mrtds.jsonbaked into/etc/mero-kms/kms.env, the same lockdown as the node image, and the dm-verity seal as the last step. The allowlist is part of the image and so of its measurements. The image ismerotee-kms-<profile>-<version>in familymerotee-kms-<profile>, 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 the KMS TDX image probe). 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. An image that is not proven this way is deleted.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>.
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).
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:- Run
build-and-release.sh <profile>(Packer + Ansible) to build the GCP image. Its last step seals the root behind dm-verity, and it checks the files the image must carry twice: on the build host before the seal, and inside the sealed EROFS image after it (seal-root.sh --require). Today that is GCE’s disk-naming rule andgoogle_nvme_id, which name the data disk/dev/disk/by-id/google-data; a missing one fails the build. - 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.
- Emit
mrtd-<profile>.json(MRTD + RTMR0–3), a provenance record, and the attestation artifacts.
- Run
-
cleanup_attestation_resources— tear down the ephemeral VM and firewall rule. -
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 (image_suffix in mero-tee/ubuntu.pkr.hcl). Packer never replaces an
existing image for such a build, 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.
KMS TDX image probe — kms-tdx-image-probe.yaml
Section titled “KMS TDX image probe — kms-tdx-image-probe.yaml”Manual only (workflow_dispatch), and it publishes nothing. It builds the
mero-kms TDX cluster image (design;
image_role = "kms", playbook-kms.yml) from the dispatched commit, with the
node allowlist of a published node release (node_release_tag) baked in. It
then runs a two-replica cluster on TDX VMs: one started with
kms-bootstrap=true, the other with kms-peers naming the first. It checks
that both come to hold the root and report the same transport key (derived
from the root, so equal keys mean one root), and that Intel Trust Authority
verifies both quotes with identical measurements; a joiner that measures
differently is replaced by a new VM, up to three times, as described above. It deletes its image, VMs and
firewall rule when done. Run it to prove a change to the image before the
Release mero-kms workflow builds and measures it for real. Like branch node
builds, its image carries a suffix and its own family, so it can never be
mistaken for a release.
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.