Skip to content

🐬 Builds ArchLinux Images for DockerHub 🐬 #568

🐬 Builds ArchLinux Images for DockerHub 🐬

🐬 Builds ArchLinux Images for DockerHub 🐬 #568

Workflow file for this run

name: 🐬 Builds ArchLinux Images for DockerHub 🐬
#REF: https://docs.docker.com/build/ci/github-actions/multi-platform/
#SECRETS: DOCKERHUB_USERNAME || DOCKERHUB_TOKEN
#URL: https://hub.docker.com/r/pkgforge/archlinux/tags
#For: https://github.com/pkgforge/devscripts/tree/main/Github/Runners
#------------------------------------------------------------------------------------#
# One job per architecture builds and pushes by digest. No tag is created there.
# A merge job with needs: over the whole matrix creates every tag from those
# digests, so a run that lost one architecture publishes nothing at all.
#
# Run it with dry_run to build against a scratch repository and create no public
# tag. That is how a branch is tested without a consumer seeing anything.
#------------------------------------------------------------------------------------#
on:
workflow_dispatch:
inputs:
dry_run:
description: Push to a scratch repository and create no public tag
type: boolean
default: false
dry_run_hub:
description: With dry_run, also copy to the Docker Hub scratch repository
type: boolean
default: false
# ⛔ The rollback guard's only way through. A stale but validly signed
# package set is what an on-path attacker on a plain http ARM mirror can
# serve, and signatures do not catch it. A legitimate upstream revert
# exists too, so the override is a named, deliberate act rather than a
# silent pass. HISTORY/arm-rollback.md.
allow_anchor_downgrade:
description: Publish even though an anchor version went backwards
type: boolean
default: false
schedule:
- cron: "30 05 * * *" # 08:30 PM UTC everyday
defaults:
run:
shell: bash
permissions:
contents: read
# A superseded scheduled run is cancelled rather than racing the newer one.
concurrency:
group: build-deploy-${{ github.ref }}
cancel-in-progress: true
env:
GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/archlinux
HUB_IMAGE: pkgforge/archlinux
SCRATCH_IMAGE: ghcr.io/${{ github.repository_owner }}/archlinux-ci
# The Docker Hub side of a dry run. Copying across registries is the one part
# of the publish that a GHCR-only dry run cannot reach, so it needs a scratch
# repository of its own on the other registry.
HUB_SCRATCH_IMAGE: pkgforge/archlinux-ci
#------------------------------------------------------------------------------------#
jobs:
#----------------------------------------------------------------------------------#
# Resolve every input once. Each architecture then builds against the same
# version, the same commit and an anchor pinned before any build started, so a
# run is reproducible as a unit and not only job by job.
#----------------------------------------------------------------------------------#
resolve:
name: Resolve inputs
runs-on: ubuntu-latest
outputs:
version: ${{ steps.inputs.outputs.version }}
build_date: ${{ steps.inputs.outputs.build_date }}
anchors: ${{ steps.inputs.outputs.anchors }}
target_image: ${{ steps.inputs.outputs.target_image }}
hub_image: ${{ steps.inputs.outputs.hub_image }}
publish_hub: ${{ steps.inputs.outputs.publish_hub }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Resolve
id: inputs
env:
DRY_RUN: ${{ inputs.dry_run }}
DRY_RUN_HUB: ${{ inputs.dry_run_hub }}
run: |
set -euo pipefail
version="$(date -u +'%Y.%m.%d')"
build_date="$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
# ⛔ dry_run_hub only makes sense inside a dry run. Without this guard
# a run with dry_run_hub alone would take the real branch and read as
# though it had tested something.
if [ "${DRY_RUN_HUB:-false}" = "true" ] && [ "${DRY_RUN:-false}" != "true" ]; then
echo "dry_run_hub needs dry_run. On its own it would publish to the real repositories." >&2
exit 1
fi
if [ "${DRY_RUN:-false}" = "true" ]; then
target_image="$SCRATCH_IMAGE"
hub_image="$HUB_SCRATCH_IMAGE"
if [ "${DRY_RUN_HUB:-false}" = "true" ]; then
publish_hub=true
echo "::notice::dry run into ${SCRATCH_IMAGE} and ${HUB_SCRATCH_IMAGE}, exercising the cross-registry copy, creating no public tag"
else
publish_hub=false
echo "::notice::dry run, building into ${SCRATCH_IMAGE} and creating no public tag"
fi
else
target_image="$GHCR_IMAGE"
hub_image="$HUB_IMAGE"
publish_hub=true
fi
{
echo "version=$version"
echo "build_date=$build_date"
echo "target_image=$target_image"
echo "hub_image=$hub_image"
echo "publish_hub=$publish_hub"
} >> "$GITHUB_OUTPUT"
# The anchor names the pinned tag family. The ports are not in
# lockstep, so it is resolved per architecture.
anchors='{'
sep=''
for arch in amd64 arm64 armv7 loong64 riscv64 ppc ppc64 ppc64le; do
anchor="$(scripts/resolve-anchor "$arch")"
anchors="${anchors}${sep}\"${arch}\":\"${anchor}\""
sep=','
echo "anchor for $arch: $anchor"
done
anchors="${anchors}}"
jq -e . <<< "$anchors" > /dev/null
echo "anchors=$anchors" >> "$GITHUB_OUTPUT"
# ⛔ A build whose anchor went backwards is refused here, before anything
# is built or pushed. 20 of the 46 shipped mirrors are plain http, all on
# the two ARM ports, because the mirror Arch Linux ARM recommends offers
# no https. An on-path attacker there cannot forge a package, and can
# serve a stale but validly signed set. Signatures do not catch that.
#
# The floor is what this repository already published, read from the
# public tag list with no token and no state file. FLOOR_IMAGE is the
# real repository even in a dry run: the floor is a fact about the
# packages, not about where this run intends to push.
- name: Refuse a build whose anchor went backwards
env:
ANCHORS: ${{ steps.inputs.outputs.anchors }}
FLOOR_IMAGE: ${{ env.GHCR_IMAGE }}
ALLOW_ANCHOR_DOWNGRADE: ${{ inputs.allow_anchor_downgrade && '1' || '0' }}
CONTAINER_RUNTIME: docker
run: scripts/check-anchor-floor "$ANCHORS"
- name: Check the repository before building it
run: tests/run.sh static
#----------------------------------------------------------------------------------#
# Build each architecture and push it by digest. No tag is created here.
#
# The Dockerfile runs pacman on the builder's own architecture and only the
# last few steps are emulated, so QEMU is set up but carries little of the
# work. Cache is keyed per architecture: one shared key thrashes.
#
# There is no runner debloat step. Each job now builds one architecture rather
# than all of them in one job, so the disk pressure that step existed for is
# much lower. ⚠ Runner disk use has not been measured on this workflow.
#----------------------------------------------------------------------------------#
build:
name: Build ${{ matrix.docker_arch }}
needs: resolve
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
strategy:
# One run reports every broken architecture. The merge job still refuses
# unless all of them passed.
fail-fast: false
matrix:
include:
- docker_arch: amd64
platform: linux/amd64
runner: ubuntu-latest
# Set the ARM64_RUNNER repository variable to a native arm64 label to
# take this target out of emulation. ⚠ Whether this repository has
# access to those runners is unverified, so the default keeps QEMU.
- docker_arch: arm64
platform: linux/arm64
runner: ${{ vars.ARM64_RUNNER || 'ubuntu-latest' }}
- docker_arch: armv7
platform: linux/arm/v7
runner: ubuntu-latest
# The loong64 port is published by the Linux Club of Peking
# University, not by Arch, so it carries its own keyring. Both
# feasibility gates are measured in HISTORY/loong64.md.
- docker_arch: loong64
platform: linux/loong64
runner: ubuntu-latest
- docker_arch: riscv64
platform: linux/riscv64
runner: ubuntu-latest
# The three PowerPC ports are published by ArchPOWER, which carries
# its own keyring and lays its repositories out differently from
# every other port: the core equivalent is named base, there is no
# extra, and `any` architecture packages are in a second database.
# HISTORY/powerpc.md has the measurements.
- docker_arch: ppc
platform: linux/ppc
runner: ubuntu-latest
- docker_arch: ppc64
platform: linux/ppc64
runner: ubuntu-latest
- docker_arch: ppc64le
platform: linux/ppc64le
runner: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up QEMU
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
# ⛔ The action does not cover big endian PowerPC, and nothing says so.
# tonistiigi/binfmt, which it runs, ships nine emulators: aarch64, arm,
# i386, loongarch64, mips64, mips64el, ppc64le, riscv64 and s390x.
# qemu-ppc and qemu-ppc64 are not among them. Measured 2026-08-28 by
# listing the image's own filesystem.
#
# The last stage of the Dockerfile runs pacman-key and locale-gen as
# target architecture binaries, so without a handler those two jobs fail
# at "exec format error" partway through a build that already downloaded
# every package.
#
# Ubuntu's own qemu-user-static ships both. update-binfmts imports the
# handler the package describes rather than a magic string written here.
- name: Register the big endian PowerPC emulators
if: matrix.docker_arch == 'ppc' || matrix.docker_arch == 'ppc64'
env:
DOCKER_ARCH: ${{ matrix.docker_arch }}
run: |
set -euo pipefail
handler="qemu-${DOCKER_ARCH}"
# ⛔ Not apt. Ubuntu's qemu-user-static ships the emulators and no
# binfmt descriptions at all: /usr/share/binfmts holds one entry,
# python3.13, and update-binfmts --import qemu-ppc answers "couldn't
# find information about 'qemu-ppc' to import" and exits 2. Measured
# 2026-08-28 on this workflow and in a Debian trixie container.
#
# This image registers every emulator it ships, with the F flag, which
# is the thing that has to be true. Pinned by digest like every other
# image here, and scripts/check-image-pins reads the marker.
# tag: latest
docker run --rm --privileged \
docker.io/multiarch/qemu-user-static@sha256:fe60359c92e86a43cc87b3d906006245f77bfc0565676b80004cc666e4feb9f0 \
--reset -p yes
# ⛔ Asserted, not assumed. A registration that did nothing leaves the
# build to fail much later, inside a buildx step, with a message about
# exec format that names no architecture.
reg="/proc/sys/fs/binfmt_misc/${handler}"
if [ ! -f "$reg" ]; then
echo "${handler} is not registered after the binfmt image ran" >&2
echo "registered handlers:" >&2
ls /proc/sys/fs/binfmt_misc >&2
exit 1
fi
cat "$reg"
# ⚠ The F flag is what makes the handler usable from inside a
# container. Without it the kernel opens the interpreter in the
# container's own filesystem, where the emulator does not exist, and
# buildkit's target stage cannot run a single target binary.
if ! awk '/^flags:/ && $2 ~ /F/ { found = 1 } END { exit !found }' "$reg"; then
echo "${handler} is registered without the F flag" >&2
echo "the emulator would have to exist inside the build container, and it does not" >&2
exit 1
fi
echo "::notice::${handler} registered with the F flag"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Login to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: ${{ github.workspace }}
platforms: ${{ matrix.platform }}
build-args: |
IMAGE_VERSION=${{ needs.resolve.outputs.version }}
SOURCE_COMMIT=${{ github.sha }}
BUILD_DATE=${{ needs.resolve.outputs.build_date }}
outputs: type=image,name=${{ needs.resolve.outputs.target_image }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=${{ matrix.docker_arch }}
cache-to: type=gha,mode=max,scope=${{ matrix.docker_arch }}
provenance: mode=max
sbom: true
# ⛔ The databases the build resolved against, taken from the build rather
# than fetched again. A package superseded upstream between the two is
# installed in the image and absent from every current database, so the
# evidence has a hole and the build fails. Scheduled run 33094128354 lost
# amd64 to exactly that: its build step began at 16:37:48Z and the second
# read of core.db landed at 16:38:51Z, 63 seconds of window, and
# ca-certificates-mozilla moved from 3.127-1 to 3.128-1 inside it.
#
# The bootstrap stage is already in the builder from the step above, so
# this exports files rather than installing anything again. A cache miss
# would resolve against newer databases, and gen-evidence still names any
# package it cannot account for, so the check is unchanged either way.
# HISTORY/evidence-race.md.
- name: Export the databases the build resolved against
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: ${{ github.workspace }}
platforms: ${{ matrix.platform }}
target: dbsnapshot
build-args: |
IMAGE_VERSION=${{ needs.resolve.outputs.version }}
outputs: type=local,dest=/tmp/dbsnapshot
cache-from: type=gha,scope=${{ matrix.docker_arch }}
- name: Record the evidence for what was pushed
env:
IMAGE: ${{ needs.resolve.outputs.target_image }}@${{ steps.build.outputs.digest }}
PLATFORM: ${{ matrix.platform }}
DOCKER_ARCH: ${{ matrix.docker_arch }}
CONTAINER_RUNTIME: docker
SOURCE_COMMIT: ${{ github.sha }}
BUILD_DATE: ${{ needs.resolve.outputs.build_date }}
# Read where the build left them. gen-evidence refuses to start when
# this directory is missing a database for an enabled repository, so
# an export that silently produced nothing cannot pass as a fetch.
DB_SNAPSHOT: /tmp/dbsnapshot
run: |
set -euo pipefail
# --platform is required. provenance and sbom wrap the pushed
# manifest in an index, so a bare pull selects the runner's own
# architecture and fails with "no matching manifest for linux/amd64".
docker pull --platform "$PLATFORM" "$IMAGE"
mkdir -p /tmp/evidence
# Which package, which version, which size, which checksum, and when
# it was built. A record saying the build succeeded is not evidence.
scripts/gen-evidence "$DOCKER_ARCH" "$IMAGE" "$PLATFORM" \
"/tmp/evidence/evidence-${DOCKER_ARCH}.json"
- name: Test what was actually pushed
env:
IMAGE: ${{ needs.resolve.outputs.target_image }}@${{ steps.build.outputs.digest }}
PLATFORM: ${{ matrix.platform }}
CONTAINER_RUNTIME: docker
EVIDENCE: /tmp/evidence/evidence-${{ matrix.docker_arch }}.json
run: |
set -euo pipefail
# The image suite inspects without starting, so an architecture whose
# bootstrap installed nothing still fails with a readable assertion
# rather than a runc error.
tests/run.sh image
- name: Upload the evidence
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: evidence-${{ matrix.docker_arch }}
path: /tmp/evidence/evidence-${{ matrix.docker_arch }}.json
if-no-files-found: error
retention-days: 90
- name: Export the digest
env:
DIGEST: ${{ steps.build.outputs.digest }}
run: |
set -euo pipefail
if [ -z "$DIGEST" ]; then
echo "the build produced no digest" >&2
exit 1
fi
mkdir -p /tmp/digests
printf '%s' "$DIGEST" > "/tmp/digests/${{ matrix.docker_arch }}"
- name: Upload the digest
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: digest-${{ matrix.docker_arch }}
path: /tmp/digests/${{ matrix.docker_arch }}
if-no-files-found: error
retention-days: 1
#----------------------------------------------------------------------------------#
# Create every tag from the digests the matrix produced.
#
# needs: over the whole matrix is what makes a partial failure publish nothing.
# There is no if: always() here on purpose: one architecture failing means no
# tag moves, rather than an index with a hole in it.
#
# The per architecture tags are created first and the index tags last, so
# :latest only moves after everything else has succeeded.
#----------------------------------------------------------------------------------#
publish:
name: Create tags
needs: [resolve, build]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Download the digests
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: /tmp/digests
pattern: digest-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Login to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
if: needs.resolve.outputs.publish_hub == 'true'
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create the per architecture tags
env:
TARGET_IMAGE: ${{ needs.resolve.outputs.target_image }}
HUB_TARGET: ${{ needs.resolve.outputs.hub_image }}
PUBLISH_HUB: ${{ needs.resolve.outputs.publish_hub }}
VERSION: ${{ needs.resolve.outputs.version }}
ANCHORS: ${{ needs.resolve.outputs.anchors }}
run: |
set -euo pipefail
images=("$TARGET_IMAGE")
if [ "$PUBLISH_HUB" = "true" ]; then
images+=("$HUB_TARGET")
fi
for arch in amd64 arm64 armv7 loong64 riscv64 ppc ppc64 ppc64le; do
digest_file="/tmp/digests/$arch"
if [ ! -s "$digest_file" ]; then
echo "no digest for $arch, refusing to tag a partial release" >&2
exit 1
fi
digest="$(cat "$digest_file")"
anchor="$(jq -er --arg a "$arch" '.[$a]' <<< "$ANCHORS")"
mapfile -t tags < <(scripts/tag-names arch "$arch" "$VERSION" "$anchor" "${images[@]}")
args=()
for t in "${tags[@]}"; do
args+=(-t "$t")
done
echo "==> $arch $digest"
printf ' %s\n' "${tags[@]}"
docker buildx imagetools create "${args[@]}" "$TARGET_IMAGE@$digest"
done
- name: Create the index tags
env:
TARGET_IMAGE: ${{ needs.resolve.outputs.target_image }}
HUB_TARGET: ${{ needs.resolve.outputs.hub_image }}
PUBLISH_HUB: ${{ needs.resolve.outputs.publish_hub }}
VERSION: ${{ needs.resolve.outputs.version }}
run: |
set -euo pipefail
images=("$TARGET_IMAGE")
if [ "$PUBLISH_HUB" = "true" ]; then
images+=("$HUB_TARGET")
fi
sources=()
for arch in amd64 arm64 armv7 loong64 riscv64 ppc ppc64 ppc64le; do
sources+=("$TARGET_IMAGE@$(cat "/tmp/digests/$arch")")
done
mapfile -t tags < <(scripts/tag-names index "$VERSION" "${images[@]}")
args=()
for t in "${tags[@]}"; do
args+=(-t "$t")
done
printf ' %s\n' "${tags[@]}"
docker buildx imagetools create "${args[@]}" "${sources[@]}"
- name: Verify what was published
env:
TARGET_IMAGE: ${{ needs.resolve.outputs.target_image }}
HUB_TARGET: ${{ needs.resolve.outputs.hub_image }}
PUBLISH_HUB: ${{ needs.resolve.outputs.publish_hub }}
VERSION: ${{ needs.resolve.outputs.version }}
run: |
set -euo pipefail
# ⛔ Both registries are verified, not just the one the digests were
# staged on. Copying across registries is a separate operation that can
# fail on its own, and until this step covered it a Docker Hub tag that
# never arrived, or arrived without every platform, ended a green run.
verify_index() {
local image="$1" dated got expect
# The dated index tag carries a leading v. scripts/tag-names is the
# authority on the spelling, so it is asked rather than
# reconstructed: inspecting a tag the publish job never created
# would fail every run.
dated="$(scripts/tag-names index "$VERSION" "$image" | grep -v ':latest$')"
echo "verifying $dated"
# The index must carry every platform the matrix built. An index with
# a hole in it is the failure this whole topology exists to prevent.
got="$(docker buildx imagetools inspect "$dated" --raw \
| jq -r '[ .manifests[].platform
| select(.os != "unknown")
| .architecture + (if .variant then "/" + .variant else "" end) ]
| sort | join(" ")')"
echo "index platforms: $got"
# armv7 renders as arm/v7. A bare match on arm would also match
# arm64, so the assertion would pass with armv7 missing and mean
# nothing.
for expect in amd64 arm64 loong64 riscv64 ppc ppc64 ppc64le arm/v7; do
if ! grep -qw -- "$expect" <<< "$got"; then
echo "the index for $dated is missing $expect" >&2
echo "it carries: $got" >&2
echo "the digests were staged on ${TARGET_IMAGE}. If that index is" >&2
echo "complete and this one is not, the cross-registry copy is what failed." >&2
exit 1
fi
done
echo "::notice::published $dated with platforms $got"
}
verify_index "$TARGET_IMAGE"
if [ "$PUBLISH_HUB" = "true" ]; then
verify_index "$HUB_TARGET"
else
echo "::notice::publish_hub is false, so no Docker Hub tag was created or verified"
fi