mirror of
https://github.com/storytold/craft-common-actions.git
synced 2026-11-15 15:02:24 +00:00
* ci: use StepSecurity's maintained actions Each third-party action that StepSecurity publishes a secure drop-in replacement for now uses that replacement, pinned to a commit with its version as a comment. Versions follow what we used: the newest StepSecurity release in the same major, or the closest major they publish. dtolnay/rust-toolchain refs that named a toolchain (stable, nightly, 1.76) become the replacement's `toolchain` input. * tests: find the FreeBSD VM, SonarCloud scan and install-action steps by their StepSecurity names
324 lines
12 KiB
YAML
324 lines
12 KiB
YAML
name: "Docker build"
|
|
|
|
# Builds one image per entry in `platforms`, pushes every platform by digest to
|
|
# each repository in `images`, then joins those digests into a single multi-arch
|
|
# manifest per repository. This is how the two craft web images publish:
|
|
# artcraft-services (8 binaries, a 64-core runner and a GHA layer cache),
|
|
# autocraft, and font-preview-service (multi-arch, GHCR and Docker Hub).
|
|
#
|
|
# docker-publish:
|
|
# uses: storytold/craft-common-actions/.github/workflows/docker-build.yml@main
|
|
# with:
|
|
# images: |
|
|
# ghcr.io/storytold/font-preview-service
|
|
# docker.io/storytold/font-preview-service
|
|
# secrets: inherit
|
|
#
|
|
# The caller owns triggers, concurrency and the `packages: write` permission; a
|
|
# called workflow cannot grant itself more than its caller has.
|
|
on:
|
|
workflow_call:
|
|
inputs:
|
|
images:
|
|
description: "Image repositories to push, one per line (ghcr.io/... and/or docker.io/...)"
|
|
type: string
|
|
required: true
|
|
platforms:
|
|
description: 'JSON array of {"platform": "linux/amd64", "runner": "ubuntu-26.04"} entries'
|
|
type: string
|
|
required: false
|
|
default: '[{"platform":"linux/amd64","runner":"ubuntu-26.04"},{"platform":"linux/arm64","runner":"ubuntu-26.04-arm"}]'
|
|
dockerfile:
|
|
description: "Dockerfile path, relative to the checkout"
|
|
type: string
|
|
required: false
|
|
default: "Dockerfile"
|
|
context:
|
|
description: "Build context, relative to the checkout"
|
|
type: string
|
|
required: false
|
|
default: "."
|
|
target:
|
|
description: "Dockerfile stage to build; empty builds the final stage"
|
|
type: string
|
|
required: false
|
|
default: ""
|
|
build-args:
|
|
description: "Docker build arguments, one KEY=VALUE per line"
|
|
type: string
|
|
required: false
|
|
default: ""
|
|
tags:
|
|
description: "docker/metadata-action tag rules applied to every image"
|
|
type: string
|
|
required: false
|
|
default: |
|
|
type=ref,event=branch
|
|
type=semver,pattern={{version}}
|
|
type=raw,value=latest,enable=${{ github.ref_name == github.event.repository.default_branch }}
|
|
pre-build-command:
|
|
description: "Bash run after checkout and before the build, for example fetching large sources; empty skips it"
|
|
type: string
|
|
required: false
|
|
default: ""
|
|
free-disk:
|
|
description: "Remove the runner's preinstalled SDKs before building, for images that need the space"
|
|
type: boolean
|
|
required: false
|
|
default: false
|
|
cache-from:
|
|
description: "BuildKit cache source, for example type=gha; empty disables it"
|
|
type: string
|
|
required: false
|
|
default: ""
|
|
cache-to:
|
|
description: "BuildKit cache destination, for example type=gha,mode=max; empty disables it"
|
|
type: string
|
|
required: false
|
|
default: ""
|
|
dhi-login:
|
|
description: "Log in to dhi.io before building, for images based on Docker Hardened Images"
|
|
type: boolean
|
|
required: false
|
|
default: false
|
|
timeout-minutes:
|
|
description: "Time limit for each platform build"
|
|
type: number
|
|
required: false
|
|
default: 60
|
|
secrets:
|
|
DOCKERHUB_USERNAME:
|
|
description: "Docker Hub user for dhi.io base images and docker.io pushes; only read when configured"
|
|
required: false
|
|
DOCKERHUB_TOKEN:
|
|
description: "Docker Hub token for dhi.io base images and docker.io pushes; only read when configured"
|
|
|
|
# Read-only by default; each job that pushes to GHCR asks for `packages: write` itself.
|
|
permissions:
|
|
contents: read
|
|
|
|
jobs:
|
|
build:
|
|
name: Build ${{ matrix.platform.platform }}
|
|
permissions:
|
|
contents: read
|
|
packages: write
|
|
strategy:
|
|
fail-fast: true
|
|
matrix:
|
|
platform: ${{ fromJSON(inputs.platforms) }}
|
|
runs-on: ${{ matrix.platform.runner }}
|
|
timeout-minutes: ${{ inputs.timeout-minutes }}
|
|
steps:
|
|
- name: Harden runner
|
|
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
|
|
with:
|
|
egress-policy: audit
|
|
- name: Check out
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
persist-credentials: false
|
|
|
|
# The Rust release build in artcraft-services fills the ~14 GB of SDKs a
|
|
# hosted runner ships with; pruning them first avoids a mid-build ENOSPC.
|
|
- name: Free disk space
|
|
if: ${{ inputs.free-disk }}
|
|
shell: bash
|
|
run: |
|
|
set -x
|
|
df -h /
|
|
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \
|
|
/opt/hostedtoolcache/CodeQL || true
|
|
sudo docker system prune -af --volumes || true
|
|
df -h /
|
|
|
|
# Passed through the environment so the command is data, not workflow
|
|
# source: it runs with `set -euo pipefail` like any other build step.
|
|
- name: Prepare build context
|
|
if: ${{ inputs.pre-build-command != '' }}
|
|
shell: bash
|
|
env:
|
|
PRE_BUILD_COMMAND: ${{ inputs.pre-build-command }}
|
|
run: bash --noprofile --norc -eo pipefail -c "$PRE_BUILD_COMMAND"
|
|
|
|
- name: Set up Buildx
|
|
uses: step-security/setup-buildx-action@e50058a0fcd8d1a7d626c905502d010a468bf238 # v4.4.1
|
|
|
|
- name: Log in to Docker Hub (DHI base images)
|
|
if: ${{ inputs.dhi-login }}
|
|
uses: step-security/docker-login-action@8c7b695d8738c5699a679ff5254baf0f19f20a94 # v4.6.0
|
|
with:
|
|
registry: dhi.io
|
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
|
|
- name: Log in to GHCR
|
|
if: ${{ contains(inputs.images, 'ghcr.io') }}
|
|
uses: step-security/docker-login-action@8c7b695d8738c5699a679ff5254baf0f19f20a94 # v4.6.0
|
|
with:
|
|
registry: ghcr.io
|
|
username: ${{ github.actor }}
|
|
password: ${{ secrets.GITHUB_TOKEN }}
|
|
|
|
- name: Log in to Docker Hub
|
|
if: ${{ contains(inputs.images, 'docker.io/') }}
|
|
uses: step-security/docker-login-action@8c7b695d8738c5699a679ff5254baf0f19f20a94 # v4.6.0
|
|
with:
|
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
|
|
# A by-digest push names one repository, so each image is built separately;
|
|
# the layer cache makes the repeats cheap. Every platform build must produce
|
|
# the same manifest digest, which the merge job then tags.
|
|
- name: Build and push by digest
|
|
id: build
|
|
shell: bash
|
|
env:
|
|
IMAGES: ${{ inputs.images }}
|
|
PLATFORM: ${{ matrix.platform.platform }}
|
|
BUILD_CONTEXT: ${{ inputs.context }}
|
|
BUILD_DOCKERFILE: ${{ inputs.dockerfile }}
|
|
BUILD_TARGET: ${{ inputs.target }}
|
|
BUILD_ARGS: ${{ inputs.build-args }}
|
|
CACHE_FROM: ${{ inputs.cache-from }}
|
|
CACHE_TO: ${{ inputs.cache-to }}
|
|
run: |
|
|
set -euo pipefail
|
|
digest=""
|
|
while IFS= read -r image; do
|
|
image="${image//[[:space:]]/}"
|
|
[ -n "$image" ] || continue
|
|
case "$image" in
|
|
ghcr.io/* | docker.io/*) ;;
|
|
*)
|
|
echo "error: unsupported image repository: $image" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
# No provenance attestation: it names the registry it was pushed to,
|
|
# so each repository would get a different digest and the merge job
|
|
# could not tag one manifest for both.
|
|
args=(--platform "$PLATFORM" --file "$BUILD_DOCKERFILE" --provenance=false)
|
|
if [ -n "$BUILD_TARGET" ]; then args+=(--target "$BUILD_TARGET"); fi
|
|
if [ -n "$CACHE_FROM" ]; then args+=(--cache-from "$CACHE_FROM"); fi
|
|
if [ -n "$CACHE_TO" ]; then args+=(--cache-to "$CACHE_TO"); fi
|
|
while IFS= read -r build_arg; do
|
|
build_arg="${build_arg#"${build_arg%%[![:space:]]*}"}"
|
|
[ -n "$build_arg" ] || continue
|
|
args+=(--build-arg "$build_arg")
|
|
done <<< "$BUILD_ARGS"
|
|
meta="$RUNNER_TEMP/meta-$(printf '%s' "$image" | tr '/.:' '___').json"
|
|
docker buildx build \
|
|
"${args[@]}" \
|
|
--output "type=image,name=$image,push-by-digest=true,name-canonical=true,push=true" \
|
|
--metadata-file "$meta" \
|
|
"$BUILD_CONTEXT"
|
|
candidate="$(jq -r '."containerimage.digest" // empty' "$meta")"
|
|
if [ -z "$candidate" ]; then
|
|
echo "error: no digest produced for $image" >&2
|
|
exit 1
|
|
fi
|
|
if [ -n "$digest" ] && [ "$digest" != "$candidate" ]; then
|
|
echo "error: $image produced $candidate, expected $digest" >&2
|
|
exit 1
|
|
fi
|
|
digest="$candidate"
|
|
echo "$image -> $digest"
|
|
done <<< "$IMAGES"
|
|
if [ -z "$digest" ]; then
|
|
echo "error: images is empty" >&2
|
|
exit 1
|
|
fi
|
|
echo "digest=$digest" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Export digest
|
|
shell: bash
|
|
env:
|
|
DIGEST: ${{ steps.build.outputs.digest }}
|
|
run: |
|
|
mkdir -p "$RUNNER_TEMP/digests"
|
|
touch "$RUNNER_TEMP/digests/${DIGEST#sha256:}"
|
|
|
|
- name: Upload digest
|
|
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
|
with:
|
|
name: digests-${{ strategy.job-index }}
|
|
path: ${{ runner.temp }}/digests/*
|
|
if-no-files-found: error
|
|
retention-days: 1
|
|
|
|
merge:
|
|
name: Publish multi-arch manifests
|
|
needs: build
|
|
permissions:
|
|
contents: read
|
|
packages: write
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 15
|
|
steps:
|
|
- name: Harden runner
|
|
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
|
|
with:
|
|
egress-policy: audit
|
|
- name: Download digests
|
|
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
|
with:
|
|
path: ${{ runner.temp }}/digests
|
|
pattern: digests-*
|
|
merge-multiple: true
|
|
|
|
- name: Set up Buildx
|
|
uses: step-security/setup-buildx-action@e50058a0fcd8d1a7d626c905502d010a468bf238 # v4.4.1
|
|
|
|
- name: Log in to GHCR
|
|
if: ${{ contains(inputs.images, 'ghcr.io') }}
|
|
uses: step-security/docker-login-action@8c7b695d8738c5699a679ff5254baf0f19f20a94 # v4.6.0
|
|
with:
|
|
registry: ghcr.io
|
|
username: ${{ github.actor }}
|
|
password: ${{ secrets.GITHUB_TOKEN }}
|
|
|
|
- name: Log in to Docker Hub
|
|
if: ${{ contains(inputs.images, 'docker.io/') }}
|
|
uses: step-security/docker-login-action@8c7b695d8738c5699a679ff5254baf0f19f20a94 # v4.6.0
|
|
with:
|
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
|
|
- name: Docker metadata
|
|
id: meta
|
|
uses: step-security/metadata-action@21378f5b76c96c69ac34d21cc3873d85e26381b1 # v6.2.0
|
|
with:
|
|
images: ${{ inputs.images }}
|
|
tags: ${{ inputs.tags }}
|
|
|
|
- name: Create manifest lists and push
|
|
shell: bash
|
|
working-directory: ${{ runner.temp }}/digests
|
|
env:
|
|
IMAGES: ${{ inputs.images }}
|
|
TAGS: ${{ steps.meta.outputs.tags }}
|
|
run: |
|
|
set -euo pipefail
|
|
# metadata-action emits "<repo>:<tag>" for every image, so each
|
|
# registry only receives the tags that belong to it.
|
|
while IFS= read -r repo; do
|
|
repo="${repo//[[:space:]]/}"
|
|
[ -n "$repo" ] || continue
|
|
tag_args=()
|
|
while IFS= read -r tag; do
|
|
case "$tag" in
|
|
"$repo":*) tag_args+=(--tag "$tag") ;;
|
|
esac
|
|
done <<< "$TAGS"
|
|
if [ "${#tag_args[@]}" -eq 0 ]; then
|
|
echo "no tags for $repo on this ref; skipping"
|
|
continue
|
|
fi
|
|
refs=()
|
|
for digest in *; do
|
|
refs+=("${repo}@sha256:${digest}")
|
|
done
|
|
docker buildx imagetools create "${tag_args[@]}" "${refs[@]}"
|
|
done <<< "$IMAGES"
|