ci: multi-arch (amd64+arm64) publish for whp-default #2

Merged
jknapp merged 1 commits from feat/multiarch-publish into main 2026-11-12 02:02:59 +00:00
3 changed files with 345 additions and 4 deletions

No files matched your search

+41 -4
View File
@@ -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
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
+90
View File
@@ -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:<digest> \
# --arm64 sha256:<digest> --arm64-outcome success|failure|cancelled|skipped \
# --repo <registry/path> [--repo <other registry/path> ...] \
# --tag <tag> [--tag <tag> ...] [--builder <buildx 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 <repo>@<digest> 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 <ref>: 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)"
+214
View File
@@ -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 <repo>@<digest> 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)