Skip to content

Bound concurrent CA-key signing, shedding on the OCSP path #744

Bound concurrent CA-key signing, shedding on the OCSP path

Bound concurrent CA-key signing, shedding on the OCSP path #744

name: Container images
# Builds the CentOS Stream and Alpine runtime images for linux/amd64 and
# linux/arm64 and publishes multi-arch manifests to the GitHub Container
# Registry (GHCR).
#
# Triggers:
# * Push to main -> build + push the rolling "edge" (and "main") tags.
# * Release tags (v*) -> build + push versioned + latest tags.
# * Manual (workflow_dispatch) -> build, push only if the "push" input is set.
# * Pull requests, always -> build both variants on both architectures as a
# validation check. Same-repo PRs additionally push a throwaway pr-<n> tag;
# fork PRs build only and discard the result (their GITHUB_TOKEN is
# read-only and cannot write packages).
#
# See docs/container-images.md for the one-time repository setup the owner must
# perform (enabling GHCR and package visibility).
on:
push:
branches:
- main
tags:
- "v*"
pull_request:
types: [opened, synchronize, reopened]
workflow_dispatch:
inputs:
push:
description: "Push images to GHCR (otherwise build only)"
type: boolean
default: false
# GITHUB_TOKEN needs packages:write to push to GHCR; it is granted per-job to
# the build and merge jobs that actually push, together with the id-token and
# attestations scopes those jobs need to sign what they pushed. On pull
# requests from forks GitHub automatically downgrades it to read-only; the
# build job detects that and skips pushing, and every signing step is gated on
# the same output, so a fork PR neither pushes nor signs.
permissions:
contents: read
checks: read # verify-release-tag reads the tagged commit's check runs
# Cancel superseded PR runs, but never cancel a release/tag build mid-flight.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
# -- Decide whether this run pushes, and compute the image name -------------─
setup:
name: Configure
runs-on: ubuntu-latest
outputs:
push: ${{ steps.cfg.outputs.push }}
image: ${{ steps.cfg.outputs.image }}
steps:
- name: Determine push policy and image name
id: cfg
env:
EVENT: ${{ github.event_name }}
DISPATCH_PUSH: ${{ github.event.inputs.push }}
# Empty for same-repo PRs is impossible; for forks this differs from
# the base repository, which is how we detect a fork PR.
PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
BASE_REPO: ${{ github.repository }}
run: |
case "$EVENT" in
push) push=true ;; # tag build (release)
workflow_dispatch) push="$DISPATCH_PUSH" ;; # honour the input
pull_request)
# Push only for same-repo PRs; fork PRs lack a write token.
if [ "$PR_HEAD_REPO" = "$BASE_REPO" ]; then push=true; else push=false; fi
;;
*) push=false ;;
esac
# GHCR requires a lowercase image path.
image="ghcr.io/$(echo "$BASE_REPO" | tr '[:upper:]' '[:lower:]')"
{
echo "push=$push"
echo "image=$image"
} >> "$GITHUB_OUTPUT"
echo "push=$push image=$image"
# -- Gate release-tag builds like the Release workflow ----------------------─
# On v* tags only: the tag must match the internal/version constant and the
# tagged commit must have gone green in CI, so a bad tag cannot publish
# versioned images either. On every other event the steps are skipped and
# the job passes in seconds, existing only so `build` can depend on it
# unconditionally.
verify-tag:
name: Verify release tag
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
if: startsWith(github.ref, 'refs/tags/v')
- uses: ./.github/actions/verify-release-tag
if: startsWith(github.ref, 'refs/tags/v')
# -- Build one (variant x architecture) on a native runner ------------------─
# Each image is pushed by digest (no tag); the merge job assembles the
# per-variant multi-arch manifest from the collected digests.
build:
name: Build ${{ matrix.variant }} (${{ matrix.arch }})
needs: [setup, verify-tag]
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write # push per-arch images by digest
id-token: write # mint the Sigstore signing certificate
attestations: write # record the attestation against the repository
strategy:
fail-fast: false
matrix:
variant: [centos, alpine]
platform: [linux/amd64, linux/arm64]
include:
- variant: centos
dockerfile: Dockerfile
- variant: alpine
dockerfile: Dockerfile.alpine
- platform: linux/amd64
arch: amd64
runner: ubuntu-latest
- platform: linux/arm64
arch: arm64
runner: ubuntu-24.04-arm
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Log in to GHCR
if: needs.setup.outputs.push == 'true'
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Image labels
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ${{ needs.setup.outputs.image }}
# Push path: build and push by digest for later manifest assembly.
- name: Build and push by digest
if: needs.setup.outputs.push == 'true'
id: push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: ${{ matrix.dockerfile }}
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
# Provenance attestations create extra manifests that confuse
# push-by-digest + imagetools merge, so disable them here.
provenance: false
outputs: type=image,name=${{ needs.setup.outputs.image }},push-by-digest=true,name-canonical=true,push=true
# Validation path (fork PRs / dispatch without push): build for the
# runner's native arch, load it, and smoke-test the entrypoint.
- name: Build and load for smoke test
if: needs.setup.outputs.push != 'true'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: ${{ matrix.dockerfile }}
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
load: true
tags: openvox-ca:smoke-${{ matrix.variant }}-${{ matrix.arch }}
- name: Smoke test entrypoint
if: needs.setup.outputs.push != 'true'
run: docker run --rm openvox-ca:smoke-${{ matrix.variant }}-${{ matrix.arch }} --help
- name: Export digest
if: needs.setup.outputs.push == 'true'
run: |
mkdir -p /tmp/digests
digest="${{ steps.push.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
if: needs.setup.outputs.push == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: digests-${{ matrix.variant }}-${{ matrix.arch }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
# -- Provenance and SBOM for this per-architecture image ---------------─
# The per-arch images are the meaningful ones to catalogue: each has its
# own binary and its own base package set (CentOS Stream vs Alpine), so
# scanning them here catalogues curl, openssl and the rest of the base
# layer as well as the Go modules. The merged index gets provenance only.
#
# Every step below is keyed off the same push output as the build: fork
# PRs push nothing and so sign nothing, and their GITHUB_TOKEN could not
# mint a certificate anyway.
- name: Install Syft
if: needs.setup.outputs.push == 'true'
id: syft
uses: anchore/sbom-action/download-syft@3ad7283483fc7af8ff2b4ea19663c2d5ca935e26 # v0.24.2
with:
syft-version: v1.51.1
- name: Generate image SBOMs
if: needs.setup.outputs.push == 'true'
env:
IMAGE: ${{ needs.setup.outputs.image }}
DIGEST: ${{ steps.push.outputs.digest }}
SYFT: ${{ steps.syft.outputs.cmd }}
# Scanned by digest, never by tag, and read straight back out of the
# registry so the document describes the image that was pushed rather
# than a local rebuild of it. Syft authenticates with the credentials
# docker/login-action already wrote.
run: |
"$SYFT" scan "registry:${IMAGE}@${DIGEST}" \
--source-name "$IMAGE" \
-o "spdx-json=/tmp/image.spdx.json" \
-o "cyclonedx-json=/tmp/image.cdx.json"
for f in /tmp/image.spdx.json /tmp/image.cdx.json; do
if [ ! -s "$f" ]; then
echo "::error::$f is empty; Syft catalogued nothing for ${IMAGE}@${DIGEST}"
exit 1
fi
done
echo "SPDX packages: $(jq '.packages | length' /tmp/image.spdx.json)"
echo "CycloneDX components: $(jq '.components | length' /tmp/image.cdx.json)"
- name: Attest image provenance
if: needs.setup.outputs.push == 'true'
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
# subject-name carries no tag: the image being attested is
# identified by the digest the push reported, and re-resolving a
# tag here is exactly the race this workflow avoids.
subject-name: ${{ needs.setup.outputs.image }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
# sbom-path takes a single file, so attesting both formats is two calls
# against the same subject rather than one call with two documents.
- name: Attest image SBOM (SPDX)
if: needs.setup.outputs.push == 'true'
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-name: ${{ needs.setup.outputs.image }}
subject-digest: ${{ steps.push.outputs.digest }}
sbom-path: /tmp/image.spdx.json
push-to-registry: true
- name: Attest image SBOM (CycloneDX)
if: needs.setup.outputs.push == 'true'
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-name: ${{ needs.setup.outputs.image }}
subject-digest: ${{ steps.push.outputs.digest }}
sbom-path: /tmp/image.cdx.json
push-to-registry: true
# -- Merge per-architecture digests into one multi-arch manifest ------------─
merge:
name: Publish ${{ matrix.variant }} manifest
needs: [setup, build]
if: needs.setup.outputs.push == 'true'
runs-on: ubuntu-latest
permissions:
packages: write # push the multi-arch manifests and tags
id-token: write # mint the Sigstore signing certificate
attestations: write # record the attestation against the repository
strategy:
fail-fast: false
matrix:
include:
# CentOS Stream is the default image: unsuffixed tags + latest.
- variant: centos
flavor: |
latest=auto
# Alpine images carry an -alpine suffix, including latest-alpine.
- variant: alpine
flavor: |
latest=auto
suffix=-alpine,onlatest=true
steps:
- name: Download digests
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: /tmp/digests
pattern: digests-${{ matrix.variant }}-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Log in to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Compute tags
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ${{ needs.setup.outputs.image }}
flavor: ${{ matrix.flavor }}
tags: |
type=edge
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}},enable=${{ !startsWith(github.ref_name, 'v0.') }}
type=ref,event=branch
type=ref,event=pr
- name: Create multi-arch manifest
id: manifest
working-directory: /tmp/digests
env:
IMAGE: ${{ needs.setup.outputs.image }}
run: |
# --metadata-file reports the digest of the index this command just
# pushed. That matters: the obvious way to learn it afterwards is
# `imagetools inspect <tag>`, which is a fresh tag resolution, and a
# tag is mutable. Re-resolving one between publishing and signing
# opens a window where the thing signed is not the thing published.
#
# Word splitting is intentional: expand the -t flags and the
# per-digest image references into separate arguments.
# shellcheck disable=SC2046
docker buildx imagetools create \
--metadata-file /tmp/index.json \
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf "${IMAGE}@sha256:%s " *)
digest="$(jq -r '.["containerimage.descriptor"].digest' /tmp/index.json)"
case "$digest" in
sha256:*) ;;
*) echo "::error::imagetools create reported no index digest"; exit 1 ;;
esac
echo "digest=$digest" >> "$GITHUB_OUTPUT"
echo "index digest: $digest"
- name: Attest index provenance
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-name: ${{ needs.setup.outputs.image }}
subject-digest: ${{ steps.manifest.outputs.digest }}
push-to-registry: true
- name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
with:
cosign-release: v3.1.3
- name: Sign the published index
env:
IMAGE: ${{ needs.setup.outputs.image }}
DIGEST: ${{ steps.manifest.outputs.digest }}
# Signed as well as attested: an attestation satisfies `gh attestation
# verify` and `cosign verify-attestation`, but an admission policy that
# simply asserts "this image is signed" wants a plain signature, which
# is what `cosign verify` checks.
#
# --recursive covers the index and every child manifest in one call.
#
# The storage scheme is left at cosign's default. Setting
# --registry-referrers-mode=oci-1-1 explicitly, as would be tidier,
# is refused by cosign v3.1.3 unless COSIGN_EXPERIMENTAL=1 is also
# set, and an experimental gate is not a thing to depend on in the
# publishing path: a release that renames or drops it would break
# signing. What that flag was meant to buy — no silent change of
# scheme under us — is bought instead by pinning cosign's version
# above, and the default is what a plain `cosign verify` reads with
# no environment variable set.
#
# The subject is always ${IMAGE}@${DIGEST}: `cosign sign` accepts a
# tag, and signing one would reintroduce the race the metadata file
# exists to close.
run: |
cosign sign --yes --recursive "${IMAGE}@${DIGEST}"
- name: Inspect published image
env:
IMAGE: ${{ needs.setup.outputs.image }}
DIGEST: ${{ steps.manifest.outputs.digest }}
run: |
docker buildx imagetools inspect "${IMAGE}@${DIGEST}"
- name: Verify the published tags still point at what was signed
env:
IMAGE: ${{ needs.setup.outputs.image }}
DIGEST: ${{ steps.manifest.outputs.digest }}
# A post-condition, not a lock: it cannot prevent a tag moving between
# the push and the signature, but it converts "silently published a tag
# pointing at unsigned content" into a red build. Concurrency already
# serialises runs on the same ref; this catches the cross-ref case,
# e.g. an `edge` push landing on top of a tag build.
run: |
checked=0
while read -r tag; do
[ -n "$tag" ] || continue
got="$(docker buildx imagetools inspect --format '{{.Manifest.Digest}}' "$tag")"
if [ "$got" != "$DIGEST" ]; then
echo "::error::$tag resolves to $got, but $DIGEST was signed"
exit 1
fi
echo "ok: $tag -> $got"
checked=$((checked + 1))
done < <(jq -r '.tags[]' <<< "$DOCKER_METADATA_OUTPUT_JSON")
# Guard against the loop having verified nothing at all: an empty tag
# list would otherwise pass this step in silence.
if [ "$checked" -eq 0 ]; then
echo "::error::No tags were checked; the metadata action produced an empty tag list"
exit 1
fi
echo "verified $checked tag(s) against $DIGEST"
# -- Prove the signature and attestations are actually consumable ------─
# Signing exiting zero says the push was accepted, not that anyone can
# verify the result. Three assumptions this design rests on are only
# testable against a real registry, and each is checked here so that a
# wrong one is a red build on `main`/`edge` rather than a discovery
# during a release:
#
# 1. Keyless Sigstore signing produces a signature that verifies
# against the workflow identity we expect.
# 2. `cosign sign --recursive` really did reach the index's children,
# not just the index. This is the step most likely to surprise,
# because the index was assembled by `imagetools create` rather than
# pushed by buildx.
# 3. cosign can discover the attestations `actions/attest` pushed to
# GHCR through the OCI 1.1 referrers API. The whole choice of
# GitHub-native attestation over `cosign attest` depends on this; if
# it does not hold, the fallback is running `cosign attest` too.
#
# The identity is built from the run's own context rather than hardcoded,
# so this verifies correctly on a fork as well as upstream, and on a tag
# as well as a branch.
- name: Verify what was just signed
env:
IMAGE: ${{ needs.setup.outputs.image }}
DIGEST: ${{ steps.manifest.outputs.digest }}
working-directory: /tmp/digests
run: |
identity="https://github.com/${GITHUB_REPOSITORY}/.github/workflows/container-images.yml@${GITHUB_REF}"
issuer="https://token.actions.githubusercontent.com"
echo "expecting certificate identity: $identity"
echo "--- index signature ---"
cosign verify --certificate-identity "$identity" \
--certificate-oidc-issuer "$issuer" "${IMAGE}@${DIGEST}" > /dev/null
# A child digest, taken from the same files the manifest was built
# from rather than by inspecting the index back out of the registry.
child="$(set -- *; echo "$1")"
if [ -z "$child" ] || [ ! -e "$child" ]; then
echo "::error::No per-architecture digest files found; nothing to verify --recursive against"
exit 1
fi
echo "--- child manifest signature (sha256:$child) ---"
cosign verify --certificate-identity "$identity" \
--certificate-oidc-issuer "$issuer" "${IMAGE}@sha256:${child}" > /dev/null
# Provenance only, not the SBOM attestations. Those carry a
# versioned predicate type (https://spdx.dev/Document/v2.3, tracking
# the SBOM's own spdxVersion), and cosign's `spdxjson` shorthand
# resolves to the unversioned URI, so asserting them here would
# couple the build to Syft's SPDX version and fail on a bump for a
# reason that is not a real defect. Provenance is enough for what
# this step exists to prove: it is pushed to the registry by the same
# mechanism, so discovering it demonstrates the referrers path works.
echo "--- provenance attestation on the child, via the referrers API ---"
cosign verify-attestation --type slsaprovenance1 \
--certificate-identity "$identity" \
--certificate-oidc-issuer "$issuer" \
"${IMAGE}@sha256:${child}" > /dev/null
echo "signature and provenance verified against $identity"