Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 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
306 changes: 306 additions & 0 deletions .github/workflows/tmp-router-image.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,306 @@
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
#
# 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
# Use the timestamp of the last commit that touched a build input.
# This is what scripts/build-tmp-router.sh computes for local rebuilds,
# so CI and local produce the same layer mtimes. `git log -1 ...`
# exits 0 with empty output when no commit matches the path filter
# (and only fails with non-zero when git itself errors), so the
# `-z` guard below — not the `|| echo 0` — is what catches the
# empty-result case; the `|| echo 0` is defensive against git
# error paths only.
run: |
set -euo pipefail
SDE="$(git log -1 --format=%ct -- \
cmd/router/Dockerfile cmd/router/ router/ tmproto/ targeting/ urlcanon/ \
go.mod go.sum || echo 0)"
if [ -z "$SDE" ]; then SDE=0; fi
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 != 'pull_request'
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 != 'pull_request' }}
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 != 'pull_request'
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 the linux/amd64 image here using scripts/build-tmp-router.sh
# — which does NOT consult the GHA cache — and assert the resulting
# per-platform image-manifest digest equals 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.
if: github.event_name != 'pull_request'
env:
EXPECTED_AMD64: ${{ steps.platforms.outputs.amd64 }}
run: |
set -euo pipefail
ACTUAL_AMD64="$(scripts/build-tmp-router.sh --platform linux/amd64 2>/tmp/build-stderr.log | tail -n1)"
echo "expected (publish-path build): $EXPECTED_AMD64"
echo "actual (no-cache clean rebuild): $ACTUAL_AMD64"
if [ "$EXPECTED_AMD64" != "$ACTUAL_AMD64" ]; then
echo "::error::Reproducibility check FAILED — 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 /tmp/build-stderr.log || true
exit 1
fi
echo "Reproducibility verified: linux/amd64 publish-path digest matches a clean rebuild."

- 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 != 'pull_request'
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 != 'pull_request'
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 != 'pull_request'
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 intentionally narrow: only `main` and `tmp-router-v*`
# refs are trusted; a fork or branch build would carry a different
# ref in the OIDC token and fail 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.*)' \
# --certificate-oidc-issuer='https://token.actions.githubusercontent.com'
if: github.event_name != 'pull_request'
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.*)' \
# --certificate-oidc-issuer='https://token.actions.githubusercontent.com'
if: github.event_name == 'push'
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