From 01741e735aa36c2f2f0fbce9420f4c5016a8756a Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Thu, 12 Nov 2026 06:13:02 -0800 Subject: [PATCH] ci: publish cac, cac-fpm and shared-httpd multi-arch (amd64 + arm64) One job per matrix leg on the trusted x86 runner: amd64 built natively, arm64 under QEMU, each pushed BY DIGEST (untagged), then scripts/assemble-multiarch-index.sh creates the existing tags (:phpNN, :latest for php85 / shared-httpd :latest) from this run's digests. The arm64 step is continue-on-error with its own timeout, so an arm64 failure publishes the usual amd64-only tag and a stale arm64 is never re-attached. Build args, base-image pin, pkg-cache probe, php.net EXPECT_PHP_MIN gate, secrets and the cac-litespeed/cac-lsphp/shared-ols jobs are untouched. No Dockerfile change. Adds scripts/tests/multiarch-workflow.test.py (static wiring + tag-scheme execution) to Shell-Checks, and fixes index-platforms.test.sh line 17 which tripped lsphp-info-probe.test.sh's early-exit-reader scan on trunk. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0185ukXjXyosSkunDsdWz3mb --- .gitea/workflows/build-push.yaml | 158 +++++++++++++++++++-- scripts/tests/index-platforms.test.sh | 2 +- scripts/tests/multiarch-workflow.test.py | 173 +++++++++++++++++++++++ 3 files changed, 318 insertions(+), 15 deletions(-) create mode 100755 scripts/tests/multiarch-workflow.test.py diff --git a/.gitea/workflows/build-push.yaml b/.gitea/workflows/build-push.yaml index 8f76e58..64adaba 100644 --- a/.gitea/workflows/build-push.yaml +++ b/.gitea/workflows/build-push.yaml @@ -76,6 +76,11 @@ jobs: ./scripts/tests/image-label-amd64.test.sh ./scripts/tests/index-platforms.test.sh + # Static wiring of the multi-arch publish jobs (cac, cac-fpm, shared-httpd): + # by-digest pushes, non-fatal arm64 leg, timeouts, exact tag scheme. + - name: multi-arch workflow wiring test + run: python3 -I scripts/tests/multiarch-workflow.test.py + - name: lsphp probe regression test run: ./scripts/tests/lsphp-info-probe.test.sh @@ -92,6 +97,7 @@ jobs: Build-and-Push: runs-on: ubuntu-latest + timeout-minutes: 75 strategy: # One PHP version failing (e.g. an EOL stream) must not cancel the # security builds of the others. @@ -143,6 +149,8 @@ jobs: - name: Set up QEMU uses: docker/setup-qemu-action@v3 + with: + platforms: arm64 # BuildKit (docker-container driver) ignores dockerd's registry-mirrors, # so the mirror is configured on the builder; BuildKit itself still falls @@ -197,23 +205,64 @@ jobs: username: ${{ secrets.CI_USER }} password: ${{ secrets.CI_TOKEN }} - - name: Build and Push Image + # amd64: required. Pushed untagged BY DIGEST (no tag); failure fails the + # job and publishes nothing, exactly as before multi-arch. The tags are only + # created by the assemble step below. + - name: Build amd64 (push by digest) + id: amd64 uses: docker/build-push-action@v6 with: platforms: linux/amd64 - push: true build-args: | PHPVER=${{ matrix.phpver }} CACHE_BUST=${{ steps.bust.outputs.value }} BASE_IMAGE=${{ steps.base.outputs.image }} PKG_CACHE=${{ steps.pkgcache.outputs.url }} EXPECT_PHP_MIN=${{ steps.phpmin.outputs.min }} - tags: | - repo.anhonesthost.net/cloud-hosting-platform/cac:php${{ matrix.phpver }} - ${{ matrix.phpver == '85' && 'repo.anhonesthost.net/cloud-hosting-platform/cac:latest' || '' }} + outputs: type=image,name=repo.anhonesthost.net/cloud-hosting-platform/cac,push-by-digest=true,name-canonical=true,push=true + + # arm64: best effort, built under QEMU on this same trusted x86 runner (no + # secret ever leaves it). Same Dockerfile, same build-args (incl. the php.net + # EXPECT_PHP_MIN gate, which runs `php -r` on the arm64 binary). Must never + # block or alter the amd64 tag: on failure/timeout the tag is published + # amd64-only, as before multi-arch. + - name: Build arm64 (push by digest) + id: arm64 + continue-on-error: true + timeout-minutes: 45 + uses: docker/build-push-action@v6 + with: + platforms: linux/arm64 + build-args: | + PHPVER=${{ matrix.phpver }} + CACHE_BUST=${{ steps.bust.outputs.value }} + BASE_IMAGE=${{ steps.base.outputs.image }} + PKG_CACHE=${{ steps.pkgcache.outputs.url }} + EXPECT_PHP_MIN=${{ steps.phpmin.outputs.min }} + outputs: type=image,name=repo.anhonesthost.net/cloud-hosting-platform/cac,push-by-digest=true,name-canonical=true,push=true + + # Creates the index tag(s) from THIS run's digests only (a previous run's + # arm64 is never re-attached). scripts/assemble-multiarch-index.sh. + - name: Assemble index tag(s) + env: + AMD64_DIGEST: ${{ steps.amd64.outputs.digest }} + ARM64_DIGEST: ${{ steps.arm64.outputs.digest }} + ARM64_OUTCOME: ${{ steps.arm64.outcome }} + PHPVER: ${{ matrix.phpver }} + run: | + set -euo pipefail + tags=(--tag "php${PHPVER}") + # :latest tracks php85 (unchanged tag scheme). + [ "$PHPVER" = 85 ] && tags+=(--tag latest) + scripts/assemble-multiarch-index.sh \ + --amd64 "$AMD64_DIGEST" \ + --arm64 "$ARM64_DIGEST" --arm64-outcome "$ARM64_OUTCOME" \ + --repo repo.anhonesthost.net/cloud-hosting-platform/cac \ + "${tags[@]}" Build-FPM-Images: runs-on: ubuntu-latest + timeout-minutes: 75 strategy: # One PHP version failing (e.g. an EOL stream) must not cancel the # security builds of the others. @@ -265,6 +314,8 @@ jobs: - name: Set up QEMU uses: docker/setup-qemu-action@v3 + with: + platforms: arm64 # BuildKit (docker-container driver) ignores dockerd's registry-mirrors, # so the mirror is configured on the builder; BuildKit itself still falls @@ -319,21 +370,62 @@ jobs: username: ${{ secrets.CI_USER }} password: ${{ secrets.CI_TOKEN }} - - name: Build and Push FPM Image + # amd64: required. Pushed untagged BY DIGEST (no tag); failure fails the + # job and publishes nothing, exactly as before multi-arch. The tags are only + # created by the assemble step below. + - name: Build amd64 (push by digest) + id: amd64 uses: docker/build-push-action@v6 with: file: ./Dockerfile.fpm platforms: linux/amd64 - push: true build-args: | PHPVER=${{ matrix.phpver }} CACHE_BUST=${{ steps.bust.outputs.value }} BASE_IMAGE=${{ steps.base.outputs.image }} PKG_CACHE=${{ steps.pkgcache.outputs.url }} EXPECT_PHP_MIN=${{ steps.phpmin.outputs.min }} - tags: | - repo.anhonesthost.net/cloud-hosting-platform/cac-fpm:php${{ matrix.phpver }} - ${{ matrix.phpver == '85' && 'repo.anhonesthost.net/cloud-hosting-platform/cac-fpm:latest' || '' }} + outputs: type=image,name=repo.anhonesthost.net/cloud-hosting-platform/cac-fpm,push-by-digest=true,name-canonical=true,push=true + + # arm64: best effort, built under QEMU on this same trusted x86 runner (no + # secret ever leaves it). Same Dockerfile, same build-args (incl. the php.net + # EXPECT_PHP_MIN gate, which runs `php -r` on the arm64 binary). Must never + # block or alter the amd64 tag: on failure/timeout the tag is published + # amd64-only, as before multi-arch. + - name: Build arm64 (push by digest) + id: arm64 + continue-on-error: true + timeout-minutes: 45 + uses: docker/build-push-action@v6 + with: + file: ./Dockerfile.fpm + platforms: linux/arm64 + build-args: | + PHPVER=${{ matrix.phpver }} + CACHE_BUST=${{ steps.bust.outputs.value }} + BASE_IMAGE=${{ steps.base.outputs.image }} + PKG_CACHE=${{ steps.pkgcache.outputs.url }} + EXPECT_PHP_MIN=${{ steps.phpmin.outputs.min }} + outputs: type=image,name=repo.anhonesthost.net/cloud-hosting-platform/cac-fpm,push-by-digest=true,name-canonical=true,push=true + + # Creates the index tag(s) from THIS run's digests only (a previous run's + # arm64 is never re-attached). scripts/assemble-multiarch-index.sh. + - name: Assemble index tag(s) + env: + AMD64_DIGEST: ${{ steps.amd64.outputs.digest }} + ARM64_DIGEST: ${{ steps.arm64.outputs.digest }} + ARM64_OUTCOME: ${{ steps.arm64.outcome }} + PHPVER: ${{ matrix.phpver }} + run: | + set -euo pipefail + tags=(--tag "php${PHPVER}") + # :latest tracks php85 (unchanged tag scheme). + [ "$PHPVER" = 85 ] && tags+=(--tag latest) + scripts/assemble-multiarch-index.sh \ + --amd64 "$AMD64_DIGEST" \ + --arm64 "$ARM64_DIGEST" --arm64-outcome "$ARM64_OUTCOME" \ + --repo repo.anhonesthost.net/cloud-hosting-platform/cac-fpm \ + "${tags[@]}" Build-LiteSpeed-Images: runs-on: ubuntu-latest @@ -647,6 +739,7 @@ jobs: Build-Shared-httpd: runs-on: ubuntu-latest + timeout-minutes: 50 steps: - name: Checkout uses: actions/checkout@v4 @@ -692,6 +785,8 @@ jobs: - name: Set up QEMU uses: docker/setup-qemu-action@v3 + with: + platforms: arm64 # BuildKit (docker-container driver) ignores dockerd's registry-mirrors, # so the mirror is configured on the builder; BuildKit itself still falls @@ -717,17 +812,52 @@ jobs: username: ${{ secrets.CI_USER }} password: ${{ secrets.CI_TOKEN }} - - name: Build and Push Shared httpd Image + # amd64: required. Pushed untagged BY DIGEST (no tag); failure fails the + # job and publishes nothing, exactly as before multi-arch. The tags are only + # created by the assemble step below. + - name: Build amd64 (push by digest) + id: amd64 uses: docker/build-push-action@v6 with: file: ./Dockerfile.shared-httpd platforms: linux/amd64 - push: true build-args: | BASE_IMAGE=${{ steps.base.outputs.image }} PKG_CACHE=${{ steps.pkgcache.outputs.url }} - tags: | - repo.anhonesthost.net/cloud-hosting-platform/shared-httpd:latest + outputs: type=image,name=repo.anhonesthost.net/cloud-hosting-platform/shared-httpd,push-by-digest=true,name-canonical=true,push=true + + # arm64: best effort, built under QEMU on this same trusted x86 runner (no + # secret ever leaves it). Same Dockerfile, same build-args (incl. the php.net + # EXPECT_PHP_MIN gate, which runs `php -r` on the arm64 binary). Must never + # block or alter the amd64 tag: on failure/timeout the tag is published + # amd64-only, as before multi-arch. + - name: Build arm64 (push by digest) + id: arm64 + continue-on-error: true + timeout-minutes: 25 + uses: docker/build-push-action@v6 + with: + file: ./Dockerfile.shared-httpd + platforms: linux/arm64 + build-args: | + BASE_IMAGE=${{ steps.base.outputs.image }} + PKG_CACHE=${{ steps.pkgcache.outputs.url }} + outputs: type=image,name=repo.anhonesthost.net/cloud-hosting-platform/shared-httpd,push-by-digest=true,name-canonical=true,push=true + + # Creates the index tag(s) from THIS run's digests only (a previous run's + # arm64 is never re-attached). scripts/assemble-multiarch-index.sh. + - name: Assemble index tag(s) + env: + AMD64_DIGEST: ${{ steps.amd64.outputs.digest }} + ARM64_DIGEST: ${{ steps.arm64.outputs.digest }} + ARM64_OUTCOME: ${{ steps.arm64.outcome }} + run: | + set -euo pipefail + scripts/assemble-multiarch-index.sh \ + --amd64 "$AMD64_DIGEST" \ + --arm64 "$ARM64_DIGEST" --arm64-outcome "$ARM64_OUTCOME" \ + --repo repo.anhonesthost.net/cloud-hosting-platform/shared-httpd \ + --tag latest Build-Shared-OLS: runs-on: ubuntu-latest diff --git a/scripts/tests/index-platforms.test.sh b/scripts/tests/index-platforms.test.sh index 0487908..ef453ac 100755 --- a/scripts/tests/index-platforms.test.sh +++ b/scripts/tests/index-platforms.test.sh @@ -14,7 +14,7 @@ cat >"$T/it" <<'STUB' # "create ..." -> appended to $STUB_LOG. if [ -n "${STUB_LOG:-}" ]; then if [ "$1" = create ]; then echo "create $*" >>"$STUB_LOG"; exit 0; fi - ref=$2; a=$(grep -F "$ref=" "$ARCHES" | head -1 | cut -d= -f2); [ -n "$a" ] || exit 1 + ref=$2; a=$(grep -F -m1 "$ref=" "$ARCHES" | cut -d= -f2); [ -n "$a" ] || exit 1 printf '{"architecture":"%s"}' "$a"; exit 0 fi [ "$2" = --raw ] || exit 2 diff --git a/scripts/tests/multiarch-workflow.test.py b/scripts/tests/multiarch-workflow.test.py new file mode 100755 index 0000000..5940fc8 --- /dev/null +++ b/scripts/tests/multiarch-workflow.test.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +"""Static wiring checks for the multi-arch publish jobs in .gitea/workflows/build-push.yaml +(Build-and-Push = cac, Build-FPM-Images = cac-fpm, Build-Shared-httpd = shared-httpd). + +No YAML library (the Shell-Checks runner has none): jobs/steps are split on their indentation. +Env WORKFLOW overrides the file under test (used to show the tests fail on the old workflow). +Run: python3 -I scripts/tests/multiarch-workflow.test.py +""" +import os, re, subprocess, tempfile, unittest, stat + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.normpath(os.path.join(HERE, '..', '..')) +WF = os.environ.get('WORKFLOW') or os.path.join(ROOT, '.gitea', 'workflows', 'build-push.yaml') +with open(WF) as _f: + TEXT = _f.read() +REG = 'repo.anhonesthost.net/cloud-hosting-platform' + +SLICE = { # job -> (image repo, dockerfile arg or None, matrix?) + 'Build-and-Push': ('cac', None, True), + 'Build-FPM-Images': ('cac-fpm', './Dockerfile.fpm', True), + 'Build-Shared-httpd': ('shared-httpd', './Dockerfile.shared-httpd', False), +} +OUT_OF_SLICE = ['Build-LiteSpeed-Images', 'Build-LSPHP-Images', 'Build-Shared-OLS'] + + +def jobs(): + m = re.search(r'^jobs:\n', TEXT, re.M) + body = TEXT[m.end():] + parts = re.split(r'^ ([A-Za-z0-9_-]+):\n', body, flags=re.M) + return {parts[i]: parts[i + 1] for i in range(1, len(parts), 2)} + + +def steps(job): + s = re.split(r'^ - name: ', job, flags=re.M)[1:] + out = [] + for blk in s: + name = blk.split('\n', 1)[0].strip() + out.append((name, blk)) + return out + + +def step(job, prefix): + hits = [(n, b) for n, b in steps(job) if n.startswith(prefix)] + assert len(hits) == 1, (prefix, [n for n, _ in hits]) + return hits[0][1] + + +class Wiring(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.jobs = jobs() + + def test_triggers_unchanged(self): + head = TEXT.split('jobs:')[0] + self.assertRegex(head, r'on:\n push:\n branches:\n - trunk\n workflow_dispatch:') + + def test_slice_jobs_exist_and_keep_matrix_and_no_fail_fast(self): + for j, (_, _, matrix) in SLICE.items(): + self.assertIn(j, self.jobs, j) + if matrix: + self.assertIn('fail-fast: false', self.jobs[j], j) + self.assertIn('phpver: [82, 83, 84, 85]', self.jobs[j], j) + + def test_job_timeout_bounds_the_arm64_step(self): + for j in SLICE: + job = self.jobs[j] + jt = int(re.search(r'^ timeout-minutes: (\d+)$', job, re.M).group(1)) + at = int(re.search(r'timeout-minutes: (\d+)', step(job, 'Build arm64')).group(1)) + self.assertGreaterEqual(at, 20, j) # T3: arm64 under qemu took 7m47s + self.assertGreater(jt, at + 20, j) # room for setup + amd64 + assemble + self.assertLessEqual(jt, 120, j) + + def test_step_order_and_ids(self): + for j in SLICE: + names = [n for n, _ in steps(self.jobs[j])] + a = [i for i, n in enumerate(names) if n.startswith('Build amd64')] + r = [i for i, n in enumerate(names) if n.startswith('Build arm64')] + s = [i for i, n in enumerate(names) if n.startswith('Assemble')] + self.assertEqual((len(a), len(r), len(s)), (1, 1, 1), j) + self.assertLess(a[0], r[0], j) + self.assertLess(r[0], s[0], j) + self.assertIn('id: amd64', step(self.jobs[j], 'Build amd64')) + self.assertIn('id: arm64', step(self.jobs[j], 'Build arm64')) + self.assertLess(names.index('Login to Gitea'), a[0], j) + + def test_arm64_non_fatal_amd64_fatal(self): + for j in SLICE: + self.assertIn('continue-on-error: true', step(self.jobs[j], 'Build arm64'), j) + self.assertNotIn('continue-on-error', step(self.jobs[j], 'Build amd64'), j) + self.assertNotIn('continue-on-error', step(self.jobs[j], 'Assemble'), j) + + def test_platforms_and_qemu(self): + for j in SLICE: + self.assertIn('platforms: linux/amd64\n', step(self.jobs[j], 'Build amd64'), j) + self.assertIn('platforms: linux/arm64\n', step(self.jobs[j], 'Build arm64'), j) + self.assertRegex(step(self.jobs[j], 'Set up QEMU'), r'platforms: arm64', j) + + def test_both_push_by_digest_untagged_to_gitea_only(self): + for j, (repo, _, _) in SLICE.items(): + for which in ('Build amd64', 'Build arm64'): + st = step(self.jobs[j], which) + self.assertIn('type=image,name=%s/%s,push-by-digest=true,name-canonical=true,push=true' % (REG, repo), st, j) + self.assertNotRegex(st, r'^\s+tags:', j) + self.assertNotRegex(st, r'^\s+push: true', j) + self.assertNotIn('ghcr.io', self.jobs[j], j) # CAC has never pushed to GHCR; do not widen + + def test_same_build_args_and_dockerfile_for_both_platforms(self): + for j, (_, dockerfile, _) in SLICE.items(): + a = step(self.jobs[j], 'Build amd64'); r = step(self.jobs[j], 'Build arm64') + ba = lambda s: re.search(r'build-args: \|\n((?: .*\n)+)', s).group(1) + self.assertEqual(ba(a), ba(r), j) + for need in (('PKG_CACHE=${{ steps.pkgcache.outputs.url }}', 'BASE_IMAGE=${{ steps.base.outputs.image }}')): + for n in need: + self.assertIn(n, ba(a), j) + if SLICE[j][2]: + for n in ('PHPVER=${{ matrix.phpver }}', 'CACHE_BUST=${{ steps.bust.outputs.value }}', + 'EXPECT_PHP_MIN=${{ steps.phpmin.outputs.min }}'): + self.assertIn(n, ba(a), j) + if dockerfile: + self.assertIn('file: %s\n' % dockerfile, a, j) + self.assertIn('file: %s\n' % dockerfile, r, j) + + def test_assemble_uses_this_runs_outputs_and_repo(self): + for j, (repo, _, _) in SLICE.items(): + st = step(self.jobs[j], 'Assemble') + self.assertIn('${{ steps.amd64.outputs.digest }}', st, j) + self.assertIn('${{ steps.arm64.outputs.digest }}', st, j) + self.assertIn('${{ steps.arm64.outcome }}', st, j) + self.assertIn('scripts/assemble-multiarch-index.sh', st, j) + self.assertIn('--repo %s/%s' % (REG, repo), st, j) + self.assertEqual(st.count('--repo '), 1, j) + + def test_tag_scheme_unchanged(self): + """Run the assemble step's shell with a recording stub: exact tags per version.""" + for j, (repo, _, matrix) in SLICE.items(): + st = step(self.jobs[j], 'Assemble') + script = re.search(r'run: \|\n((?: .*\n?)+)', st).group(1) + script = '\n'.join(l[10:] for l in script.split('\n')) + for ver in (['82', '83', '84', '85'] if matrix else ['']): + with tempfile.TemporaryDirectory() as d: + os.makedirs(os.path.join(d, 'scripts')) + stub = os.path.join(d, 'scripts', 'assemble-multiarch-index.sh') + with open(stub, 'w') as f: + f.write('#!/usr/bin/env bash\nprintf "%s\\n" "$@" > "$LOG"\n') + os.chmod(stub, 0o755) + log = os.path.join(d, 'log') + env = dict(os.environ, LOG=log, AMD64_DIGEST='sha256:' + 'a' * 64, + ARM64_DIGEST='sha256:' + 'b' * 64, ARM64_OUTCOME='success', PHPVER=ver) + subprocess.run(['bash', '-c', script], cwd=d, env=env, check=True) + with open(log) as f: + args = f.read().split('\n')[:-1] + tags = [args[i + 1] for i, x in enumerate(args) if x == '--tag'] + want = ['latest'] if not matrix else (['php' + ver] + (['latest'] if ver == '85' else [])) + self.assertEqual(tags, want, (j, ver)) + self.assertEqual(args[args.index('--repo') + 1], '%s/%s' % (REG, repo)) + self.assertEqual(args[args.index('--arm64-outcome') + 1], 'success') + + def test_arm64_never_added_outside_the_first_slice(self): + for j in OUT_OF_SLICE: + self.assertIn(j, self.jobs, j) + job = self.jobs[j] + self.assertIn('platforms: linux/amd64', job, j) + self.assertNotIn('linux/arm64', job, j) + self.assertNotIn('push-by-digest', job, j) + self.assertNotIn('assemble-multiarch-index', job, j) + + def test_only_the_assemble_script_creates_tags_in_slice(self): + for j in SLICE: + self.assertNotIn('imagetools create', self.jobs[j], j) # only via the tested script + + +if __name__ == '__main__': + unittest.main(verbosity=2) -- 2.52.0