build-push: workflow_dispatch input to rebuild a single image leg #41
No files matched your search
Executable
+84
@@ -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"
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user