tmp-router: reproducible build pipeline for TEE attestation#393
tmp-router: reproducible build pipeline for TEE attestation#393ohalushchak-exadel wants to merge 6 commits into
Conversation
Sets up the build side of the TEE attestation chain whose wire shape is proposed in adcontextprotocol/adcp#5770. A TEE attestation document carries a measurement of the running workload; a verifier compares that measurement against an allowlist of expected values. The allowlist is only as trustworthy as the procedure that produces it — anyone allowlisting a digest needs to be able to rebuild the same source and recompute it. This change makes that property hold for cmd/router. Changes: - cmd/router/Dockerfile: base images pinned by multi-arch index digest (renovate- annotated); Go build adds -buildvcs=false -ldflags="-buildid=" to strip path, VCS, and build-id entropy from the binary; SOURCE_DATE_EPOCH wired through as a build-arg so BuildKit can normalize layer mtimes. - scripts/build-tmp-router.sh: local reproducible-build script. Derives SOURCE_DATE_EPOCH from the last commit that touched a build input, calls docker buildx with the same flags CI uses, prints the OCI image digest, and optionally emits a tmp-router-measurements/v1 JSON manifest. Tested back-to-back on the same source — both runs produce identical image digests. - .github/workflows/tmp-router-image.yml: parallel to context-agent-image.yml. Builds linux/amd64 + linux/arm64, pushes to ghcr.io/<owner>/<repo>/tmp-router, signs with cosign keyless OIDC, writes the measurements manifest as a workflow artifact, and on tag pushes attaches it to the published image as a cosign attestation so verifiers can discover it from the registry rather than from the workflow run. - docs/tmp-router-reproducible-build.md: operator/auditor verification procedure, what is and is not pinned, the OCI-digest → per-platform-measurement mapping (direct equality for GCP Confidential Space; deterministic derivation via the platform tool for Nitro / TDX / SEV-SNP — out of scope here), and what to do on a reproducibility divergence. Scope is the OCI image digest. EIF/MRTD/SNP_MEASUREMENT derivation requires platform tooling GitHub-hosted runners don't have, and lands separately on a controlled host per release.
There was a problem hiding this comment.
Request changes. The supply-chain plumbing is sound, but the reproducibility property this PR exists to deliver is not verifiable as built, and CI will fail on a routine trigger.
Crediting the good first: all eight third-party actions are pinned by full 40-char SHA, cosign is version-pinned (v3.0.6), permissions: is minimal (contents: read / packages: write / id-token: write), and every privileged step — GHCR login, push, cosign install, sign — gates on github.event_name != 'pull_request'. cosign signs ${tag}@${DIGEST} (content digest, not a mutable tag). No fork-PR OIDC/push exfiltration path. The workflow is a faithful parallel of context-agent-image.yml. Right shape on the security mechanics.
MUST FIX (blocking)
-
The documented verification loop can never succeed —
security-reviewer: High.docs/tmp-router-reproducible-build.md:30-42tells an auditor to runscripts/build-tmp-router.sh --platform linux/amd64(single platform,--provenance=false --sbom=false, script L83-91 → a single-platform image-manifest digest) and compare it againstimagetools inspect ... {{.Manifest.Digest}}of the published tag. But CI publishes a multi-arch index built withplatforms: linux/amd64,linux/arm64+provenance: mode=max+sbom: true(.github/workflows/tmp-router-image.yml:117-127);steps.build.outputs.digest(L140) is the index digest, which references per-platform manifests plus non-reproducible provenance/SBOM attestation manifests (mode=maxprovenance embeds build timestamps and runner identity). A single-platform manifest digest and a multi-arch index-with-attestations digest are structurally different objects — they cannot be byte-identical, not "might differ," but never match by construction. Per the doc's own instruction (L42 + "Reporting reproducibility failures"), an honest auditor must then treat every verification as a build-integrity incident. The in-line comment at L117-119 ("they do NOT change the image digest itself") is wrong for index digests: attestations are added as manifests in the index, which changes the index digest. Net: the reproducibility half of the "signature + reproducibility prevents a backdoored binary" claim is inert — operators either drown the real signal in guaranteed false alarms or allowlist a digest they never reproduced. Fix: compare per-platform child manifest digests on both sides, and record a per-platform digest map in the measurements manifest rather than the index digest. -
CI breaks on routine pushes to main —
code-reviewer: Blocker..github/workflows/tmp-router-image.yml:60-66computesSDE="$(git log -1 --format=%ct -- <build-input paths>)"with no|| echo 0fallback (the local script has one atscripts/build-tmp-router.sh:67). Theon.pushtrigger (L21-23) has nopaths:filter, so every push to main runs this job — including pushes touching none of those paths.git log -1 -- <paths>then prints empty,SDE=""propagates, and the manifest step's($source_date_epoch | tonumber)(L160) raisesCannot parse '' as numberunderset -euo pipefailand fails the workflow. Amplified byactions/checkout@v4defaulting tofetch-depth: 1: with only the tip commit present,git log -1 -- <paths>returns empty for any commit that didn't itself touch a build input. Fix: add the|| echo 0fallback and setfetch-depth: 0— which also fixes (3). -
Shallow clone silently diverges CI from local SDE —
code-reviewer: Blocker. Even when (2) doesn't crash, afetch-depth: 1CI checkout walks truncated history, sogit log -1 -- <paths>resolves a different last-touching-commit timestamp than an auditor's fullgit clone. DifferentSOURCE_DATE_EPOCH→ different layer mtimes → different digest. The doc says (L42) any divergence means "do not allowlist."fetch-depth: 0on the checkout is required for the local and CI SDE to agree. -
GCP "direct equality" row is factually wrong —
ad-tech-protocol-expert: Severity-1. The mapping table (docs/tmp-router-reproducible-build.md:65) and the manifestreproducibility.notereferencesubmods.confidential_space.image_digest. In the Confidential Space attestation token, image identity lives undersubmods.container.image_digest, notsubmods.confidential_space.*(that submodule carries support/monitoring attributes). A verifier's policy-as-code keyed on the documented path won't resolve. Compounding it: Confidential Space binds the single platform image it actually runs, while the manifest publishes the multi-arch index digest asimage_digest— so "Direct equality" fails even for a correctly-running workload. This is the one mapping the PR presents as trivially solved, and it's the one most likely wrong in practice. (ad-tech-protocol-expertflagged this from domain knowledge — web access was gated in its environment, so confirm the exact claim path against the GCP Confidential Space token-claims reference before re-merge. The index-vs-per-platform digest-level problem is independently confirmed by all three reviewers and holds regardless.)
This is also the one test-plan box you left for the reviewer — "confirms the OCI-digest → per-platform-measurement mapping is the right factoring" — and it's the one that didn't survive review.
Follow-ups (non-blocking — fix alongside the above or file as issues)
- Schema
v1denotes two shapes —ad-tech-protocol-expert: Severity-2. Both emitters stamp"schema": "tmp-router-measurements/v1"but disagree: CI emitsimage,platforms(array),build{},source.ref,reproducibility{note}; the local script (scripts/build-tmp-router.sh:105-124) emitsplatform(singular), noimage/build/platforms, and a top-levelnote. A versioned identifier the wire spec (adcp#5770) will parse must denote one shape. Make both emit a shared required core with CI-only fields optional, or give the local one a distinct id. cosign attest --type customhas no stable predicateType. If #5770 wants registry-side measurement discovery, define a named predicateType so discovery is deterministic rather than "the one custom attestation on the image."- Nitro PCR0 determinism is slightly overstated. Doc says "same OCI image + same Nitro CLI version ⇒ same PCR0"; the EIF measurement also folds in the linuxkit kernel/init blobs. Add a word so operators don't allowlist a PCR0 they can't reproduce after a
nitro-clibase bump. source.dirtyis structurally alwaysfalsein CI (tmp-router-image.yml:144— a fresh checkout is never dirty). Reads as an integrity signal but measures nothing in CI. Drop it from the CI manifest or label it local-only.
Minor nits (non-blocking)
date -u -ris GNU/BSD-divergent.scripts/build-tmp-router.sh:71—date -u -r <epoch>means "reference file mtime" on GNU coreutils and "seconds since epoch" on BSD/macOS; the|| echo unknownmasks it, so a macOS auditor seesunknownfor a valid epoch. Display-only.
Reproducibility is the whole game here, and the published-vs-local digest contract has to actually close before any operator is told to allowlist these measurements. Fix 1–4, reconcile the digest level across workflow/manifest/doc, and I'll re-review.
…tory, GCP claim path Addresses the 4 blockers from PR #393 review: 1. Verification loop now closes. CI was publishing the multi-arch INDEX digest as `image_digest`, which references provenance/SBOM attestation manifests alongside the per-platform image manifests — that index digest can never byte-match a single-platform local rebuild. Replaced with per-platform image-manifest digests in a `platform_digests` map (resolved via `docker buildx imagetools inspect --raw` after push), with `index_digest` carried for traceability only. The local script's manifest schema now matches: it emits `platform_digests` with the one platform it built. The doc verification flow is rewritten to compare per-platform digests with an `imagetools inspect --raw | jq` pipeline. 2. SDE computation no longer crashes. `git log -1 --format=%ct -- <paths>` on a push that didn't touch a build input returned empty, then `($source_date_epoch | tonumber)` failed under `set -euo pipefail`. Added `|| echo 0` fallback plus an explicit empty-string guard. 3. Shallow-clone SDE drift fixed. `actions/checkout` now uses `fetch-depth: 0` so `git log -1 -- <paths>` resolves the same last-touching-commit timestamp CI and local both see. With the previous default `fetch-depth: 1`, CI's truncated history could resolve a different SDE than an auditor's full clone, producing a different image digest and a "do not allowlist" verdict for an otherwise-good build. 4. GCP Confidential Space claim path corrected: `submods.confidential_space.image_digest` → `submods.container.image_digest` in both the doc table and the CI manifest's reproducibility note. `confidential_space` carries platform/support attributes; workload-image identity lives under `container`. Confidential Space binds the single platform image it actually runs — which is exactly what `platform_digests` gives us once #1 is fixed. Plus the schema-reconciliation follow-up: both emitters now stamp `tmp-router-measurements/v1` against the same shape (`platform_digests` map, optional `index_digest`, optional `source.dirty`). Plus the Nitro PCR0 wording: noted that PCR0 also folds in linuxkit kernel/init blobs shipped with the Nitro CLI release, so allowlisting a specific PCR0 requires pinning the Nitro CLI version, not just the OCI image. Verified locally: two consecutive `scripts/build-tmp-router.sh` runs on this branch produce byte-identical per-platform digests. The new measurements manifest now publishes the value an auditor actually compares.
…PR builds
The previous `select(. != "") // {}` chain halted the jq stream when both
amd64 and arm64 inputs were empty (the PR-build case where the per-platform
resolution step is gated off), producing no output and crashing the
manifest step under `set -euo pipefail`. Replaced with explicit if/then,
which produces `platform_digests: {}` on PR and the populated map on push.
Verified with jq locally against both the empty and populated cases.
There was a problem hiding this comment.
Request changes. The one property this PR exists to establish — a local rebuild produces the same per-platform image-manifest digest CI publishes — does not hold yet, and the doc tells auditors to treat a mismatch as a build-integrity incident. Fail-closed beats fail-open: shipping a verification procedure that fails on its own happy path is worse than not shipping it. The build plumbing is otherwise sound and the supply-chain posture is clean.
Reviewed with code-reviewer (build/script correctness) and security-reviewer (attestation trust boundary). The block comes from code-reviewer; security-reviewer returned no High.
Things I checked
- Supply-chain posture is clean (
security-reviewer, confirmed): trigger ispull_request, notpull_request_target; all 8 third-party actions are SHA-pinned (tmp-router-image.yml:65,92,95,99,107,124,217,225); every secret/state-mutating step is gatedif: github.event_name != 'pull_request'(login:98, push:129, resolve:153, cosign:224, sign:237, attest:256). Fork PRs get a read-only token and no OIDC regardless of the declaredpackages:write/id-token:write. No${{ }}injection intorun:blocks — interpolated values are structural (github.sha,steps.*.outputs.*) and the hot ones go throughenv:. - The index-vs-per-platform distinction is right. CI binds
platform_digests(tmp-router-image.yml:144-169), not the index digest, which legitimately moves withprovenance: mode=max/sbom: true. The local script disables both (build-tmp-router.sh:85-86) and emits the same per-platform shape. The jq selectors at:159-160and the map builder at:195-198are correct —(.annotations["vnd.docker.reference.type"] | not)properly excludes the attestation manifests, and the+-merge yields the right map. - SDE parity holds. The
git log -1 --format=%ct -- <paths>path list is character-identical between CI (tmp-router-image.yml:84-86) and local (build-tmp-router.sh:57-59),fetch-depth: 0is set, and both fall back to0on empty. - Go build flags are the standard reproducible recipe (
cmd/router/Dockerfile:32-37):CGO_ENABLED=0,-trimpath,-buildvcs=false,-ldflags=\"-s -w -buildid=\". Base images pinned by index digest.
Blocking
-
OCI labels asymmetry — the local digest will not match the CI digest.
tmp-router-image.yml:117-120passes threeorg.opencontainers.image.*labels (plus the dynamic onesdocker/metadata-actioninjects) intobuild-push-action, which writes them into the per-platform image config.build-tmp-router.sh:81-89sets none. The image config is part of the per-platform image-manifest digest, so an auditor runningscripts/build-tmp-router.sh --platform linux/amd64perdocs/tmp-router-reproducible-build.md:36gets a digest that does not equal the publishedplatform_digests[\"linux/amd64\"]— anddocs/tmp-router-reproducible-build.md:52tells them to treat that as a build-pipeline integrity incident. The headline path is broken on the first try. Fix: move all labels into the Dockerfile asLABELinstructions so both emitters bake identical config, or pass the same--labelargs in the local script. (code-reviewer: Blocker.) -
Image-config
createdtimestamp is not normalized. Neither path setsrewrite-timestamp=trueon the image output (tmp-router-image.yml:122-142;build-tmp-router.sh:78).SOURCE_DATE_EPOCHclamps layer mtimes but does not reliably rewrite the configcreatedfield across BuildKit versions — so two builds at different wall-clock times can produce different config bytes and a different per-platform manifest digest, intermittently. Addrewrite-timestamp=trueto the image output on both paths and assert.created == SDEviaimagetools inspect --raw. (code-reviewer: Blocker.)
Both reduce to the same gap: the local==CI equality is asserted but neither validated nor yet achievable. The test plan reflects this — [x] Two consecutive local runs produce byte-identical image digests proves local==local, not the claim that matters. There is no checkbox for "local rebuild matches the CI-published per-platform digest," and [ ] CI workflow runs successfully on this PR is unchecked. The primary user-facing path ships unvalidated.
Follow-ups (non-blocking — file as issues)
- Cosign verification identity is too permissive (
security-reviewerM1).--certificate-identity-regexp='...tmp-router-image\.yml@.*'(tmp-router-image.yml:235,docs:91) matches a signature from any ref — including aworkflow_dispatchrun on an unreviewed feature branch withpackages:write/id-token:write. For a TEE allowlist, only tag-ref signatures should be trusted (they're also the only ones carrying thecosign attestmanifest). Anchor and constrain:'^https://github\.com/adcontextprotocol/adcp-go/\.github/workflows/tmp-router-image\.yml@refs/tags/tmp-router-v.*$'. Update both the workflow comments and the doc. - GCP Confidential Space binding is under-specified (
security-reviewerM2).docs:103presentssubmods.container.image_digestequality as the complete binding rule. The digest comparison is sound and not a substitution vector, but an operator implementing exactly that accepts a debug-mode CS instance of the correct image. The verifier must additionally assertsubmods.confidential_space.support_attributescontainsSTABLEand pin the expectedaud/iss. State this in the GCP row. - BuildKit/buildx version is unpinned (
code-reviewerHigh).setup-buildx-action(:95) leaves the BuildKit daemon free, and the per-platform manifest digest is sensitive to BuildKit version (compression defaults, media types, annotation ordering). "Docker 24+" (docs:39) is too loose for byte reproducibility. Pin the BuildKit image in CI and record the builder version in the measurements manifest so a benign version skew is distinguishable from a real divergence. - No
GOTOOLCHAIN=local(code-reviewerHigh).cmd/router/Dockerfile:32runsgo buildwith defaultGOTOOLCHAIN=auto. Fine today (go.mod< base image), but a futurego.modtoolchain bump above the base image silently downloads a different toolchain over the network — contradictingdocs:114's "nothing reaches out to the network at build time." AddENV GOTOOLCHAIN=localso a skew fails loudly.
Minor nits (non-blocking)
date -u -r <epoch>is broken on GNU/Linux.build-tmp-router.sh:64—-rmeans "reference file" on GNU coreutils, so the human-readable SDE prints(unknown)for nearly every auditor (masked by|| echo unknown). Cosmetic; SDE itself is correct. Usedate -u -d \"@$SOURCE_DATE_EPOCH\".cosign-installerSHA carries a# v4.1.1comment while installingcosign-release: v3.0.6(tmp-router-image.yml:225,228). Harmless (the SHA executes), but confirm the SHA↔tag mapping so the next Renovate bump isn't a trap.SOURCE_DATE_EPOCH=0fallback is a silent correctness cliff. Consider failing hard rather than building at epoch 0 if derivation yields empty (build-tmp-router.sh:56-60).
Fix the two blockers — the label and timestamp normalization — and the reproducibility claim becomes true rather than aspirational. The rest is guidance hardening that can land as follow-ups.
|
The automated review encountered an issue (possibly reached max turns, timed out, or failed to post the final This is an automated message from the Argus AI review workflow. |
There was a problem hiding this comment.
Solid second pass — the four blockers from the prior round are genuinely closed, and the per-platform-vs-index-digest factoring is the right instinct. Commenting rather than approving: the trust chain this PR exists to establish has two gaps that land squarely on the open design question you flagged in the test plan, plus a guaranteed-wrong line on Linux. None block on the repo's MUST-FIX bar (no Go/schema/wire change, no weakening of existing TMP verification), but the PR's whole reason for being is "the allowlist is only as trustworthy as the procedure," so the procedure has to close.
Things I checked
- Workflow permissions are exactly minimal —
contents:read,packages:write,id-token:write, no job widens them (.github/workflows/tmp-router-image.yml:50-53). - All 8 third-party actions pinned by full 40-char SHA. Fork PRs cannot push, sign, or attest —
push, GHCR login,cosign sign, and the platform-digest resolve are all gatedgithub.event_name != 'pull_request'(lines 98, 129, 153, 224, 237). fetch-depth: 0is present and load-bearing forSOURCE_DATE_EPOCHagreement with a full local clone (lines 64-72); the SDE git-path list is byte-identical between CI (:84-86) andscripts/build-tmp-router.sh:57-59.- The "provenance/SBOM change only the index digest, not the per-platform manifest digests" claim is correct, and the
(.annotations["vnd.docker.reference.type"] | not)jq filter at:159-160correctly excludes the attestation manifests. - Dockerfile reproducibility flag set is right:
CGO_ENABLED=0 -trimpath -buildvcs=false -ldflags="-s -w -buildid=", base images pinned by index digest (cmd/router/Dockerfile:15,17,32-37). COPY layering is sane. - Cross-emitter
tmp-router-measurements/v1schema parity holds: CI addsindex_digest/image/build, local addssource.dirty, both shareschema+platform_digestsmap +source.{revision,revision_short,date_epoch}. Doc:82-84documents the optional fields.
What would flip me to approve
1. The signature covers the index digest; every verifier in your own docs allowlists the per-platform digest — and the procedure never closes that gap. (security-reviewer, High.) cosign sign runs against steps.build.outputs.digest, the multi-arch index digest (:240, :245). But platform_digests (:195-198), the doc table (docs/tmp-router-reproducible-build.md:103), and the GCP submods.container.image_digest comparison all bind to the per-platform image-manifest digest. The chain is actually completable — the signed index references those per-platform digests in .manifests[].digest — but the verification flow at docs/...:43-52 extracts the per-platform digest via imagetools inspect | jq without first cosign verify-ing the index it pulled it from, and :54 then calls platform_digests "the authoritative shortcut." As written, an auditor trusts a per-platform digest with no signature check on the object that vouches for it. Fix is either: sign the per-platform digests directly, or make the doc procedure verify the index signature first and confirm the allowlisted digest appears in the verified index. Right now the doc is the product, and the procedure skips the step that makes the signature load-bearing.
2. Nothing in CI proves the published per-platform digest reproduces before it's signed and attested. (security-reviewer, High, + your own unchecked [ ] CI workflow runs successfully.) The reproducibility check is entirely manual and after-the-fact. With cache-from/cache-to: type=gha on the publishing path (:141-142) — and GHA cache is not a trust boundary — a poisoned go build layer lands in a "reproducible" image that CI then signs and attests as authoritative, and :54 tells operators the signed manifest is the shortcut that lets them skip the rebuild. Either add a CI step that runs scripts/build-tmp-router.sh --platform linux/amd64 in a clean context and fails the build on digest divergence, or drop GHA cache on the tag/release path and state plainly in the manifest note + docs that the signed manifest is not a substitute for an independent rebuild. This is the loop the PR claims to close ("you can never tell whether a divergent measurement is a build bug or a backdoor", docs/...:11); right now CI cannot tell either.
3. date -u -r is BSD-only and fails on every Linux host, including the CI runner. (code-reviewer.) scripts/build-tmp-router.sh:64 — GNU date reads -r as "reference file mtime," so date -u -r 1700000000 errors; the 2>/dev/null || echo unknown swallows it and every Linux operator sees SOURCE_DATE_EPOCH = <ts> (unknown). Cosmetic — the SDE passed to the build is correct — but it's wrong on the primary target and the fix is one token: date -u -d "@$SOURCE_DATE_EPOCH".
Follow-ups (non-blocking — fold in or file)
- Tighten the cosign cert-identity regexp to the release refs.
:235/docs/...:91end intmp-router-image\.yml@.*, which matches a build signed from any ref of this repo, not justmain/tmp-router-v*. Pin the trusted ref. (security-reviewer, Medium.) cosign attestonly fires on tag pushes, butedge/main-<sha>tags are signed with no registry-discoverable manifest (:248, :256vs:111-112). An operator pinningedgegets a 90-day-expiring artifact, not an attestation. Either attest all pushed builds or document that onlytmp-router-v*is supportable for TEE allowlisting. (security-reviewer, Medium.)--type customgives the predicate no namespacedpredicateType(:264) —verify-attestation --type custommatches any custom predicate on the image. A stable type URI (e.g.https://adcontextprotocol.org/tmp-router-measurements/v1) binds verification to the schema. (security-reviewer, Low.)- Cross-environment reproducibility is unvalidated. Test plan has
[x]two consecutive same-machine runs — that's warm-cache, same-host. The property TEE allowlisting needs is different machine → same digest. That's what[ ] CI workflow runs successfully on this PRwould start to prove; until it's green, the headline claim ships unverified against the path it targets.
Minor nits (non-blocking)
- Dead
|| echo 0fallback + misleading comment.git log -1 --format=%ct -- <paths>exits 0 with empty output when nothing matches;|| echo 0only fires if git itself fails, not the "history truncated" case the comment at:79-81describes. The-zguard is what actually catches empty. Behavior is correct; the comment isn't. - PR-build steps run with empty digests.
Write measurements manifest/Upload measurements manifest(:171-221) aren't gated on!= 'pull_request', so a PR build uploads a near-empty manifest artifact namedtmp-router-measurements-(empty digest suffix,:219). Harmless, but gate them for cleanliness. - The PR body still says Draft — GitHub says otherwise (
isDraft: false). Worth reconciling so the next reviewer knows whether you want merge or eyes.
Substance is good and the direction is right. Close #1 and #2 (the procedure/CI gaps that make the signature and the manifest load-bearing) and fix the one-line date bug, and this is a clean approve.
— Argus
Addresses the 3 blockers from the second review pass: 1. Signature ↔ verification gap. cosign signs the multi-arch INDEX digest, but TEE verifiers and the docs allowlist per-platform image-manifest digests; the previous verification flow extracted per-platform digests from the registry without verifying the cosign signature first, so trust hung on an unverified registry response. New verification flow in docs/tmp-router-reproducible-build.md runs `cosign verify` first, captures the verified index digest from `critical.image.docker-manifest-digest`, and only then walks `imagetools inspect --raw | jq` over the verified index to extract per-platform digests. The doc's "platform_digests is the authoritative shortcut" claim is removed — the attestation proves the workflow produced those values, not that an independent build reproduces them. 2. CI was signing un-reproduced images. The publish path uses `cache-from/cache-to: type=gha,scope=tmp-router` for fast PR iteration, but GHA cache is not a trust boundary — a poisoned cached layer would land in a "reproducible" image that CI then signed as authoritative. New `Verify reproducibility (no-cache clean rebuild)` step runs `scripts/build-tmp-router.sh --platform linux/amd64` (which does not consult the GHA cache) AFTER the publish-path build and BEFORE cosign sign, and fails the workflow when the per-platform digests diverge. CI now refuses to publish a signed digest it cannot reproduce. 3. `date -u -r <epoch>` is BSD-only. On GNU coreutils (every Linux host, including the CI runner) `-r` means "reference file mtime," so the display crashed and `|| echo unknown` masked it. Replaced with a portable `format_epoch` helper that tries GNU `date -d "@<epoch>"` first, falls back to BSD `date -r <epoch>`. Plus the security follow-ups: - Tightened the cosign cert-identity regexp in all doc + comment examples from `tmp-router-image\.yml@.*` to `tmp-router-image\.yml@refs/(heads/main|tags/tmp-router-v.*)`. A fork or feature-branch build's OIDC token carries a different ref and would no longer match. - Attestation now fires on every push (was: tag pushes only). Operators pinning `edge` or `main-<sha>` also get a registry-discoverable measurements manifest. - Replaced `cosign attest --type custom` with the stable predicate-type URI `https://adcontextprotocol.org/tmp-router-measurements/v1` so `verify-attestation --type ...` binds to this exact schema rather than matching any custom predicate on the image. Plus nits: - Fixed the misleading SDE comment: `|| echo 0` is defensive against `git log` itself failing; the `-z` guard is what catches the empty- output case (git returns 0 with empty output when the path filter matches no commits). - Gated `Write measurements manifest` and `Upload measurements manifest` on `github.event_name != 'pull_request'` so PR builds no longer upload an empty-suffix workflow artifact. Reproducibility check: two consecutive `scripts/build-tmp-router.sh` runs on this branch still produce byte-identical per-platform digests.
There was a problem hiding this comment.
Request changes. The build pipeline is sound and well-iterated; the measurements doc makes TEE-mapping claims an operator would act on that don't hold for two of four formats — and that's the box this PR's own test plan left unchecked.
Things I checked
- Dockerfile COPY list is complete.
cmd/routeris its own module (replace => ../../);router/tmproto/targetingonly importtargeting/*,tmproto,urlcanon— all COPY'd. Noadcp/oridentityagent/in the build graph, sogo buildresolves. No build-break. - Reproducibility gate fails closed.
Verify reproducibility (no-cache clean rebuild)(tmp-router-image.yml:173-199) runs beforecosign sign(262) andattest(286); noif: always(), mismatch isexit 1underset -euo pipefail. CI refuses to sign a digest it can't reproduce. (security-reviewer: PASS) - Fork PRs can't sign or push. Trigger is
pull_request, notpull_request_target; push gated!= 'pull_request', every login/sign/attest step gated; GitHub deniesid-tokento fork PRs regardless. (security-reviewer: PASS) - GHA cache can't poison a signed image — a poisoned layer diverges from the no-cache rebuild and fails the gate before signing. (PASS)
- Docs verification order (
docs/tmp-router-reproducible-build.md:39-70): cosign verify first, capture the verified index digest, then inspect by that digest — content-addressed, no TOCTOU. (PASS) - All actions SHA-pinned;
GITHUB_TOKENused only as the GHCR login password, never echoed. - stdout/stderr split in the compare (
yml:189): the script prints the digest to stdout only (build-tmp-router.sh:107), human output to stderr;2>... | tail -n1captures exactly the digest. Correct.
MUST FIX
-
TEE measurement mapping is unsound for Intel TDX and AMD SEV-SNP (
docs/tmp-router-reproducible-build.md:127-128; manifest notetmp-router-image.yml:241).ad-tech-protocol-expert: unsound, cited divergence. The table treats all four formats as one "image-plus-host-chain → measurement" model. That's only true for Nitro PCR0, which genuinely measures the EIF (kernel+init+app). Intel TDX MRTD measures the TD's initial guest memory (TDVF/firmware + initial pages); the rootfs/app lands in RTMRs, not MRTD. AMD SEV-SNP SNP_MEASUREMENT is the launch digest of initial guest memory (OVMF + boot pages); app identity is anchored separately (typically a dm-verity roothash). An operator who allowlists an MRTD or SNP_MEASUREMENT believing the "same model" claim pins it to the tmp-router binary is pinning only the firmware/boot chain — a weaker guarantee than the doc promises, in the doc that exists to make the allowlist trustworthy. Keep the Nitro row; rewrite the TDX/SEV-SNP rows to say the headline register measures the VM boot/firmware image and workload identity must be anchored separately (RTMR roothash for TDX; measured kernel cmdline for SEV-SNP) — explicitly not the same model as PCR0.This is the box you left unchecked:
[ ] Reviewer confirms the OCI-digest → per-platform-measurement mapping is the right factoring. Confirmed — for two of four formats it isn't. -
GCP direct-equality needs a launch-by-per-platform-digest caveat (
docs:125; manifest noteyml:241). Thesubmods.container.image_digestcorrection is right (thecontainersubmodule carries workload identity;confidential_spacecarries platform attributes). But "direct equality against thelinux/amd64platform_digestsentry" only holds if the workload is launched by its per-platform manifest digest. Launch by the multi-arch index digest and the token can carry the index digest — which the doc itself says is never what you compare. The verification procedure extracts the amd64 digest; nothing forces the deployment to run that reference. State that the workload must be launched astmp-router@<linux/amd64 digest>, not by tag or index. -
scripts/build-tmp-router.shlacks the empty-SDE floor CI added (build-tmp-router.sh:56-60vsyml:89).git log -1 --format=%ct -- <paths>exits 0 with empty stdout when the path filter matches no commit, so the|| echo 0at line 59 never fires —SOURCE_DATE_EPOCHis exported empty. CI floors exactly this case (if [ -z "$SDE" ]; then SDE=0,yml:89, added in 29afd54 with a comment spelling out why). The script — whose one job is to reproduce CI's digest — doesn't. An auditor on a shallow clone whose tip doesn't touch a build path computesSDE=""where CI used0, gets a different digest and a false "DIVERGED — do not allowlist" verdict; with--measurements-out, jq'stonumberon""hard-crashes the manifest write (build-tmp-router.sh:132). It fails closed and the documented full-clone flow avoids it — but it's a two-line parity fix and the script advertises CI parity. After line 60:if [[ -z "$SOURCE_DATE_EPOCH" ]]; then SOURCE_DATE_EPOCH=0; fi.
Follow-ups (non-blocking — file as issues)
- SDE path list is duplicated between
yml:86-88andbuild-tmp-router.sh:57-59. They must stay byte-identical or CI and a local auditor compute different digests — a false divergence the docs (lines 138-144) tell auditors to treat as a security incident. Single-source it (workflow calls the script for the SDE). - arm64 is signed/attested but not independently reproduced in CI — the gate (
yml:173-199) only rebuildslinux/amd64. Either rebuild both, or note in the manifestreproducibility.notethat only amd64 was CI-reproduced. source.dirtymisses untracked files (build-tmp-router.sh:113) —git diff --quiet HEADdoesn't see new untracked files under a COPY'd path, so a build-affecting untracked file reportsdirty: false. Usegit status --porcelain -- <paths>.
Minor nits (non-blocking)
- "Nothing reaches out to the network at build time" overstates it (
docs:136). The Go build downloads modules via GOPROXY; reproducibility rests ongo.sum, which the same paragraph correctly cites. Reword to "module downloads are checksum-pinned viago.sum" rather than implying an airgapped build.
Fix the two TEE-mapping rows (#1, #2) and the SDE floor (#3); the pipeline mechanics are right — ad-tech-protocol-expert cleared Nitro PCR0 and security-reviewer cleared the whole sign/attest trust chain. Re-request and I'll re-check the doc rows.
…oor, DRY the SDE derivation Addresses the 3 MUST FIX items from the third review pass, plus the small follow-ups the reviewer flagged as fileable-as-issues. MUST FIX 1: TDX MRTD and SEV-SNP SNP_MEASUREMENT are firmware/boot-chain measurements, not workload measurements. The prior mapping table treated all non-GCP formats as one "image-plus-host-chain → workload measurement" model, which is only true for Nitro PCR0 (measures the full EIF: kernel + init + application). On Intel TDX, MRTD measures TDVF + initial guest memory; workload identity lands in an RTMR (typically RTMR3 via IMA). On AMD SEV-SNP, SNP_MEASUREMENT is the launch digest of OVMF + boot pages; workload identity anchors via a measured dm-verity roothash. Rewritten both rows to say plainly that the headline register is not a workload measurement, and that a TDX/SEV-SNP deployment with a meaningful workload allowlist needs a companion anchor (RTMR / dm-verity roothash) — out of scope for this PR. Kept the Nitro row unchanged (it was correct). MUST FIX 2: GCP direct-equality now names the launch-reference caveat. The `submods.container.image_digest` claim only carries the per-platform digest when the workload is launched by its per-platform manifest digest (`tmp-router@<linux/amd64 digest>`). Launch by tag or by index and the token may carry the index digest — which the reproducibility procedure never treats as trusted. Added an explicit MUST on the operator's launch reference. MUST FIX 3: `scripts/build-tmp-router.sh` now floors an empty SDE to 0. Previously the script relied on `git log -1 --format=%ct -- <paths> || echo 0`, but `git log` exits 0 with empty stdout when the path filter matches no commit (only exits non-zero when git itself errors), so `|| echo 0` never fired and SOURCE_DATE_EPOCH could be exported empty. An empty value would hard-crash the build-arg pass and jq's `tonumber` in the manifest write. CI already had this floor (yml:89); the script now shares the guard via the new scripts/tmp-router-sde.sh. Plus the follow-ups the reviewer flagged as fileable-as-issues: - SDE derivation is now single-sourced in scripts/tmp-router-sde.sh, called by both the workflow and the local build script. The path list can no longer drift between CI and a local auditor; a drift would cause CI and local to compute different SDE values, produce different image digests, and make the documented reproducibility check fail against a genuinely reproducible build. - Verify-reproducibility step now rebuilds both linux/amd64 and linux/arm64 (was: amd64 only). arm64 goes through QEMU on the amd64 runner — slower but symmetric with what is actually published and signed. - source.dirty detection uses `git status --porcelain -- <build-paths>` instead of `git diff --quiet HEAD`. The old check missed new untracked files under a COPY'd path; a build-affecting untracked file would have reported dirty:false. Plus the minor nit: reworded the "nothing reaches out to the network at build time" line to describe what actually happens (module downloads via GOPROXY, checksum-pinned by go.sum) rather than implying an airgapped build. Reproducibility check: two consecutive local runs on this branch still produce byte-identical per-platform digests.
There was a problem hiding this comment.
Two real defects in the trust chain, both in the load-bearing parts. The build side is clean — the reproducibility model is right, the no-cache rebuild gate is correctly ordered before sign, actions are SHA-pinned, and the TEE measurement-mapping table is conservative and correct. But the verifier-side regexp operators copy-paste is too loose, and the advertised workflow_dispatch trigger fails. Requesting changes on those two.
MUST FIX
-
Unanchored
--certificate-identity-regexpaccepts non-mainbranch builds.docs/tmp-router-reproducible-build.md:41and:113, plus the workflow comments attmp-router-image.yml:277and:298— the exact string an auditor/TEE verifier pastes intocosign verify. Cosign matches certificate identities with Go'sregexp.MatchString, which is unanchored (substring match). No^/$. Theheads/mainalternation branch therefore matches any SAN merely containing it —...tmp-router-image.yml@refs/heads/main-attackerpasses. That defeats the "onlymainandtmp-router-v*refs are trusted" guarantee the comment at:274-275promises: an operator who runs the documented command allowlists an image built off an arbitrarymain-prefixed branch. This is the load-bearing trust anchor of the whole pipeline — it has to be exact before anyone copies it. Anchor both ends and escape the path dots in all four locations:--certificate-identity-regexp='^https://github\.com/adcontextprotocol/adcp-go/\.github/workflows/tmp-router-image\.yml@refs/(heads/main|tags/tmp-router-v[0-9].*)$'(
security-reviewer: High.) -
workflow_dispatchproduces zero tags but pushes — the advertised manual-rebuild trigger fails every run.tmp-router-image.yml:105-111: onworkflow_dispatch,edge/type=shaare gated onevent_name == 'push', the threetype=matchrules only fire on tag refs, andlatestonly ontmp-router-v*tags → the metadata step emits no tags. ButBuild & push(:124) setspush: ${{ github.event_name != 'pull_request' }}, which istruefor dispatch.docker/build-push-actionwithpush: trueand emptytagshas no image reference to push and fails the job. The header comment (:18) advertisesworkflow_dispatch → manual rebuildas supported. Either add a dispatch-enabled fallback tag or gatepushongithub.event_name == 'push'and skip push/sign/attest for dispatch. (code-reviewer: Blocker.)
These two compound: #2 currently masks #1's dispatch exploit path (empty tags abort before sign). "Fix" #2 by adding a dispatch tag without also anchoring the regexp in #1 and you hand a signed, verifier-trusted image to any main-prefixed branch. Resolve both together, and constrain the sign/attest if: to github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/tmp-router-v') so the signing scope can't drift open again.
Things I checked
- Dockerfile COPY set is complete. Every internal import reachable from
cmd/router—router,tmproto,targeting/**,urlcanon— is copied (Dockerfile:26-30). Noadcpimport in the reachable set, so no missing COPY; the build closure holds. - Reproducibility comparison is sound.
provenance: mode=max+sbom: trueemit separate attestation manifests; they move the index digest but not the per-platform image-manifest digest. The(.annotations[\"vnd.docker.reference.type\"] | not)filter at:154-155correctly excludes them, so the publish-path per-platform digest is legitimately comparable to theprovenance=falselocal rebuild. - SDE single-sourcing.
scripts/tmp-router-sde.shis the one derivation both CI (:82) and the local script (build-tmp-router.sh:61) call;fetch-depth: 0(:72) makes CI resolve the same last-touching commit a full clone does. Empty-git log→0is guarded in bothtmp-router-sde.sh:36and again atbuild-tmp-router.sh:63-68. - jq manifest construction handles empty digests via
if == \"\" then {}guards;tonumberis safe because SDE is guaranteed numeric upstream (:238,build-tmp-router.sh:149). - No script-injection surface. No
github.event.*.body/title/head_refreaches anyrun:block;$TAGS/$DIGESTpass throughenv:; jq uses--arg. - Predicate-type binding.
cosign attest --type <full-URI>(:309) matches the documentedverify-attestation --type <same-URI>(docs:111); a rogue--type custompredicate wouldn't match, and the doc calls that out at:117. - TEE measurement mapping (
docs:125-130) is materially correct and conservative — GCPsubmods.container.image_digestwith the launch-by-per-platform-digest MUST, Nitro PCR0-covers-EIF with the Nitro-CLI-version dependence, and the explicit "TDX MRTD / SEV-SNP SNP_MEASUREMENT are boot-chain only, need a companion RTMR / dm-verity anchor" warnings. It steers operators away from allowlisting a weaker measurement than they think.
Follow-ups (non-blocking — file as issues)
- Moving tags point at an unreproduced image before the gate.
edge/main-<sha>/latestare pushed at:124, beforeVerify reproducibility(:166-203) and before sign (:266). When the gate fails, the job aborts leaving those tags on an unsigned, unreproducible digest. A consumer pulling by tag without cosign-verifying runs it. Push to a digest first and repoint moving tags only after gate+sign pass, or document thatedge/latestmay transiently reference a rejected build. (security-reviewer: Medium.) - Repro-gate failure diagnostic is unreachable when the rebuild itself errors.
tmp-router-image.yml:190runs the rebuild underset -euo pipefail; a non-zerobuild-tmp-router.shaborts the step before the::error::/tail stderr_logblock at:193-199. Capture the exit status explicitly (if ! actual=\"$(...)\"; then ...) so a build failure surfaces the captured stderr, not just a digest mismatch. Twomktempfiles also leak (no cleanup). (code-reviewer.)
Minor nits (non-blocking)
--helpparsing is brittle.build-tmp-router.sh:49relies onsed -n '2,/^set -euo pipefail/p'— couples help output to the exactsetline. Fine today; will silently truncate if the preamble is reordered.
Test plan
Three unchecked boxes, including [ ] CI workflow runs successfully on this PR (build-only, no push, on pull_request trigger) — the primary validation path for this change. The workflow_dispatch defect in MUST FIX #2 wouldn't be caught by the PR-trigger build-only path, so the green PR run doesn't cover the manual trigger the header advertises. Worth an actual dispatch run against a scratch branch once #2 is fixed.
Fifth commit on the reproducibility loop, each closing a real gap the prior review surfaced — the trust chain has gotten genuinely tighter each pass. Anchor the regexp and fix the dispatch tags and this is ready.
… narrow signing scope MUST FIX 1: The `--certificate-identity-regexp` cosign uses Go's `regexp.MatchString`, which is unanchored (substring match) — the prior regex `heads/main` would accept a SAN merely containing that substring, e.g. `heads/main-attacker`. That defeated the "only main and version-tag refs are trusted" guarantee the comments claimed. Anchored both ends, escaped the literal dots, and constrained the tag portion to `tmp-router-v[0-9].*` so a branch named `tmp-router-vroot` cannot slip through: ^https://github\.com/.../\.github/workflows/tmp-router-image\.yml @refs/(heads/main|tags/tmp-router-v[0-9].*)$ Applied in all four locations that ship this string to an auditor/verifier: the sign step comment, the attest step comment, the docs `cosign verify` example, and the docs `cosign verify-attestation` example. Comment updated to spell out that the anchors are load-bearing against Go's default substring semantics. MUST FIX 2: On `workflow_dispatch`, the metadata step produced zero tags (`edge`/`type=sha` gated on push event; `type=match` rules only fire on tag refs) but `push: != 'pull_request'` was true — so `docker/build-push-action` was asked to push with an empty tag list and failed. Now every push/sign/attest-related gate is `github.event_name == 'push'` (not `!= 'pull_request'`), so `workflow_dispatch` runs build-only, matching the header comment (updated to say so explicitly). Signing and attestation carry an additional trusted-refs pin (`github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/tmp-router-v')`) as defense-in-depth against a future trigger- list expansion silently widening what gets signed. Plus a follow-up fix on the repro-gate diagnostic: under `set -euo pipefail`, `actual="$(scripts/build-tmp-router.sh ...)"` failing aborted the step before the `::error::` block could tail the captured stderr — an operator saw "step failed" with no context. Now the rebuild's exit status is captured explicitly with `if ! actual="$(...)"; then ...`, so a rebuild *failure* surfaces the same diagnostic block as a rebuild *mismatch*. Also added `trap cleanup EXIT` so per-platform `mktemp` stderr files don't leak. Reproducibility check: same per-platform digest as prior rounds.
There was a problem hiding this comment.
Clean build-side of the TEE attestation chain. The factoring is right: sign the multi-arch index, but bind trust to per-platform image-manifest digests, and verify a no-cache clean rebuild before cosign sign so a poisoned GHA cache can never get a signature. That ordering is the load-bearing part, and it's correct.
Things I checked
- cert-identity regexp is correctly anchored.
^https://github\.com/adcontextprotocol/adcp-go/\.github/workflows/tmp-router-image\.yml@refs/(heads/main|tags/tmp-router-v[0-9].*)$blocksheads/main-attacker(themainalternative is pinned to$),tmp-router-vroot(must be followed by[0-9]), and any fork/feature-branch ref (fork Fulcio SANs carry the fork owner; feature branches producerefs/heads/<branch>).security-reviewer: no bypass reachable.tmp-router-image.yml:306,331. - Fork PRs cannot sign or push. Trigger is
pull_request, notpull_request_target, so fork runs get a read-only token and noid-token; push/login/sign/attest are all gatedgithub.event_name == 'push'plus a trusted-ref pin.tmp-router-image.yml:99,130,312,335. - The reproducibility comparison is logically valid. Publish-path build carries
provenance: mode=max/sbom: true; those attach as separate attestation manifests and do not alter the per-platform image-manifest digest, so comparing the index-extracted per-platform digest against the script's--provenance=false --sbom=falsesingle-platform rebuild is sound.tmp-router-image.yml:135-141,172-227. - Every third-party action is SHA-pinned — checkout, qemu, buildx, login, metadata, build-push, upload-artifact, cosign-installer. No floating tags.
- SDE is single-sourced in
scripts/tmp-router-sde.sh(shared by CI and the local script) with an empty-result floor to0;tonumberondate_epochis safe in both emitters.scripts/tmp-router-sde.sh:35-39,scripts/build-tmp-router.sh:614-619. - TEE measurement mapping is accurate.
ad-tech-protocol-expert: sound-with-caveats — the two claims most likely to prevent a broken allowlist (TDX MRTD ≠ workload → RTMR3; SEV-SNP SNP_MEASUREMENT ≠ workload → dm-verity roothash) are present and correct, as is the GCP launch-by-per-platform-digest caveat and the unchanged Nitro PCR0/EIF row. - CI on this PR is green, including the new
Build & publishjob (build-only onpull_request) andAnalyze (actions). Test-plan box "CI workflow runs successfully on this PR" is satisfied.
Follow-ups (non-blocking — file as issues)
- Prerelease tags hijack the floating tags (
code-reviewer: Major). The tag rules attmp-router-image.yml:114-117usetype=match(raw regex), nottype=semver. Pushingtmp-router-v1.2.3-rc1matchestmp-router-v(\d+\.\d+)→1.2andtmp-router-v(\d+)→1, andlatestfires on anyrefs/tags/tmp-router-v*— so an RC would repointlatest,1, and1.2at the prerelease image and each gets cosign-signed as authoritative. No adopter is affected yet (the GHCR package doesn't exist until the first push), so this is a follow-up — but fix it before the first real tag push. Switch totype=semver,pattern=...(excludes prereleases from the floating forms) or gatelatest/major/minor on a no-prerelease ref pattern. The one thing four rounds of review didn't surface. - GCP claim-path hedge (
ad-tech-protocol-expert).docs/tmp-router-reproducible-build.md:524asserts thesubmods.container.image_digestvssubmods.confidential_spacesplit as settled fact. It's the right path today, but the Confidential Space token schema is versioned and has shifted. Add a "confirm against the CS token version your workload runs under" note so an operator on a different launch-spec doesn't hardcode a claim path that isn't there. - arm64 clean rebuild under a 30-minute job budget. The verify step does a full no-cache rebuild of both platforms (arm64 via QEMU) on top of the publish build, under
timeout-minutes: 30(tmp-router-image.yml:68,185-226). A timeout fails before signing (fail-safe, integrity preserved) — but confirm real run times so it doesn't become a flaky merge gate. - PR body table is stale. It still lists
submods.confidential_space.image_digest; the doc correctly sayssubmods.container.image_digest. Doc is right, body wasn't updated after commit 2.
Minor nits (non-blocking)
- Unescaped
.intmp-router-v[0-9].*. After the first digit,.*matches any char including/. Not exploitable — the string must also be a realgithub.refthat passed theon: tags:filter, and git refname rules forbid..and friends. Tightening totmp-router-v[0-9][^@]*is cosmetic.tmp-router-image.yml:306. - Flag-value handling relies on
set -u.--platform/--push/--measurements-outas the final arg with no value aborts with a bash "unbound variable" rather than the script's own usage error. Fail-loud, acceptable.scripts/build-tmp-router.sh:40-47.
Approving. Follow-ups noted below — the prerelease-tag one before the first tmp-router-v* tag.
Bucket 2 of the TEE work. Lands the build-side of the chain; the wire spec is in adcontextprotocol/adcp#5770 and the in-enclave runtime work (bucket 3) is a separate PR.
Why this exists
A TEE attestation document carries a measurement of the running workload (Nitro PCR0, TDX MRTD, SEV-SNP SNP_MEASUREMENT, GCP Confidential Space image digest). A verifier compares that measurement against an allowlist. The allowlist is only as trustworthy as the procedure that produces it — anyone allowlisting a digest needs to be able to rebuild the same source and recompute it. Today our
cmd/routerbuild is not deterministic; this PR fixes that.What changes
cmd/router/Dockerfile-buildvcs=falseand-ldflags="-buildid="to strip path, VCS, and build-id entropy.SOURCE_DATE_EPOCHwired through as a build-arg so BuildKit normalizes layer mtimes.scripts/build-tmp-router.shSOURCE_DATE_EPOCHfrom the last commit that touched a build input; callsdocker buildxwith the same flags CI uses; prints the OCI image digest; optionally emits atmp-router-measurements/v1JSON manifest..github/workflows/tmp-router-image.ymlcontext-agent-image.yml. Buildslinux/amd64+linux/arm64, pushes toghcr.io/<owner>/<repo>/tmp-router, signs each pushed digest with cosign keyless OIDC, writes the measurements manifest as a workflow artifact, and on tag pushes attaches it to the published image as a cosign attestation so verifiers discover it from the registry.docs/tmp-router-reproducible-build.mdVerified locally
Ran
scripts/build-tmp-router.shtwice back-to-back on this branch; both runs produced the same OCI image digest:The script also produces a
tmp-router-measurements/v1manifest with the digest, the source revision, anddirty: true/falsebased on the working tree state.How verifiers will use it
submods.confidential_space.image_digestclaimFor non-GCP cases the operator runs a one-shot transformation from this OCI image to the platform-specific measurement on a host with the relevant tooling — out of scope for this PR (GitHub-hosted runners don't have Nitro / TDX / SEV-SNP CLIs).
Out of scope (explicit non-goals)
provenance: mode=maxin-toto attestation.Test plan
scripts/build-tmp-router.shruns cleanly, prints the OCI image digest.scripts/build-tmp-router.sh --measurements-out path.jsonwrites a validtmp-router-measurements/v1manifest..github/workflows/tmp-router-image.ymlparses as valid YAML.bash -n scripts/build-tmp-router.shpasses.pull_requesttrigger).mainor first tag push, the GHCR package gets created (it'll be private by default; a repo admin flips it to public per the workflow header comment).🤖 Generated with Claude Code