Skip to content
Open
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
112 changes: 57 additions & 55 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
# - Signs provenance (index) and per-arch SBOMs (per-arch manifest) via Sigstore
# - Writes a pretty job summary with tags, digest and attestation status

name: 🐳 Docker Publish
name: 🐳 Build + Publish Docker Image

on:
workflow_dispatch:
Expand All @@ -24,12 +24,13 @@ on:
required: false
default: ''
push:
# Trigger on new tags (which are created after each merge)
# Loose on purpose, so bad tags fail loudly in prepare instead of silently not running
tags: ['*.*.*']
schedule:
- cron: '0 4 * * 0'

concurrency:
# Per-ref, so back-to-back releases each still get published
group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.tag }}
cancel-in-progress: false

Expand All @@ -51,12 +52,12 @@ jobs:
semver: ${{ steps.resolve.outputs.semver }}
latest: ${{ steps.resolve.outputs.latest }}
steps:
- name: 🛎️ Checkout (with tags)
- name: Checkout (with tags)
uses: actions/checkout@v6
with:
fetch-depth: 0

- name: 🔢 Resolve & validate version
- name: Resolve and validate version
id: resolve
env:
INPUT_TAG: ${{ inputs.tag }}
Expand All @@ -68,7 +69,7 @@ jobs:
SEMVER='^[0-9]+\.[0-9]+\.[0-9]+$'

if [ -n "$INPUT_TAG" ]; then
# Manual rebuild of a specific release - validate before doing any work
# Manual rebuild of a specific release, never moves :latest
if ! echo "$INPUT_TAG" | grep -qE "$SEMVER"; then
echo "::error::Invalid tag '${INPUT_TAG}'. Must be semver (e.g. 2.2.0)."
exit 1
Expand All @@ -77,21 +78,18 @@ jobs:
echo "::error::Tag '${INPUT_TAG}' does not exist in this repository."
exit 1
fi
# Rebuilding an older release must never move :latest
ref="refs/tags/${INPUT_TAG}"; version="$INPUT_TAG"; semver=true; latest=false

elif [ "$REF_TYPE" = "tag" ]; then
# A release tag was pushed (by 🔖 Auto Version & Tag, or by hand)
# Release tag pushed
if ! echo "$REF_NAME" | grep -qE "$SEMVER"; then
echo "::error::Tag '${REF_NAME}' is not semver; refusing to publish."
exit 1
fi
ref="refs/tags/${REF_NAME}"; version="$REF_NAME"; semver=true; latest=true

elif [ "$EVENT" = "schedule" ]; then
# Weekly refresh. Rebuild the newest *release* rather than master, so
# :latest picks up base image patches without drifting onto unreleased
# code. semver=false keeps already-published version tags immutable
# Weekly refresh of :latest from the newest release, version tags stay immutable
newest=$(git tag --list --sort=-v:refname | grep -E "$SEMVER" | head -n1 || true)
if [ -z "$newest" ]; then
echo "::error::No semver tag found to rebuild."
Expand Down Expand Up @@ -135,12 +133,12 @@ jobs:
arch: arm64
runs-on: ${{ matrix.runner }}
steps:
- name: 🛎️ Checkout
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.ref }}

- name: 🏷️ Build metadata
- name: Build metadata
id: meta
run: |
set -euo pipefail
Expand All @@ -149,19 +147,18 @@ jobs:
echo "created=$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
} >> "$GITHUB_OUTPUT"

- name: 🔧 Set up Buildx
- name: Set up Buildx
uses: docker/setup-buildx-action@v4

- name: 🔑 Login to GHCR
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}

# Attestations can't go through the docker exporter, so this scan-only
# build sets provenance: false. The push below re-adds them.
- name: 🔨 Build image (load for scan)
# The docker exporter can't carry attestations, so the push step re-adds them
- name: Build image (load for scan)
uses: docker/build-push-action@v7
with:
context: .
Expand All @@ -171,9 +168,8 @@ jobs:
tags: web-check-scan:${{ matrix.arch }}
provenance: false

# Only the weekly cron treats CVEs as fatal. Everywhere else the scan is
# advisory, so a Trivy or DB outage can never block a release
- name: 🛡️ Trivy vulnerability scan
# Advisory everywhere except cron, so a Trivy outage can't block a release
- name: Trivy vulnerability scan
id: scan
uses: aquasecurity/trivy-action@v0.36.0
continue-on-error: ${{ github.event_name != 'schedule' }}
Expand All @@ -190,23 +186,22 @@ jobs:
output: 'trivy-${{ matrix.arch }}.sarif'
timeout: '10m'

# If CVEs blocked the build, print them so they're readable in the log
- name: 📋 List blocking CVEs (on scan failure)
- name: List blocking CVEs (on scan failure)
if: always() && steps.scan.outcome == 'failure'
continue-on-error: true
run: |
jq -r '.runs[].results[]? | "\(.ruleId): \(.message.text)"' \
"trivy-${{ matrix.arch }}.sarif" | sort -u

