#!/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)