Files
nolimit-discounts/ci/check-workflows.py
T

111 lines
4.1 KiB
Python
Raw Normal View History

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