- name: 📤 Upload Trivy SARIF
- name: Upload Trivy SARIF
if: always() && hashFiles(format('trivy-{0}.sarif', matrix.arch)) != ''
continue-on-error: true
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: trivy-${{ matrix.arch }}.sarif
category: trivy-${{ matrix.arch }}

- name: 🚀 Push by digest
- name: Push by digest
id: push
uses: docker/build-push-action@v7
with:
Expand All @@ -221,7 +216,7 @@ jobs:
org.opencontainers.image.created=${{ steps.meta.outputs.created }}
outputs: type=image,name=${{ env.GH_IMAGE }},push-by-digest=true,name-canonical=true,push=true

- name: 🧬 Write digest
- name: Write digest
env:
DIGEST: ${{ steps.push.outputs.digest }}
DIGESTS_DIR: ${{ runner.temp }}/digests
Expand All @@ -235,7 +230,7 @@ jobs:
mkdir -p "$DIGESTS_DIR"
echo "$DIGEST" > "$DIGESTS_DIR/$ARCH"

- name: 📤 Upload digest
- name: Upload digest
uses: actions/upload-artifact@v7
with:
name: digest-${{ matrix.arch }}
Expand All @@ -257,24 +252,24 @@ jobs:
env:
HAS_DH: ${{ secrets.DOCKERHUB_PASSWORD != '' }}
steps:
- name: 📥 Download digests
- name: Download digests
uses: actions/download-artifact@v8
with:
path: ${{ runner.temp }}/digests
pattern: digest-*
merge-multiple: true

- name: 🔧 Set up Buildx
- name: Set up Buildx
uses: docker/setup-buildx-action@v4

- name: 🔑 Login to GHCR
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: 🔑 Login to Docker Hub
- name: Login to Docker Hub
id: dh_login
if: env.HAS_DH == 'true'
continue-on-error: true
Expand All @@ -283,9 +278,8 @@ jobs:
username: ${{ vars.DOCKER_USERNAME || 'lissy93' }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}

# Builds race: a newer release tagged while this one was building must not
# be clobbered by an older build finishing second
- name: 🕓 Guard against :latest regression
# An older build finishing second must not clobber a newer release
- name: Guard against :latest regression
id: guard
env:
VERSION: ${{ needs.prepare.outputs.version }}
Expand All @@ -305,7 +299,7 @@ jobs:
fi
echo "latest=$keep" >> "$GITHUB_OUTPUT"

- name: 🗂️ Generate tags
- name: Generate tags
id: meta
uses: docker/metadata-action@v6
with:
Expand All @@ -320,8 +314,9 @@ jobs:
flavor: |
latest=false

# GHCR PUSH
- name: 🧩 Create & push manifest (GHCR)
# Registries are pushed separately, so Docker Hub can never block GHCR.
# imagetools (unlike docker manifest) keeps the BuildKit attestation manifests
- name: Create and push manifest (GHCR)
id: manifest
working-directory: ${{ runner.temp }}/digests
run: |
Expand All @@ -345,7 +340,7 @@ jobs:
echo "digest=$DIGEST" >> "$GITHUB_OUTPUT"
echo "Published ${#TAGS[@]} tag(s) to GHCR at ${DIGEST}"

- name: 🧩 Create & push manifest (Docker Hub)
- name: Create and push manifest (Docker Hub)
id: dh_manifest
if: steps.dh_login.outcome == 'success'
continue-on-error: true
Expand All @@ -370,19 +365,19 @@ jobs:
ARGS=(); for t in "${TAGS[@]}"; do ARGS+=(-t "$t"); done
docker buildx imagetools create "${ARGS[@]}" "${SOURCES[@]}"
DIGEST=$(docker buildx imagetools inspect "${TAGS[0]}" --format '{{.Manifest.Digest}}')
# Same source descriptors must yield the same index; if not, the
# attestations below would be signing the wrong thing
if [ "$DIGEST" != "$GHCR_DIGEST" ]; then
echo "::error::Docker Hub digest ${DIGEST} != GHCR ${GHCR_DIGEST}; skipping its attestations"
exit 1
fi
echo "Published ${#TAGS[@]} tag(s) to Docker Hub at ${DIGEST}"

