Merge pull request #20 from kristianwind/workflows-parse-and-gate
CI / Static checks (push) Failing after 27s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Failing after 2m9s

Parse the workflows on Gitea too, and keep the compat matrix off its runner
This commit is contained in:
Kristian Wind
2026-09-17 00:38:59 +02:00
committed by GitHub
6 changed files with 207 additions and 11 deletions
+8 -1
View File
@@ -39,7 +39,14 @@ jobs:
# work again on a single self-hosted runner for no extra information.
# GitHub's runners take the scheduled load; Gitea keeps workflow_dispatch,
# so the run is still one click away there.
if: github.event_name != 'schedule' || github.server_url == 'https://github.com'
#
# 🔴 This gates EVERY event except workflow_dispatch, not just the
# schedule. The earlier `!= 'schedule'` form let `pull_request` through,
# and this workflow triggers on any PR touching ci/ or tests/ — so five
# cells landed on the one self-hosted runner together. A sibling plugin
# measured six jobs dying at once with exit code 137: SIGKILL, out of
# memory, two seconds in, with not one error message in the log.
if: github.event_name == 'workflow_dispatch' || github.server_url == 'https://github.com'
runs-on: ubuntu-latest
strategy:
+8 -1
View File
@@ -39,7 +39,14 @@ jobs:
# work again on a single self-hosted runner for no extra information.
# GitHub's runners take the scheduled load; Gitea keeps workflow_dispatch,
# so the run is still one click away there.
if: github.event_name != 'schedule' || github.server_url == 'https://github.com'
#
# 🔴 This gates EVERY event except workflow_dispatch, not just the
# schedule. The earlier `!= 'schedule'` form let `pull_request` through,
# and this workflow triggers on any PR touching ci/ or tests/ — so five
# cells landed on the one self-hosted runner together. A sibling plugin
# measured six jobs dying at once with exit code 137: SIGKILL, out of
# memory, two seconds in, with not one error message in the log.
if: github.event_name == 'workflow_dispatch' || github.server_url == 'https://github.com'
runs-on: ubuntu-latest
strategy:
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""Parse every workflow file and check the rules that only bite on Gitea.
Two things this catches, both of which are silent where they happen:
1. A workflow that does not parse produces NO RUN on Gitea — not a failed run,
not a cancelled one. It looks exactly like a runner that has not picked the
job up, so you wait, and waiting never fixes it.
2. A job with a multi-cell matrix that is not gated to GitHub asks a single
self-hosted runner to do all of it at once. A sibling plugin measured six
jobs dying together with exit code 137 — SIGKILL, out of memory, two
seconds in, with not one error message in the log.
It reports how many files and jobs it examined, and it is fatal when that is
zero: "all workflows are fine" is also what this prints having parsed nothing,
which is the failure mode the whole ci/ kit has now been bitten by four times.
"""
import sys
import yaml
# 🔴 Exact conditions, not a substring. The first version of this check asked
# whether the `if:` CONTAINED the server_url test, and passed the very
# condition it was written to catch:
#
# github.event_name != 'schedule' || github.server_url == '...'
#
# — which gates the schedule and lets `pull_request` through, so five cells
# still land on the Gitea runner on every PR that touches ci/. A substring
# match is "adjacent to the signal" rather than the signal, which is the
# failure mode this whole ci/ kit keeps being bitten by.
ACCEPTED = {
# Gitea runs it only when a human clicks it.
"github.event_name == 'workflow_dispatch' || "
"github.server_url == 'https://github.com'",
# Or never on Gitea at all.
"github.server_url == 'https://github.com'",
}
def cells(job):
"""Number of matrix cells, 1 when the job has no matrix."""
matrix = (job.get('strategy') or {}).get('matrix')
if not isinstance(matrix, dict):
return 1
if 'include' in matrix and isinstance(matrix['include'], list):
return max(len(matrix['include']), 1)
total = 1
for key, values in matrix.items():
if key in ('include', 'exclude') or not isinstance(values, list):
continue
total *= max(len(values), 1)
return total
def main(paths):
problems = 0
files = 0
jobs = 0
for path in paths:
try:
with open(path) as handle:
doc = yaml.safe_load(handle)
except yaml.YAMLError as err:
print(f"::error file={path}::Does not parse, so on Gitea it will "
f"simply never run: {err}")
problems += 1
continue
files += 1
if not isinstance(doc, dict):
continue
# Only .gitea/ is checked for the gate: .github/ workflows run on
# GitHub's runners, which are not the scarce resource here.
if not path.startswith('.gitea/'):
for _ in (doc.get('jobs') or {}):
jobs += 1
continue
for name, job in (doc.get('jobs') or {}).items():
jobs += 1
if not isinstance(job, dict):
continue
n = cells(job)
if n <= 1:
continue
condition = ' '.join(str(job.get('if', '')).split())
if condition not in ACCEPTED:
print(f"::error file={path}::Job '{name}' runs {n} matrix "
f"cells on Gitea, and its `if:` is not one of the "
f"accepted gates. One self-hosted runner is asked to do "
f"all of it at once, and the symptom is exit code 137 "
f"with an empty log. Found: {condition or '<none>'}")
problems += 1
# Not caller-configurable: the smallest repo that has workflows has one.
if files == 0:
print("No workflow file was parsed at all. The paths are wrong, so "
"this check proved nothing.", file=sys.stderr)
return 1
if problems:
print(f"\n{problems} workflow problem(s) across {files} file(s).")
return 1
print(f" {files} workflow files parsed, {jobs} jobs checked")
return 0
if __name__ == '__main__':
sys.exit(main(sys.argv[1:]))
+21
View File
@@ -96,6 +96,27 @@ ci_php() {
ci_in_container "php:8.4-cli" "exec php $(printf '%q ' "$@")"
}
# Run one of this repository's python scripts, with PyYAML available.
#
# Same shape and the same reason as ci_php above, and it is not hypothetical:
# the Gitea runner image has NO PyYAML (measured — `import yaml` raises
# ModuleNotFoundError there), while every laptop and GitHub's hosted runners
# have it. So a check that merely skips when the import fails is a check that
# is switched off on exactly one forge — and Gitea is the forge where a
# workflow that does not parse produces no run and no error to click on.
#
# alpine + py3-yaml rather than a python image + pip: no network install, one
# apk call, the same pinned answer every time.
ci_python_yaml() {
if python3 -c 'import yaml' 2>/dev/null; then
python3 "$@"
return
fi
ci_in_container "alpine:3.20" \
"apk add --no-cache python3 py3-yaml >/dev/null && exec python3 $(printf '%q ' "$@")"
}
ci_step() { printf '\n\033[1m→ %s\033[0m\n' "$*"; }
ci_fail() { printf '\033[31mFAIL\033[0m %s\n' "$*" >&2; }
+47
View File
@@ -178,6 +178,53 @@ else
ok "a plugin.conf with no floors fails to load"
fi
# ------------------------------------------------------------------ workflows
# Both of these are silent where they happen. An unparseable workflow produces
# NO RUN on Gitea, and a multi-cell matrix that is not gated lands on one
# self-hosted runner as exit code 137 with an empty log. Neither shows up on
# GitHub, which is where anyone would be looking.
echo
echo "→ ci/workflows-in-sync.sh must catch what only breaks on Gitea"
cd "$work/repo"
cp -a .github/workflows "$work/wf-github-backup"
cp -a .gitea/workflows "$work/wf-gitea-backup"
restore_workflows() {
rm -rf .github/workflows .gitea/workflows
cp -a "$work/wf-github-backup" .github/workflows
cp -a "$work/wf-gitea-backup" .gitea/workflows
}
# Corrupt BOTH roots identically, so this fails on the PARSE rather than on the
# byte-identity rule that would otherwise fire first.
printf '\n this: [is not\n' >> .github/workflows/ci.yml
printf '\n this: [is not\n' >> .gitea/workflows/ci.yml
./ci/workflows-in-sync.sh >/dev/null 2>&1 \
&& bad "an unparseable workflow passed — on Gitea it would simply never run" \
|| ok "an unparseable workflow fails the build"
restore_workflows
# The gate that keeps a five-cell matrix off the single self-hosted runner.
# @ as the delimiter, not |: the gate itself contains `||`, and with | as the
# delimiter sed parses the fixture into nonsense and silently changes nothing —
# which is how this assertion first ran against an unmodified file. It reported
# "could not stage the fixture" rather than passing, which is the whole reason
# the staging is checked before the assertion is made.
sed -i "s@^ if: github.event_name == 'workflow_dispatch'.*@ if: always()@" \
.github/workflows/compat.yml .gitea/workflows/compat.yml
if grep -q 'if: always()' .gitea/workflows/compat.yml; then
./ci/workflows-in-sync.sh >/dev/null 2>&1 \
&& bad "an ungated multi-cell matrix passed — it lands on the Gitea runner as exit 137" \
|| ok "an ungated multi-cell matrix fails the build"
else
bad "could not stage the ungated-matrix fixture — the gate line did not match"
fi
restore_workflows
# Parsing nothing is never a legitimate result.
ci_python_yaml ci/check-workflows.py >/dev/null 2>&1 \
&& bad "check-workflows.py passed having parsed no file at all" \
|| ok "parsing zero workflow files is fatal"
echo
if [ "$fail" != 0 ]; then
echo "Self-test: $fail of $((pass + fail)) assertions FAILED."
+13 -9
View File
@@ -12,6 +12,7 @@
# one appears in only one root without a stated reason.
set -euo pipefail
cd "$(dirname "$0")/.."
. ci/lib.sh
# Workflows that are deliberately single-forge, and why.
declare -A only_on=(
@@ -70,15 +71,18 @@ fi
# A workflow that does not parse produces no run — the same symptom as every
# other trap in this file. ci.yml cannot catch its own syntax error this way,
# but release.yml and compat.yml can be caught here rather than on the tag.
if python3 -c 'import yaml' 2>/dev/null; then
for f in .github/workflows/*.yml .gitea/workflows/*.yml; do
[ -e "$f" ] || continue
python3 -c 'import sys,yaml; yaml.safe_load(open(sys.argv[1]))' "$f" \
|| { echo "$f is not valid YAML, so it will simply never run."; fail=1; }
done
else
echo "(PyYAML is not installed here, so the workflow files were not parsed.)"
fi
#
# 🔴 This used to skip with a note when PyYAML was missing, and still printed
# "Workflows: in sync." at the end. PyYAML is missing in exactly one place —
# the GITEA RUNNER (measured: `import yaml` raises ModuleNotFoundError on
# gitea/runner-images:ubuntu-latest) — which is the one forge where an
# unparseable workflow produces no run and no error to click on. So the check
# was switched off precisely where it was needed, and said "in sync" anyway.
#
# ci_python_yaml falls back to a container, so there is no skip path left: if
# the workflows cannot be parsed, that is a failure, not a note.
ci_python_yaml ci/check-workflows.py .github/workflows/*.yml .gitea/workflows/*.yml \
|| fail=1
[ "$fail" = 0 ] || exit 1
echo "Workflows: in sync."