Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
339 changes: 339 additions & 0 deletions .github/workflows/tmp-router-image.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,339 @@
name: tmp-router image

# Reproducibly builds the cmd/router OCI image for linux/amd64 and
# linux/arm64, pushes to ghcr.io/<owner>/<repo>/tmp-router, signs each pushed
# digest with cosign keyless (OIDC against Sigstore), and publishes a signed
# measurements manifest as both a workflow artifact and (on tag) a cosign
# attestation attached to the image.
#
# The reproducibility property: an auditor cloning this repo at the same
# revision and running scripts/build-tmp-router.sh produces the same image
# digest published here. That digest is what a TEE attestation verifier
# allowlists against the bound workload measurement.
#
# Triggers:
# - push to main → tags: edge, main-<short_sha>
# - push of tmp-router-v* → tags: <semver>, <major>.<minor>, <major>, latest
# - pull_request → build only, no push (verifies the build stays green)
# - workflow_dispatch → manual rebuild, build-only (no push, no sign)
#
# Permissions:
# - packages: write → push to GHCR
# - id-token: write → cosign keyless signing
#
# First-push note: GHCR creates the package as private. After the first
# successful push, a repo admin must flip visibility to public via
# GitHub → Packages → tmp-router → Package settings → Change visibility.

on:
push:
branches: [main]
tags: ['tmp-router-v*']
pull_request:
branches: [main]
paths:
- cmd/router/**
- router/**
- tmproto/**
- targeting/**
- urlcanon/**
- go.mod
- go.sum
- scripts/build-tmp-router.sh
- .github/workflows/tmp-router-image.yml
workflow_dispatch:

concurrency:
group: tmp-router-image-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read
packages: write
id-token: write

env:
IMAGE: ghcr.io/${{ github.repository }}/tmp-router

jobs:
build:
name: Build & publish
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
# Full history is required for the SOURCE_DATE_EPOCH derivation to
# agree with a local clone: a shallow CI checkout (default depth 1)
# truncates history, so `git log -1 -- <build-paths>` resolves a
# different last-touching-commit timestamp than an auditor's full
# clone, producing a different SDE and a different image digest.
fetch-depth: 0

- name: Compute SOURCE_DATE_EPOCH
id: sde
# Delegated to scripts/tmp-router-sde.sh so the CI and local-rebuild
# SDE derivations can never drift — a drift would cause a genuinely
# reproducible build to fail the documented verification check
# because CI and an auditor would compute different layer mtimes.
run: |
set -euo pipefail
SDE="$(scripts/tmp-router-sde.sh)"
echo "source_date_epoch=${SDE}" >> "$GITHUB_OUTPUT"
echo "SOURCE_DATE_EPOCH=${SDE}"

- name: Set up QEMU
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3

- name: Set up Buildx
uses: docker/setup-buildx-action@c47758b77c9736f4b2ef4073d4d51994fabfe349 # v3

- name: Log in to GHCR
if: github.event_name == 'push'
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Compute tags & labels
id: meta
uses: docker/metadata-action@369eb591f429131d6889c46b94e711f089e6ca96 # v5
with:
images: ${{ env.IMAGE }}
tags: |
type=raw,value=edge,enable=${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
type=sha,prefix=main-,format=short,enable=${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
type=match,pattern=tmp-router-v(.*),group=1
type=match,pattern=tmp-router-v(\d+\.\d+),group=1
type=match,pattern=tmp-router-v(\d+),group=1
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/tmp-router-v') }}
labels: |
org.opencontainers.image.title=tmp-router
org.opencontainers.image.description=Reproducibly-built TMP Router (cmd/router) for TEE-attested operation
org.opencontainers.image.vendor=Ad Context Protocol

- name: Build & push
id: build
uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6
with:
context: .
file: cmd/router/Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name == 'push' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
SOURCE_DATE_EPOCH=${{ steps.sde.outputs.source_date_epoch }}
# Provenance and SBOM are emitted as additional manifests inside
# the image index. They DO change the index digest (the index is
# the set of manifests it carries), but they do NOT change the
# per-platform image-manifest digests this workflow records below.
# mode=max captures full build provenance.
provenance: mode=max
sbom: true
cache-from: type=gha,scope=tmp-router
cache-to: type=gha,mode=max,scope=tmp-router

- name: Resolve per-platform image-manifest digests
id: platforms
# The build's `digest` output is the multi-arch INDEX digest, which
# is not directly comparable to a single-platform local rebuild
# (the index also references provenance/SBOM attestation manifests
# that change the index hash). Verifiers and TEE attestation chains
# bind to a specific platform's image-manifest digest. Resolve those
# here so the measurements manifest publishes the values an auditor
# actually compares.
if: github.event_name == 'push'
env:
INDEX_REF: ${{ env.IMAGE }}@${{ steps.build.outputs.digest }}
run: |
set -euo pipefail
RAW="$(docker buildx imagetools inspect --raw "$INDEX_REF")"
AMD64="$(printf '%s' "$RAW" | jq -r '.manifests[] | select(.platform.architecture=="amd64" and .platform.os=="linux" and (.annotations["vnd.docker.reference.type"] | not)) | .digest')"
ARM64="$(printf '%s' "$RAW" | jq -r '.manifests[] | select(.platform.architecture=="arm64" and .platform.os=="linux" and (.annotations["vnd.docker.reference.type"] | not)) | .digest')"
if [ -z "$AMD64" ] || [ -z "$ARM64" ]; then
echo "failed to resolve per-platform digests from index $INDEX_REF" >&2
printf '%s\n' "$RAW" >&2
exit 1
fi
echo "amd64=${AMD64}" >> "$GITHUB_OUTPUT"
echo "arm64=${ARM64}" >> "$GITHUB_OUTPUT"
echo "linux/amd64 = ${AMD64}"
echo "linux/arm64 = ${ARM64}"

- name: Verify reproducibility (no-cache clean rebuild)
# The publish path uses `cache-from/cache-to: type=gha,scope=tmp-router`
# so iterating PRs is fast. GHA cache is not a trust boundary: a
# poisoned cached layer would land in a "reproducible" image that
# CI then signs and attests as authoritative. To close that gap, we
# rebuild each published platform here using scripts/build-tmp-router.sh
# — which does NOT consult the GHA cache — and assert the resulting
# per-platform image-manifest digests equal what the publish-path
# build produced. A mismatch means the cached path diverged from a
# clean build; fail before cosign sign so we never publish a signed
# digest we could not reproduce. Both amd64 and arm64 are verified
# (arm64 via QEMU on the amd64 runner — slower but symmetric with
# what's actually published and signed).
if: github.event_name == 'push'
env:
EXPECTED_AMD64: ${{ steps.platforms.outputs.amd64 }}
EXPECTED_ARM64: ${{ steps.platforms.outputs.arm64 }}
run: |
set -euo pipefail
STDERR_LOGS=()
cleanup() { for f in "${STDERR_LOGS[@]}"; do rm -f "$f"; done; }
trap cleanup EXIT

verify_platform() {
local platform="$1" expected="$2"
local stderr_log
stderr_log="$(mktemp)"
STDERR_LOGS+=("$stderr_log")

# Capture the rebuild's exit status explicitly. Under
# `set -euo pipefail`, `actual="$(script ...)"` would abort
# the step on script failure before the diagnostic block
# below could run — the operator would see only "step
# failed" with no stderr context. Explicit capture surfaces
# the build failure too, not just the digest mismatch.
local actual
if ! actual="$(scripts/build-tmp-router.sh --platform "$platform" 2>"$stderr_log" | tail -n1)"; then
echo "::error::Reproducibility check FAILED for $platform — the no-cache clean rebuild itself errored."
echo "--- clean-build stderr (last 60 lines) ---"
tail -n 60 "$stderr_log" || true
return 1
fi

echo "expected ($platform, publish-path build) : $expected"
echo "actual ($platform, no-cache clean rebuild): $actual"
if [ "$expected" != "$actual" ]; then
echo "::error::Reproducibility check FAILED for $platform — the publish-path digest does not match a clean rebuild."
echo "This typically indicates a poisoned GHA cache or non-deterministic input."
echo "--- clean-build stderr (last 40 lines) ---"
tail -n 40 "$stderr_log" || true
return 1
fi
}
verify_platform "linux/amd64" "$EXPECTED_AMD64"
verify_platform "linux/arm64" "$EXPECTED_ARM64"
echo "Reproducibility verified: both linux/amd64 and linux/arm64 publish-path digests match clean rebuilds."

- name: Write measurements manifest
id: manifest
# The manifest publishes per-platform image-manifest digests, not
# the index digest. The index digest is recorded as `index_digest`
# for traceability but is NOT what a TEE attestation verifier (or a
# `scripts/build-tmp-router.sh --platform <p>` auditor) compares.
if: github.event_name == 'push'
run: |
set -euo pipefail
mkdir -p artifacts
MANIFEST="artifacts/tmp-router-measurements.json"
jq -n \
--arg index_digest "${{ steps.build.outputs.digest }}" \
--arg image "${{ env.IMAGE }}" \
--arg amd64_digest "${{ steps.platforms.outputs.amd64 }}" \
--arg arm64_digest "${{ steps.platforms.outputs.arm64 }}" \
--arg source_date_epoch "${{ steps.sde.outputs.source_date_epoch }}" \
--arg source_rev "${{ github.sha }}" \
--arg source_rev_short "$(git rev-parse --short HEAD)" \
--arg source_ref "${{ github.ref }}" \
--arg workflow_run "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
'{
schema: "tmp-router-measurements/v1",
image: $image,
index_digest: (if $index_digest == "" then null else $index_digest end),
platform_digests: (
(if $amd64_digest == "" then {} else {"linux/amd64": $amd64_digest} end) +
(if $arm64_digest == "" then {} else {"linux/arm64": $arm64_digest} end)
),
source: {
revision: $source_rev,
revision_short: $source_rev_short,
ref: $source_ref,
date_epoch: ($source_date_epoch | tonumber)
},
build: {
workflow_run: $workflow_run,
runner: "github-hosted ubuntu-latest"
},
reproducibility: {
note: "Reproduce with `scripts/build-tmp-router.sh --platform <p>` on the named revision and a BuildKit-compatible Docker (24+); compare the resulting digest against the matching entry in `platform_digests`. The `index_digest` is the multi-arch index pushed to the registry and includes provenance/SBOM attestation manifests; it is NOT what an auditor or TEE attestation verifier compares. For GCP Confidential Space the verifier compares the `linux/amd64` entry against `submods.container.image_digest` in the attestation token. For Nitro / TDX / SEV-SNP, derive the platform-specific measurement (EIF PCR0 / quote MRTD / SNP_MEASUREMENT) from the per-platform image with the documented platform-tool version; see docs/tmp-router-reproducible-build.md."
}
}' > "$MANIFEST"
echo "manifest=$MANIFEST" >> "$GITHUB_OUTPUT"
cat "$MANIFEST"

- name: Upload measurements manifest (workflow artifact)
if: github.event_name == 'push'
uses: actions/upload-artifact@de65e23aa2b7e23d713bb51fbfcb6d502f8667d8 # v4
with:
name: tmp-router-measurements-${{ steps.build.outputs.digest }}
path: ${{ steps.manifest.outputs.manifest }}
if-no-files-found: error

- name: Install cosign
if: github.event_name == 'push'
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
with:
# renovate: datasource=github-releases depName=sigstore/cosign versioning=semver
cosign-release: v3.0.6

- name: Sign image (keyless OIDC)
# Signs every pushed tag against the build's content digest (the
# multi-arch index), so all tags that resolve to the same image
# share a single transparency-log entry per digest. Verifiers MUST
# cosign verify the index BEFORE trusting any per-platform manifest
# digest extracted from it — see docs/tmp-router-reproducible-build.md
# for the canonical verification flow. The cert-identity regexp
# below is anchored (^...$) because cosign uses Go's
# regexp.MatchString, which is unanchored (substring match): without
# the anchors, `heads/main` would match `heads/main-attacker`. It
# also requires the tag portion to start with a digit
# (`tmp-router-v[0-9].*`) so a branch named `tmp-router-vroot` can
# not slip through. Only `main` and `tmp-router-v<N>...` refs are
# trusted; a fork or branch build carries a different ref in the
# OIDC token and fails this match.
# cosign verify ghcr.io/<owner>/<repo>/tmp-router:<tag> \
# --certificate-identity-regexp='^https://github\.com/<owner>/<repo>/\.github/workflows/tmp-router-image\.yml@refs/(heads/main|tags/tmp-router-v[0-9].*)$' \
# --certificate-oidc-issuer='https://token.actions.githubusercontent.com'
# Signing scope is intentionally pinned to `main` and version tags so
# a future trigger-list expansion cannot silently widen what gets
# signed — the `on:` filter already narrows to these refs, but the
# explicit `if:` here is defense-in-depth against drift.
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/tmp-router-v'))
env:
TAGS: ${{ steps.meta.outputs.tags }}
DIGEST: ${{ steps.build.outputs.digest }}
run: |
set -euo pipefail
echo "$TAGS" | while IFS= read -r tag; do
[ -z "$tag" ] && continue
cosign sign --yes "${tag}@${DIGEST}"
done

- name: Attest measurements manifest (keyless OIDC)
# Attaches the measurements manifest as a cosign attestation against
# the image digest, so it is discoverable from the published image
# rather than only from this workflow run's artifacts. Fires on
# every push (main + tag), so operators pinning `edge` or
# `main-<sha>` also get a registry-discoverable manifest. Verify with:
# cosign verify-attestation --type 'https://adcontextprotocol.org/tmp-router-measurements/v1' \
# ghcr.io/<owner>/<repo>/tmp-router@<digest> \
# --certificate-identity-regexp='^https://github\.com/<owner>/<repo>/\.github/workflows/tmp-router-image\.yml@refs/(heads/main|tags/tmp-router-v[0-9].*)$' \
# --certificate-oidc-issuer='https://token.actions.githubusercontent.com'
# Same trusted-refs pin as the sign step — attestation and signature
# must have the same signing scope.
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/tmp-router-v'))
env:
IMAGE: ${{ env.IMAGE }}
DIGEST: ${{ steps.build.outputs.digest }}
MANIFEST: ${{ steps.manifest.outputs.manifest }}
run: |
set -euo pipefail
cosign attest --yes \
--predicate "$MANIFEST" \
--type "https://adcontextprotocol.org/tmp-router-measurements/v1" \
"${IMAGE}@${DIGEST}"
33 changes: 30 additions & 3 deletions cmd/router/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,26 @@
FROM golang:1.26-alpine AS build
# syntax=docker/dockerfile:1.7
#
# Reproducible build for the TMP router.
#
# The image bytes are deterministic given the same source tree, the same
# Dockerfile, and a BuildKit-compatible builder (Docker 24+). Base images are
# pinned by digest; the Go build uses -trimpath / -buildvcs=false / -buildid=
# so the binary carries no path, VCS, or build-id entropy; SOURCE_DATE_EPOCH
# normalizes layer mtimes when set.
#
# See docs/tmp-router-reproducible-build.md for the verification procedure and
# how the resulting image digest relates to TEE attestation measurements.

# renovate: datasource=docker depName=library/golang
ARG GO_IMAGE=golang:1.26-alpine@sha256:3ad57304ad93bbec8548a0437ad9e06a455660655d9af011d58b993f6f615648
# renovate: datasource=docker depName=distroless/static-debian13
ARG RUNTIME_IMAGE=gcr.io/distroless/static-debian13:nonroot@sha256:963fa6c544fe5ce420f1f54fb88b6fb01479f054c8056d0f74cc2c6000df5240

FROM ${GO_IMAGE} AS build
ARG TARGETOS
ARG TARGETARCH
ARG SOURCE_DATE_EPOCH=0
ENV SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH}
WORKDIR /src
COPY go.mod go.sum* ./
COPY tmproto/ tmproto/
Expand All @@ -7,8 +29,13 @@ COPY urlcanon/ urlcanon/
COPY router/ router/
COPY cmd/router/ cmd/router/
WORKDIR /src/cmd/router
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /router .
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
go build \
-trimpath \
-buildvcs=false \
-ldflags="-s -w -buildid=" \
-o /router .

FROM gcr.io/distroless/static-debian13:nonroot
FROM ${RUNTIME_IMAGE}
COPY --from=build /router /router
ENTRYPOINT ["/router"]
Loading
Loading