From b5bb46740dcb9a496c2f441d3cbb08a138c33574 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Thu, 12 Nov 2026 07:52:13 -0800 Subject: [PATCH] build-push: workflow_dispatch input `only` to rebuild a single image leg Gitea cannot re-run one job and a full run takes ~1h. Add a plan job that turns the optional `only` input (e.g. cac:php85) into one filtered matrix per image family; image jobs take their matrix from it and are skipped when empty. Push events / empty input plan the full set as before. Unknown values fail the plan job. Tests added to multiarch-workflow.test.py. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0185ukXjXyosSkunDsdWz3mb --- .gitea/scripts/plan-legs.sh | 84 ++++++++++++++ .gitea/workflows/build-push.yaml | 60 +++++++++- scripts/tests/multiarch-workflow.test.py | 139 ++++++++++++++++++++++- 3 files changed, 277 insertions(+), 6 deletions(-) create mode 100755 .gitea/scripts/plan-legs.sh diff --git a/.gitea/scripts/plan-legs.sh b/.gitea/scripts/plan-legs.sh new file mode 100755 index 0000000..8761f23 --- /dev/null +++ b/.gitea/scripts/plan-legs.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +## plan-legs.sh - decide which image legs of build-push.yaml run. +## +## Env: +## ONLY workflow_dispatch input "only". Empty/blank (and every push +## event) = build everything, exactly as before. +## GITHUB_OUTPUT file the outputs are appended to (set by the runner). +## +## ONLY is a comma list of tokens (spaces ignored): +## cac:php85 cac-fpm:php84 cac-litespeed:php83 cac-lsphp:php82 one leg +## cac | cac-fpm | cac-litespeed | cac-lsphp every PHP version of that image +## shared-httpd | shared-ols that image +## Any other token is an error (exit 2, nothing is written): a typo must fail +## the plan job loudly, never silently build nothing. +## +## Outputs (matrix families = JSON array of phpver, [] = skip the job; +## single-leg images = true|false): +## cac cac_fpm cac_litespeed cac_lsphp shared_httpd shared_ols +set -euo pipefail + +MATRIX_FAMILIES=(cac cac-fpm cac-litespeed cac-lsphp) +SINGLE_FAMILIES=(shared-httpd shared-ols) +ALL_PHPVER=(82 83 84 85) + +die() { echo "plan-legs: $*" >&2; exit 2; } + +only=${ONLY-} +only=${only//[[:space:]]/} + +declare -A want_ver want_single +want_all=false + +if [ -z "$only" ]; then + want_all=true +else + n=0 + IFS=',' read -r -a toks <<< "$only" + for t in "${toks[@]}"; do + [ -n "$t" ] || continue + n=$((n + 1)) + fam=${t%%:*} + ver="" + if [ "$fam" != "$t" ]; then + ver=${t#*:} + [ -n "$ver" ] || die "empty PHP version in '$t'" + fi + if [[ " ${MATRIX_FAMILIES[*]} " == *" $fam "* ]]; then + if [ -z "$ver" ]; then + for v in "${ALL_PHPVER[@]}"; do want_ver["$fam:$v"]=1; done + elif [[ "$ver" =~ ^php(8[2-5])$ ]]; then + want_ver["$fam:${BASH_REMATCH[1]}"]=1 + else + die "unknown PHP version in '$t' (want ${fam}:php82 .. ${fam}:php85)" + fi + elif [[ " ${SINGLE_FAMILIES[*]} " == *" $fam "* ]]; then + [ "$fam" = "$t" ] || die "'$t': $fam has a single leg, use plain '$fam'" + want_single["$fam"]=1 + else + die "unknown image '$t' (known: ${MATRIX_FAMILIES[*]} ${SINGLE_FAMILIES[*]})" + fi + done + [ "$n" -gt 0 ] || die "'only' has no usable entries: '$only'" +fi + +matrix_json() { # family + local fam=$1 v out="" + for v in "${ALL_PHPVER[@]}"; do + if $want_all || [ -n "${want_ver["$fam:$v"]-}" ]; then out+="${out:+,}$v"; fi + done + printf '[%s]' "$out" +} +single_json() { # family + if $want_all || [ -n "${want_single["$1"]-}" ]; then echo true; else echo false; fi +} + +out=${GITHUB_OUTPUT:-/dev/stdout} +{ + echo "cac=$(matrix_json cac)" + echo "cac_fpm=$(matrix_json cac-fpm)" + echo "cac_litespeed=$(matrix_json cac-litespeed)" + echo "cac_lsphp=$(matrix_json cac-lsphp)" + echo "shared_httpd=$(single_json shared-httpd)" + echo "shared_ols=$(single_json shared-ols)" +} >> "$out" diff --git a/.gitea/workflows/build-push.yaml b/.gitea/workflows/build-push.yaml index 071e72d..38354af 100644 --- a/.gitea/workflows/build-push.yaml +++ b/.gitea/workflows/build-push.yaml @@ -1,10 +1,22 @@ name: Cloud Apache Container run-name: ${{ gitea.actor }} pushed a change to trunk +# Manual run. `only` (optional) rebuilds just the named legs instead of every +# image: Gitea cannot re-run a single job and a full run takes ~1h. +# Syntax: comma list of cac:php85, cac-fpm:php84, cac-litespeed:php83, +# cac-lsphp:php82, shared-httpd, shared-ols; a bare family name (cac) = all its +# PHP versions. Empty = everything (same as a push). Unknown values fail the +# plan job. See .gitea/scripts/plan-legs.sh. on: push: branches: - trunk workflow_dispatch: + inputs: + only: + description: 'Only build these legs (comma list, e.g. cac:php85 or cac-fpm:php84,shared-httpd). Empty = build everything.' + required: false + default: '' + type: string jobs: # Shell gate. Runs FIRST and costs seconds; the images below do not depend on @@ -67,7 +79,8 @@ jobs: scripts/tests/image-label-amd64.test.sh \ scripts/tests/index-platforms.test.sh \ scripts/tests/lsphp-abi-gate.test.sh \ - .gitea/scripts/resolve-base-image.sh + .gitea/scripts/resolve-base-image.sh \ + .gitea/scripts/plan-legs.sh - name: lsphp repo-version lookup test run: ./scripts/tests/lsphp-repo-version.test.sh @@ -96,7 +109,34 @@ jobs: fi ./scripts/tests/lsphp-abi-gate.test.sh + # Decides which image legs run. A job-level `if:` cannot see the `matrix` + # context, so instead of skipping matrix cells this job emits one filtered + # matrix per image family (and a true/false per single-leg image); each image + # job below takes its matrix from here and is skipped when its list is empty. + # Push events and an empty `only` input plan the full set, as before. + plan: + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + cac: ${{ steps.plan.outputs.cac }} + cac_fpm: ${{ steps.plan.outputs.cac_fpm }} + cac_litespeed: ${{ steps.plan.outputs.cac_litespeed }} + cac_lsphp: ${{ steps.plan.outputs.cac_lsphp }} + shared_httpd: ${{ steps.plan.outputs.shared_httpd }} + shared_ols: ${{ steps.plan.outputs.shared_ols }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Plan legs + id: plan + env: + ONLY: ${{ github.event.inputs.only }} + run: .gitea/scripts/plan-legs.sh + Build-and-Push: + needs: plan + if: needs.plan.outputs.cac != '[]' runs-on: ubuntu-latest timeout-minutes: 75 strategy: @@ -104,7 +144,7 @@ jobs: # security builds of the others. fail-fast: false matrix: - phpver: [82, 83, 84, 85] + phpver: ${{ fromJSON(needs.plan.outputs.cac) }} steps: - name: Checkout uses: actions/checkout@v4 @@ -262,6 +302,8 @@ jobs: "${tags[@]}" Build-FPM-Images: + needs: plan + if: needs.plan.outputs.cac_fpm != '[]' runs-on: ubuntu-latest timeout-minutes: 75 strategy: @@ -269,7 +311,7 @@ jobs: # security builds of the others. fail-fast: false matrix: - phpver: [82, 83, 84, 85] + phpver: ${{ fromJSON(needs.plan.outputs.cac_fpm) }} steps: - name: Checkout uses: actions/checkout@v4 @@ -429,6 +471,8 @@ jobs: "${tags[@]}" Build-LiteSpeed-Images: + needs: plan + if: needs.plan.outputs.cac_litespeed != '[]' runs-on: ubuntu-latest # One place for the OLS base tag: digest lookup and build-args both read it. # OLS_VERSION pinned to 1.8.4 (see the build step); Dockerfile default differs. @@ -443,7 +487,7 @@ jobs: # images stop at older OLS releases for those PHP versions, and the # cac-litespeed tier is a paid premium offering: 8.1+ is the # modernization story we're selling. - phpver: [82, 83, 84, 85] + phpver: ${{ fromJSON(needs.plan.outputs.cac_litespeed) }} steps: - name: Checkout uses: actions/checkout@v4 @@ -585,6 +629,8 @@ jobs: ${{ matrix.phpver == '85' && 'repo.anhonesthost.net/cloud-hosting-platform/cac-litespeed:latest' || '' }} Build-LSPHP-Images: + needs: plan + if: needs.plan.outputs.cac_lsphp != '[]' runs-on: ubuntu-latest # One place for the OLS base tag: digest lookup and build-args both read it. # OLS_VERSION pinned to 1.8.4 (see the build step); Dockerfile default differs. @@ -599,7 +645,7 @@ jobs: # backend for the shared-ols tier and shares the litespeed prebuilt # base, which only ships lsphp for 8.1+. Keep this matrix in lockstep # with Build-LiteSpeed-Images. - phpver: [82, 83, 84, 85] + phpver: ${{ fromJSON(needs.plan.outputs.cac_lsphp) }} steps: - name: Checkout uses: actions/checkout@v4 @@ -739,6 +785,8 @@ jobs: ${{ matrix.phpver == '85' && 'repo.anhonesthost.net/cloud-hosting-platform/cac-lsphp:latest' || '' }} Build-Shared-httpd: + needs: plan + if: needs.plan.outputs.shared_httpd == 'true' runs-on: ubuntu-latest timeout-minutes: 50 steps: @@ -861,6 +909,8 @@ jobs: --tag latest Build-Shared-OLS: + needs: plan + if: needs.plan.outputs.shared_ols == 'true' runs-on: ubuntu-latest # One place for the OLS base tag: digest lookup and build-args both read it. env: diff --git a/scripts/tests/multiarch-workflow.test.py b/scripts/tests/multiarch-workflow.test.py index 5940fc8..903e2b6 100755 --- a/scripts/tests/multiarch-workflow.test.py +++ b/scripts/tests/multiarch-workflow.test.py @@ -59,7 +59,7 @@ class Wiring(unittest.TestCase): 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) + self.assertRegex(self.jobs[j], r'phpver: \$\{\{ fromJSON\(needs\.plan\.outputs\.\w+\) \}\}', j) def test_job_timeout_bounds_the_arm64_step(self): for j in SLICE: @@ -169,5 +169,142 @@ class Wiring(unittest.TestCase): self.assertNotIn('imagetools create', self.jobs[j], j) # only via the tested script +# ---- single-leg dispatch (workflow_dispatch input `only`, plan job) ---------------------- +PLAN = os.path.join(ROOT, '.gitea', 'scripts', 'plan-legs.sh') +FULL = '[82,83,84,85]' +# image job -> (plan output, matrix?) +LEGS = { + 'Build-and-Push': ('cac', True), + 'Build-FPM-Images': ('cac_fpm', True), + 'Build-LiteSpeed-Images': ('cac_litespeed', True), + 'Build-LSPHP-Images': ('cac_lsphp', True), + 'Build-Shared-httpd': ('shared_httpd', False), + 'Build-Shared-OLS': ('shared_ols', False), +} +EMPTY = {'cac': '[]', 'cac_fpm': '[]', 'cac_litespeed': '[]', 'cac_lsphp': '[]', + 'shared_httpd': 'false', 'shared_ols': 'false'} +EVERYTHING = {'cac': FULL, 'cac_fpm': FULL, 'cac_litespeed': FULL, 'cac_lsphp': FULL, + 'shared_httpd': 'true', 'shared_ols': 'true'} + + +def plan(only): + """Run plan-legs.sh like the runner does. Returns (exit code, outputs dict, stderr).""" + with tempfile.TemporaryDirectory() as d: + out = os.path.join(d, 'out') + open(out, 'w').close() + env = dict(os.environ, GITHUB_OUTPUT=out) + if only is None: + env.pop('ONLY', None) # push event: the input does not exist + else: + env['ONLY'] = only + r = subprocess.run(['bash', PLAN], env=env, capture_output=True, text=True, timeout=30) + with open(out) as f: + kv = dict(l.rstrip('\n').split('=', 1) for l in f if '=' in l) + return r.returncode, kv, r.stderr + + +class SingleLegDispatch(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.jobs = jobs() + + def test_trigger_still_trunk_push_only_and_dispatch_has_string_input(self): + head = TEXT.split('jobs:')[0] + self.assertEqual(re.findall(r'^ branches:\n((?: - .*\n)+)', head, re.M), [' - trunk\n']) + m = re.search(r'^ workflow_dispatch:\n inputs:\n only:\n((?: .*\n)+)', head, re.M) + self.assertIsNotNone(m, 'workflow_dispatch needs an `only` input') + self.assertIn("default: ''", m.group(1)) + self.assertIn('required: false', m.group(1)) + self.assertIn('type: string', m.group(1)) + + def test_plan_job_wiring(self): + plan_job = self.jobs['plan'] + # input goes through env, never interpolated into the shell script + self.assertRegex(plan_job, r'env:\n ONLY: \$\{\{ github\.event\.inputs\.only \}\}') + run = re.search(r'run: (.*)\n', step(plan_job, 'Plan legs')).group(1) + self.assertNotIn('${{', run) + self.assertIn('.gitea/scripts/plan-legs.sh', run) + for _, (out, _) in LEGS.items(): + self.assertIn(' %s: ${{ steps.plan.outputs.%s }}' % (out, out), plan_job, out) + self.assertIn('id: plan', step(plan_job, 'Plan legs')) + + def test_every_image_job_depends_on_plan_with_its_own_output(self): + for j, (out, matrix) in LEGS.items(): + job = self.jobs[j] + self.assertRegex(job, r'(?m)^ needs: plan$', j) + want = ("if: needs.plan.outputs.%s != '[]'" if matrix else "if: needs.plan.outputs.%s == 'true'") % out + self.assertRegex(job, r'(?m)^ ' + re.escape(want) + r'$', j) + if matrix: + self.assertIn('phpver: ${{ fromJSON(needs.plan.outputs.%s) }}' % out, job, j) + self.assertNotRegex(job, r'if:.*matrix\.', j) # job-level if cannot see matrix + self.assertNotRegex(job, r'phpver: \[', j) # no literal list left behind + outs = [o for o, _ in LEGS.values()] + self.assertEqual(len(outs), len(set(outs))) + + def test_every_job_but_shell_checks_and_plan_is_gated(self): + for j in self.jobs: + if j in ('Shell-Checks', 'plan'): + continue + self.assertIn(j, LEGS, 'new job %s must be gated by the plan job' % j) + sc = self.jobs['Shell-Checks'] + self.assertNotRegex(sc, r'^ (needs|if):', 'Shell-Checks must stay ungated') + self.assertIn('.gitea/scripts/plan-legs.sh', sc) # shellchecked + self.assertIn('multiarch-workflow.test.py', sc) # this suite still runs + + def test_plan_outputs_match_what_the_script_emits(self): + rc, kv, _ = plan('') + self.assertEqual(rc, 0) + self.assertEqual(sorted(kv), sorted(o for o, _ in LEGS.values())) + + def test_push_and_empty_input_plan_everything(self): + for only in (None, '', ' ', '\t'): + rc, kv, err = plan(only) + self.assertEqual((rc, kv), (0, EVERYTHING), (only, err)) + + def test_full_matrix_equals_the_old_literal(self): + # the pre-change matrix was `phpver: [82, 83, 84, 85]` + rc, kv, _ = plan(None) + self.assertEqual([int(x) for x in kv['cac'].strip('[]').split(',')], [82, 83, 84, 85]) + + def test_single_leg(self): + rc, kv, _ = plan('cac:php85') + self.assertEqual((rc, kv), (0, dict(EMPTY, cac='[85]'))) + rc, kv, _ = plan('cac-fpm:php84') + self.assertEqual((rc, kv), (0, dict(EMPTY, cac_fpm='[84]'))) + rc, kv, _ = plan('cac-litespeed:php82') + self.assertEqual((rc, kv), (0, dict(EMPTY, cac_litespeed='[82]'))) + rc, kv, _ = plan('cac-lsphp:php83') + self.assertEqual((rc, kv), (0, dict(EMPTY, cac_lsphp='[83]'))) + rc, kv, _ = plan('shared-httpd') + self.assertEqual((rc, kv), (0, dict(EMPTY, shared_httpd='true'))) + rc, kv, _ = plan('shared-ols') + self.assertEqual((rc, kv), (0, dict(EMPTY, shared_ols='true'))) + + def test_cac_does_not_pull_in_cac_fpm(self): + rc, kv, _ = plan('cac:php85') + self.assertEqual(kv['cac_fpm'], '[]') + self.assertEqual(kv['cac_litespeed'], '[]') + + def test_family_name_means_every_version(self): + rc, kv, _ = plan('cac-fpm') + self.assertEqual((rc, kv), (0, dict(EMPTY, cac_fpm=FULL))) + + def test_comma_list_spaces_duplicates_and_ordering(self): + rc, kv, _ = plan(' cac:php85 , cac:php82,cac:php85, shared-httpd ,') + self.assertEqual((rc, kv), (0, dict(EMPTY, cac='[82,85]', shared_httpd='true'))) + rc, kv, _ = plan('cac:php85,cac-fpm:php84') + self.assertEqual((rc, kv), (0, dict(EMPTY, cac='[85]', cac_fpm='[84]'))) + + def test_unknown_values_fail_loudly_and_emit_nothing(self): + bad = ['foo', 'cac:php99', 'cac:85', 'cac:php8', 'CAC:php85', 'cac:', 'cac:php85:x', 'php85', + 'shared-httpd:php85', 'shared-ols:latest', ',', ' , ', 'cac:php85,nope', + 'cac:php85;rm -rf x', '$(id)', 'all', 'latest'] + for only in bad: + rc, kv, err = plan(only) + self.assertEqual(rc, 2, only) + self.assertEqual(kv, {}, only) # nothing half-planned + self.assertIn('plan-legs:', err, only) + + if __name__ == '__main__': unittest.main(verbosity=2) -- 2.52.0