From f1c772329b9b0be29ff07e8f33d3bd1231c0e7bb Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Wed, 11 Nov 2026 17:27:18 -0800 Subject: [PATCH] ci: publish multi-arch (linux/amd64 + linux/arm64) image via push-by-digest + index assembly One job on the trusted x86 runner builds amd64 and arm64 (QEMU), pushes each platform by digest, then scripts/assemble-multiarch-index.sh creates :latest. arm64 is continue-on-error: a failed arm64 leg publishes the usual amd64-only tag. No Dockerfile change (both base images publish arm64). Adds workflow_dispatch and an offline test for the assemble script and workflow wiring. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0185ukXjXyosSkunDsdWz3mb --- .gitea/workflows/build-push.yaml | 45 ++++- scripts/assemble-multiarch-index.sh | 90 ++++++++++ scripts/test-assemble-multiarch-index.py | 214 +++++++++++++++++++++++ 3 files changed, 345 insertions(+), 4 deletions(-) create mode 100755 scripts/assemble-multiarch-index.sh create mode 100644 scripts/test-assemble-multiarch-index.py diff --git a/.gitea/workflows/build-push.yaml b/.gitea/workflows/build-push.yaml index a32e8bb..4fdd181 100644 --- a/.gitea/workflows/build-push.yaml +++ b/.gitea/workflows/build-push.yaml @@ -4,6 +4,9 @@ on: push: branches: - main + # Manual publish (also the safe way to do the first multi-arch publish after + # merging with [skip ci] in the merge title). + workflow_dispatch: jobs: Build-and-Push: @@ -79,12 +82,46 @@ jobs: username: ${{ secrets.CI_USER }} password: ${{ secrets.CI_TOKEN }} - - name: Build Image + # Multi-arch (linux/amd64 + linux/arm64) in ONE job on the trusted x86 + # runner (arm64 under QEMU, set up above). Each platform is pushed BY + # DIGEST (untagged); the :latest tag is created only by + # scripts/assemble-multiarch-index.sh. Template + rules: + # docs/arm64/IMAGE-PIPELINE-PLAN.md sections 4.1 / 4.4 (whp repo). + # + # amd64: required. Failure here fails the job and publishes nothing, as + # before. + - name: Build amd64 (push by digest) + id: amd64 uses: docker/build-push-action@v6 with: platforms: linux/amd64 - push: true build-args: | BASE_IMAGE=${{ steps.base.outputs.image }} - tags: | - repo.anhonesthost.net/cloud-hosting-platform/whp-default:latest \ No newline at end of file + outputs: type=image,"name=repo.anhonesthost.net/cloud-hosting-platform/whp-default",push-by-digest=true,name-canonical=true,push=true + + # arm64: best effort. Must never block or alter the amd64 tag. + - name: Build arm64 (push by digest) + id: arm64 + continue-on-error: true + timeout-minutes: 20 + uses: docker/build-push-action@v6 + with: + platforms: linux/arm64 + build-args: | + BASE_IMAGE=${{ steps.base.outputs.image }} + outputs: type=image,"name=repo.anhonesthost.net/cloud-hosting-platform/whp-default",push-by-digest=true,name-canonical=true,push=true + + # The arm64 digest is read only from THIS run's step output, so a previous + # run's arm64 is never re-attached; a failed arm64 leg publishes :latest + # amd64-only exactly as before multi-arch. + - name: Assemble :latest index + env: + AMD64_DIGEST: ${{ steps.amd64.outputs.digest }} + ARM64_DIGEST: ${{ steps.arm64.outputs.digest }} + ARM64_OUTCOME: ${{ steps.arm64.outcome }} + run: | + scripts/assemble-multiarch-index.sh \ + --amd64 "$AMD64_DIGEST" \ + --arm64 "$ARM64_DIGEST" --arm64-outcome "$ARM64_OUTCOME" \ + --repo repo.anhonesthost.net/cloud-hosting-platform/whp-default \ + --tag latest diff --git a/scripts/assemble-multiarch-index.sh b/scripts/assemble-multiarch-index.sh new file mode 100755 index 0000000..0bcdc29 --- /dev/null +++ b/scripts/assemble-multiarch-index.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# Assemble the multi-arch index tag(s) from per-platform images that were +# already pushed BY DIGEST (no tag). Used by the trusted x86 publish job +# (see .gitea/workflows/build-push-coraza.yaml; template for the other WHP +# image repos, docs/arm64/IMAGE-PIPELINE-PLAN.md section 4.1/4.4). +# +# assemble-multiarch-index.sh \ +# --amd64 sha256: \ +# --arm64 sha256: --arm64-outcome success|failure|cancelled|skipped \ +# --repo [--repo ...] \ +# --tag [--tag ...] [--builder ] +# +# Rules (all enforced here so the workflow stays thin and these are testable): +# * amd64 is mandatory. A missing/malformed amd64 digest, or an amd64 digest +# that does not inspect as linux/amd64, aborts with exit 1 and publishes +# NOTHING (the same "build failed, no tag" outcome as before multi-arch). +# * arm64 is attached ONLY when its step outcome is exactly "success" AND the +# digest is well-formed AND it inspects as linux/arm64. Otherwise the tag is +# published amd64-only (warning, exit 0). The arm64 digest must come from +# THIS run's step output - the workflow never reads it from a registry tag, +# so a previous build's arm64 can never be re-attached to a newer amd64. +# * every --tag is created in every --repo from @ sources in +# that same repo (both digests were pushed to every --repo by the build). +# +# Env: IMAGETOOLS overrides the command (default: "docker buildx imagetools"); +# the test suite points it at a stub. +set -euo pipefail + +IMAGETOOLS=${IMAGETOOLS:-docker buildx imagetools} +amd64=""; arm64=""; outcome="skipped"; builder="" +repos=(); tags=() + +die() { echo "assemble-multiarch-index: $*" >&2; exit 1; } +warn() { echo "::warning::assemble-multiarch-index: $*"; } + +while [ $# -gt 0 ]; do + case "$1" in + --amd64) amd64=${2-}; shift 2 ;; + --arm64) arm64=${2-}; shift 2 ;; + --arm64-outcome) outcome=${2-}; shift 2 ;; + --repo) repos+=("${2-}"); shift 2 ;; + --tag) tags+=("${2-}"); shift 2 ;; + --builder) builder=${2-}; shift 2 ;; + *) die "unknown argument: $1" ;; + esac +done + +re='^sha256:[0-9a-f]{64}$' +[ ${#repos[@]} -gt 0 ] || die "need at least one --repo" +[ ${#tags[@]} -gt 0 ] || die "need at least one --tag" +[[ $amd64 =~ $re ]] || die "amd64 digest missing or malformed ('$amd64'); refusing to publish" + +bflag=(); [ -n "$builder" ] && bflag=(--builder "$builder") + +# arch_of : prints the image config's architecture ("" on any failure). +arch_of() { + # shellcheck disable=SC2086 + $IMAGETOOLS inspect "${bflag[@]}" "$1" --format '{{json .Image}}' 2>/dev/null \ + | python3 -I -c 'import json,sys; print(json.load(sys.stdin).get("architecture",""))' 2>/dev/null || true +} + +for r in "${repos[@]}"; do + [ "$(arch_of "$r@$amd64")" = amd64 ] || die "$r@$amd64 does not inspect as linux/amd64; refusing to publish" +done + +use_arm64=0 +if [ "$outcome" != success ]; then + warn "arm64 step outcome is '$outcome': publishing amd64-only (no arm64 digest is carried over)" +elif ! [[ $arm64 =~ $re ]]; then + warn "arm64 step reported success but its digest is missing/malformed: publishing amd64-only" +elif [ "$arm64" = "$amd64" ]; then + warn "arm64 digest equals the amd64 digest: publishing amd64-only" +else + use_arm64=1 + for r in "${repos[@]}"; do + if [ "$(arch_of "$r@$arm64")" != arm64 ]; then + warn "$r@$arm64 does not inspect as linux/arm64: publishing amd64-only" + use_arm64=0; break + fi + done +fi + +for r in "${repos[@]}"; do + targs=(); for t in "${tags[@]}"; do targs+=(-t "$r:$t"); done + srcs=("$r@$amd64"); [ "$use_arm64" = 1 ] && srcs+=("$r@$arm64") + echo "assemble-multiarch-index: $r tags=${tags[*]} sources=${#srcs[@]} ($([ "$use_arm64" = 1 ] && echo 'amd64+arm64' || echo 'amd64-only'))" + # shellcheck disable=SC2086 + $IMAGETOOLS create "${bflag[@]}" "${targs[@]}" "${srcs[@]}" +done +echo "platforms=$([ "$use_arm64" = 1 ] && echo linux/amd64,linux/arm64 || echo linux/amd64)" diff --git a/scripts/test-assemble-multiarch-index.py b/scripts/test-assemble-multiarch-index.py new file mode 100644 index 0000000..a65985b --- /dev/null +++ b/scripts/test-assemble-multiarch-index.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +"""Offline tests for scripts/assemble-multiarch-index.sh (multi-arch index assembly). + +The script is the publish-side half of the multi-arch pipeline: it receives the +amd64 and (optional) arm64 digests that the build steps pushed BY DIGEST and +turns them into the tag(s). A stub stands in for `docker buildx imagetools` +(env IMAGETOOLS), records every `create` call and answers `inspect` from a +table, so no registry or Docker is needed. + +Held invariants: + - arm64 failure / cancel / skip -> amd64-only create, exit 0 (tag still published) + - a STALE arm64 digest is never attached unless this run's arm64 outcome is success + - bad/missing amd64 digest, or amd64 that is not linux/amd64 -> exit 1, NO create + - an arm64 digest that does not inspect as arm64 is dropped (amd64-only) + - every tag x every repo is created; sources are @ in that repo + +Running: python3 scripts/test-assemble-multiarch-index.py +""" +import json +import os +import stat +import subprocess +import sys +import tempfile +import unittest + +HERE = os.path.dirname(os.path.abspath(__file__)) +SCRIPT = os.path.join(HERE, 'assemble-multiarch-index.sh') +A = 'sha256:' + 'a' * 64 +B = 'sha256:' + 'b' * 64 +C = 'sha256:' + 'c' * 64 + +STUB = r'''#!/usr/bin/env python3 +import json, os, sys +a = sys.argv[1:] +log = os.environ['STUB_LOG'] +table = json.loads(os.environ['STUB_ARCH']) +if a[0] == 'inspect': + ref = [x for x in a[1:] if '@sha256:' in x][0] + arch = table.get(ref.split('@', 1)[1]) + if arch is None: + sys.exit(1) + print(json.dumps({'architecture': arch, 'os': 'linux'})) +elif a[0] == 'create': + open(log, 'a').write(json.dumps(a[1:]) + '\n') +else: + sys.exit(2) +''' + + +class AssembleTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.stub = os.path.join(self.tmp.name, 'stub') + with open(self.stub, 'w') as f: + f.write(STUB) + os.chmod(self.stub, os.stat(self.stub).st_mode | stat.S_IEXEC) + self.log = os.path.join(self.tmp.name, 'creates.log') + + def tearDown(self): + self.tmp.cleanup() + + def run_script(self, args, arch=None): + env = dict(os.environ, IMAGETOOLS=self.stub, STUB_LOG=self.log, + STUB_ARCH=json.dumps(arch if arch is not None else {A: 'amd64', B: 'arm64'})) + p = subprocess.run(['bash', SCRIPT] + args, env=env, capture_output=True, text=True) + creates = [] + if os.path.exists(self.log): + creates = [json.loads(l) for l in open(self.log)] + return p, creates + + BASE = ['--repo', 'r.example/o/img', '--tag', 'latest'] + + def test_both_platforms(self): + p, c = self.run_script(['--amd64', A, '--arm64', B, '--arm64-outcome', 'success'] + self.BASE) + self.assertEqual(p.returncode, 0, p.stderr) + self.assertEqual(c, [['-t', 'r.example/o/img:latest', 'r.example/o/img@' + A, 'r.example/o/img@' + B]]) + self.assertIn('platforms=linux/amd64,linux/arm64', p.stdout) + + def test_arm64_failure_is_amd64_only_even_with_a_digest(self): + # A stale digest (e.g. left over from an earlier run) must not be attached. + for outcome in ('failure', 'cancelled', 'skipped', ''): + if os.path.exists(self.log): + os.remove(self.log) + p, c = self.run_script(['--amd64', A, '--arm64', B, '--arm64-outcome', outcome] + self.BASE) + self.assertEqual(p.returncode, 0, p.stderr) + self.assertEqual(c, [['-t', 'r.example/o/img:latest', 'r.example/o/img@' + A]], outcome) + self.assertIn('::warning::', p.stdout) + self.assertIn('platforms=linux/amd64\n', p.stdout) + + def test_no_arm64_args_is_amd64_only(self): + p, c = self.run_script(['--amd64', A] + self.BASE) + self.assertEqual(p.returncode, 0, p.stderr) + self.assertEqual(c, [['-t', 'r.example/o/img:latest', 'r.example/o/img@' + A]]) + + def test_success_with_empty_or_malformed_arm64_digest(self): + for bad in ('', 'sha256:short', 'latest'): + if os.path.exists(self.log): + os.remove(self.log) + p, c = self.run_script(['--amd64', A, '--arm64', bad, '--arm64-outcome', 'success'] + self.BASE) + self.assertEqual(p.returncode, 0, p.stderr) + self.assertEqual(len(c[0]), 3, bad) # -t, ref, one source + + def test_arm64_digest_that_is_not_arm64_is_dropped(self): + p, c = self.run_script(['--amd64', A, '--arm64', C, '--arm64-outcome', 'success'] + self.BASE, + arch={A: 'amd64', C: 'amd64'}) + self.assertEqual(p.returncode, 0, p.stderr) + self.assertEqual(c, [['-t', 'r.example/o/img:latest', 'r.example/o/img@' + A]]) + + def test_arm64_equal_to_amd64_is_dropped(self): + p, c = self.run_script(['--amd64', A, '--arm64', A, '--arm64-outcome', 'success'] + self.BASE) + self.assertEqual(p.returncode, 0, p.stderr) + self.assertEqual(len(c[0]), 3) + + def test_amd64_problems_publish_nothing(self): + for amd in ('', 'nope', B): # B inspects as arm64 + p, c = self.run_script(['--amd64', amd, '--arm64', B, '--arm64-outcome', 'success'] + self.BASE) + self.assertNotEqual(p.returncode, 0, amd) + self.assertEqual(c, [], amd) + p, c = self.run_script(['--amd64', C, '--arm64-outcome', 'failure'] + self.BASE, arch={}) + self.assertNotEqual(p.returncode, 0) # not inspectable + self.assertEqual(c, []) + + def test_all_tags_in_all_repos(self): + p, c = self.run_script(['--amd64', A, '--arm64', B, '--arm64-outcome', 'success', + '--repo', 'gitea.example/o/img', '--repo', 'ghcr.io/o/img', + '--tag', 'latest', '--tag', '1.2.3', '--tag', 'abc123']) + self.assertEqual(p.returncode, 0, p.stderr) + self.assertEqual(len(c), 2) + self.assertEqual(c[0], ['-t', 'gitea.example/o/img:latest', '-t', 'gitea.example/o/img:1.2.3', + '-t', 'gitea.example/o/img:abc123', + 'gitea.example/o/img@' + A, 'gitea.example/o/img@' + B]) + self.assertEqual(c[1][-2:], ['ghcr.io/o/img@' + A, 'ghcr.io/o/img@' + B]) + + def test_requires_repo_and_tag_and_rejects_unknown_args(self): + for args in (['--amd64', A, '--tag', 't'], ['--amd64', A, '--repo', 'r'], ['--bogus', 'x']): + p, c = self.run_script(args) + self.assertNotEqual(p.returncode, 0) + self.assertEqual(c, []) + + +class WorkflowWiring(unittest.TestCase): + """build-push.yaml must keep the properties the assemble script relies on, + and must not have changed what the amd64 build does.""" + + IMG = 'repo.anhonesthost.net/cloud-hosting-platform/whp-default' + + def setUp(self): + self.wf = open(os.path.join(HERE, '..', '.gitea', 'workflows', 'build-push.yaml')).read() + + def step(self, name): + parts = self.wf.split(' - name: ') + for p in parts: + if p.startswith(name): + return p + self.fail('step not found: ' + name) + + def test_triggers_unchanged_plus_manual_dispatch(self): + on = self.wf.split('\njobs:')[0] + self.assertIn('branches:\n - main\n', on) + self.assertIn('workflow_dispatch:', on) + self.assertNotIn('schedule:', on) + self.assertNotIn('pull_request', on) + + def test_arm64_step_is_non_fatal_and_digest_comes_from_step_output(self): + arm = self.step('Build arm64') + self.assertIn('continue-on-error: true', arm) + self.assertNotIn('continue-on-error', self.step('Build amd64')) + self.assertIn('steps.arm64.outputs.digest', self.wf) + self.assertIn('steps.arm64.outcome', self.wf) + self.assertIn('steps.amd64.outputs.digest', self.wf) + + def test_each_build_is_one_platform_pushed_by_digest_untagged(self): + a, b = self.step('Build amd64'), self.step('Build arm64') + self.assertIn('platforms: linux/amd64\n', a) + self.assertIn('platforms: linux/arm64\n', b) + for s in (a, b): + self.assertIn('push-by-digest=true', s) + self.assertIn(self.IMG, s) + self.assertNotIn('tags:', s) + self.assertNotRegex(s, r'\n\s+push: true') + self.assertEqual(self.wf.count('push-by-digest=true'), 2) + + def test_build_args_identical_for_both_platforms_and_keep_base_pin(self): + def args(s): + return s.split('build-args: |\n')[1].split('outputs:')[0] + a, b = args(self.step('Build amd64')), args(self.step('Build arm64')) + self.assertEqual(a, b) + self.assertIn('BASE_IMAGE=${{ steps.base.outputs.image }}', a) + + + def test_assemble_step_uses_script_for_latest_in_the_existing_registry_only(self): + asm = self.step('Assemble') + self.assertIn('scripts/assemble-multiarch-index.sh', asm) + self.assertIn('--repo ' + self.IMG, asm) + self.assertEqual(asm.count('--repo '), 1) # unchanged: Gitea only, no new registry + self.assertIn('--tag latest', asm) + self.assertEqual(asm.count('--tag '), 1) + + def test_qemu_and_registry_login_and_base_resolution_retained(self): + for needle in ('docker/setup-qemu-action', 'Login to Gitea', 'secrets.CI_USER', + 'secrets.CI_TOKEN', 'Resolve base image digest', 'Probe package cache'): + self.assertIn(needle, self.wf) + self.assertNotIn('ghcr.io', self.wf) # no new registry/secret introduced + self.assertNotIn('GHCR_TOKEN', self.wf) + + def test_assemble_step_is_last_and_after_both_builds(self): + i = [self.wf.index(n) for n in ('Build amd64', 'Build arm64', 'Assemble :latest')] + self.assertEqual(i, sorted(i)) + self.assertEqual(self.wf.rstrip().split('--tag latest')[-1], '') + + +if __name__ == '__main__': + unittest.main(verbosity=2)