111 lines
4.1 KiB
Python
111 lines
4.1 KiB
Python
#!/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:]))
|