Merge pull request #289 from voxpupuli/perf/batch-supersession-crl-re… #748
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" |