# BuildKit writes a each SBOM per architecture
- name: 🧾 Extract per-arch SBOMs & subjects
# BuildKit writes one SBOM per architecture, against the per-arch manifest.
# File-level entries are dropped to stay under the 16MB attestation limit
- name: Extract per-arch SBOMs and subjects
id: sbom
env:
DIGEST: ${{ steps.manifest.outputs.digest }}
MAX_BYTES: '16777216'
run: |
set -euo pipefail
RAW=$(docker buildx imagetools inspect "${GH_IMAGE}@${DIGEST}" --raw)
Expand All @@ -392,18 +387,25 @@ jobs:
<<< "$RAW")
fmt='{{ json (index .SBOM "linux/'"$arch"'").SPDX }}'
docker buildx imagetools inspect "${GH_IMAGE}@${DIGEST}" \
--format "$fmt" > "sbom.$arch.json" 2>/dev/null || true
if [ -n "$subject" ] && jq -e 'type == "object" and has("packages")' "sbom.$arch.json" >/dev/null 2>&1; then
--format "$fmt" > "full.$arch.json" 2>/dev/null || true
jq -c 'del(.files)
| .relationships |= map(select(
(.spdxElementId | startswith("SPDXRef-File") | not) and
(.relatedSpdxElement | startswith("SPDXRef-File") | not)))' \
"full.$arch.json" > "sbom.$arch.json" 2>/dev/null || true
size=$(stat -c%s "sbom.$arch.json" 2>/dev/null || echo 0)
if [ -n "$subject" ] && [ "$size" -le "$MAX_BYTES" ] &&
jq -e 'type == "object" and has("packages")' "sbom.$arch.json" >/dev/null 2>&1; then
echo "${arch}=true" >> "$GITHUB_OUTPUT"
echo "${arch}_subject=$subject" >> "$GITHUB_OUTPUT"
echo "linux/${arch}: $(jq '.packages | length' "sbom.$arch.json") packages -> ${subject}"
echo "linux/${arch}: $(jq '.packages | length' "sbom.$arch.json") packages, ${size} bytes, subject ${subject}"
else
echo "::warning::No SBOM or subject for linux/${arch}; skipping its attestation"
echo "::warning::Unusable SBOM for linux/${arch} (${size} bytes); skipping its attestation"
echo "${arch}=false" >> "$GITHUB_OUTPUT"
fi
done

- name: 🛡️ Attest provenance (GHCR)
- name: Attest provenance (GHCR)
id: prov_ghcr
uses: actions/attest@v4
continue-on-error: true
Expand All @@ -413,7 +415,7 @@ jobs:
push-to-registry: true
show-summary: false

- name: 🪪 Attest SBOM, amd64 (GHCR)
- name: Attest SBOM, amd64 (GHCR)
id: sbom_amd64_ghcr
if: steps.sbom.outputs.amd64 == 'true'
uses: actions/attest@v4
Expand All @@ -425,7 +427,7 @@ jobs:
push-to-registry: true
show-summary: false

- name: 🪪 Attest SBOM, arm64 (GHCR)
- name: Attest SBOM, arm64 (GHCR)
id: sbom_arm64_ghcr
if: steps.sbom.outputs.arm64 == 'true'
uses: actions/attest@v4
Expand All @@ -437,7 +439,7 @@ jobs:
push-to-registry: true
show-summary: false

- name: 🛡️ Attest provenance (Docker Hub)
- name: Attest provenance (Docker Hub)
id: prov_dh
if: steps.dh_manifest.outcome == 'success'
uses: actions/attest@v4
Expand All @@ -448,7 +450,7 @@ jobs:
push-to-registry: true
show-summary: false

- name: 🪪 Attest SBOM, amd64 (Docker Hub)
- name: Attest SBOM, amd64 (Docker Hub)
id: sbom_amd64_dh
if: steps.dh_manifest.outcome == 'success' && steps.sbom.outputs.amd64 == 'true'
uses: actions/attest@v4
Expand All @@ -460,7 +462,7 @@ jobs:
push-to-registry: true
show-summary: false

- name: 🪪 Attest SBOM, arm64 (Docker Hub)
- name: Attest SBOM, arm64 (Docker Hub)
id: sbom_arm64_dh
if: steps.dh_manifest.outcome == 'success' && steps.sbom.outputs.arm64 == 'true'
uses: actions/attest@v4
Expand All @@ -472,7 +474,7 @@ jobs:
push-to-registry: true
show-summary: false

- name: 📋 Job summary
- name: Job summary
if: always()
continue-on-error: true
env:
Expand Down Expand Up @@ -503,7 +505,7 @@ jobs:
echo "**Manifest:** \`${DIGEST:-unknown}\`"
if [ "${DH_MANIFEST:-skipped}" = "failure" ]; then
echo
echo "> ⚠️ Docker Hub publish failed GHCR was published successfully."
echo "> ⚠️ Docker Hub publish failed. GHCR was published successfully."
fi
echo
echo "The following tags have been updated and published:"
Expand All @@ -516,7 +518,7 @@ jobs:
echo
while IFS='=' read -r name outcome; do
if [ -n "$name" ]; then
echo "- $(icon "${outcome:-skipped}") ${name} ${outcome:-skipped}"
echo "- $(icon "${outcome:-skipped}") ${name}: ${outcome:-skipped}"
fi
done <<< "${RESULTS:-}"
echo
Expand All @@ -527,7 +529,7 @@ jobs:
echo "gh attestation verify oci://${GH_IMAGE}@${DIGEST:-} --repo ${GITHUB_REPOSITORY}"
echo '```'
echo
echo "Verify an SBOM (subject is the per-arch manifest, as BuildKit does):"
echo "Verify an SBOM (subject is the per-arch manifest):"
echo '```bash'
echo "gh attestation verify oci://${GH_IMAGE}@${AMD64_SUBJECT:-<amd64-digest>} --repo ${GITHUB_REPOSITORY} # amd64"
echo "gh attestation verify oci://${GH_IMAGE}@${ARM64_SUBJECT:-<arm64-digest>} --repo ${GITHUB_REPOSITORY} # arm64"
Expand Down