Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 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
266 changes: 266 additions & 0 deletions .github/workflows/tmp-router-image.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
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. Fallback to 0 if
# `git log` returns empty (defensive — should not occur with the
# full-history checkout above, since at minimum the Dockerfile and
# workflow are themselves build inputs).
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: 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.
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)
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, so all
# tags that resolve to the same image share a single transparency-log
# entry per digest. Verify with:
# cosign verify ghcr.io/<owner>/<repo>/tmp-router:<tag> \
# --certificate-identity-regexp='https://github.com/<owner>/<repo>/.github/workflows/tmp-router-image\.yml@.*' \
# --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, tag pushes only)
# 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. Verify with:
# cosign verify-attestation --type custom \
# ghcr.io/<owner>/<repo>/tmp-router@<digest> \
# --certificate-identity-regexp='...' \
# --certificate-oidc-issuer='https://token.actions.githubusercontent.com'
if: 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 custom \
"${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