Files
wolfssl/.github/scripts/check-workflows.py
T
Tobias Frauenschläger 641c39dbf3 CI: catch workflows that GitHub silently fails to load
A workflow file GitHub cannot load does not fail loudly. Its runs end
within 0s with zero jobs, no logs, no annotations and no check runs, and
the workflow re-registers under its bare path instead of its `name:`
field. Among the few hundred checks on a PR that reads as unrelated
flake, so the coverage just disappears: os-check.yml was in this state on
master for ten days in July 2026 before anyone noticed, and no open PR
reported a problem the whole time.

Add two guards.

Pre-merge, check-workflows.py measures every `run:` step against
GitHub's 21000 character cap and fails the build past it, with a warning
from 18000 so a growing step is noticed while there is still runway.
Sizes come from the parsed YAML, which is what the Actions service
evaluates, so block-scalar indentation needs no guessing. It runs from
check-source-text.yml over every workflow and composite action rather
than only PR-changed files: the cap applies per file, the whole sweep
takes well under a second, and a file can be pushed over the line by a
change elsewhere in the PR. Note that this cap is enforced by the
service and not by the workflow schema, so neither a YAML validator nor
actionlint reports it.

Post-merge, workflow-health.yml runs check-workflow-health.py daily and
looks for the symptom rather than any particular cause, so a workflow
that stops loading for a reason nobody anticipated is still caught. Two
signals: an active workflow whose registered name equals its path, and a
completed run that failed with zero jobs (prefiltered on
created_at == updated_at, so only a handful need a jobs lookup). Against
the live repository the first signal flags os-check.yml and nothing else
across 107 workflows, and reports clean on wolfTPM and wolfMQTT. It
exits 1 on a finding and 2 when the check could not be carried out at
all, because a missing token and a broken workflow call for different
responses.

Findings go into a single reused issue rather than another red check
that would blend into the noise: the body is rewritten on each run, a
comment is posted only when the set of affected workflows changes, and
the issue closes itself once everything loads again.

Finding that issue reliably turned out to be the fiddly part, and the
approach here is the one that survived testing against a live
repository. The issue is identified by both a dedicated label and its
title, and looked up through the REST issues endpoint. Both halves of
that identity matter: the label alone is a normal repository label that
anyone can apply, and an adopted issue has its body overwritten and is
then closed, so matching on the label alone would destroy a mislabelled
issue. Searching by title instead is unusable, because search ignores
--state and returns closed issues, which had the monitor re-closing an
already closed issue on every clean run. `gh issue list` reads a GraphQL
replica that can lag. The REST endpoint lags too, by about 2.4s for a
newly created issue, so the lookup re-checks a few times before
concluding nothing is open - without that, consecutive runs each open a
duplicate, and a clean run right after an outage fails to close the
issue it just opened.

Verified against the commit that caused the outage: check-workflows.py
fails on acff4d62a (21813 characters) and passes on its parent
f5ace71dd, which it flags at 20662 - already inside the warning band,
338 characters short of breaking. The full issue lifecycle (open,
repeat with no comment, comment on change, close, stay closed, reopen a
fresh issue for a new outage) was exercised end to end against a live
repository.
2026-08-03 17:05:29 +02:00

163 lines
5.7 KiB
Python
Executable File

#!/usr/bin/env python3
# Static lint for GitHub Actions workflow and composite-action files.
#
# The check that matters: GitHub caps a single `run:` step at 21000
# characters ("Exceeded max expression length 21000"). Exceeding it does
# not fail the step - GitHub refuses to load the entire workflow file, so
# every run of it ends in failure within 0s with zero jobs, no logs and no
# annotations. That is nearly invisible among a few hundred other checks:
# os-check.yml sat broken on master for ten days in July 2026 after an
# inlined config heredoc pushed one step from 20662 to 21813 characters.
#
# Because the cap is enforced by the Actions service rather than by the
# workflow schema, no YAML validator or actionlint run catches it. Hence
# this script.
#
# Sizes are measured the way GitHub sees them: parse the YAML, then take
# the length of the resulting `run` string. Block-scalar indentation is
# already stripped by the parser, so this needs no guessing about how the
# text was folded in the source file.
#
# Checks per file:
# * the file parses as YAML at all
# * every `run:` step is under the hard cap (error) and under the soft
# warning threshold (warning, so a growing list is noticed with
# runway left rather than at the cliff)
#
# Findings are emitted as GitHub workflow commands (::error / ::warning)
# so they surface as annotations on the run, and as plain text so the log
# is readable when run locally.
import argparse
import pathlib
import sys
import yaml
# GitHub's hard limit on a single run: expression.
HARD_LIMIT = 21000
# Report anything this large as a warning: enough runway to move the
# offending content out of the workflow before it becomes a failure.
SOFT_LIMIT = 18000
def iter_run_steps(doc: object) -> list[tuple[str, str]]:
"""Yield (location, script) for every `run:` step in a parsed file.
Covers both workflow files (jobs.<id>.steps[]) and composite actions
(runs.steps[]). Anything that is not shaped like a step list is
skipped rather than treated as an error: this script only measures
run steps, it is not a schema validator.
"""
found = []
def scan_steps(steps: object, where: str) -> None:
if not isinstance(steps, list):
return
for i, step in enumerate(steps):
if not isinstance(step, dict):
continue
script = step.get("run")
if not isinstance(script, str):
continue
name = step.get("name") or f"step {i + 1}"
found.append((f"{where} / {name}", script))
if not isinstance(doc, dict):
return found
jobs = doc.get("jobs")
if isinstance(jobs, dict):
for job_id, job in jobs.items():
if isinstance(job, dict):
scan_steps(job.get("steps"), f"jobs.{job_id}")
runs = doc.get("runs")
if isinstance(runs, dict):
scan_steps(runs.get("steps"), "runs")
return found
def check_file(path: pathlib.Path) -> tuple[int, int, int]:
"""Lint one file. Returns (errors, warnings, largest run: step)."""
errors = 0
warnings = 0
biggest = 0
try:
doc = yaml.safe_load(path.read_text())
except yaml.YAMLError as exc:
print(f"::error file={path}::not valid YAML: {exc}")
return (1, 0, 0)
for where, script in iter_run_steps(doc):
biggest = max(biggest, len(script))
size = len(script)
if size >= HARD_LIMIT:
over = size - HARD_LIMIT
print(f"::error file={path}::{where}: run: step is {size} "
f"characters, {over} over GitHub's {HARD_LIMIT} limit. "
f"GitHub will refuse to load this file and every run "
f"will fail in 0s with zero jobs. Move the bulk of the "
f"step out of the workflow - see .github/configs/ for "
f"the pattern used by the parallel-make-check.py "
f"workflows.")
errors += 1
elif size >= SOFT_LIMIT:
left = HARD_LIMIT - size
print(f"::warning file={path}::{where}: run: step is {size} "
f"characters, only {left} under GitHub's {HARD_LIMIT} "
f"limit. Move content out of the workflow now - at the "
f"limit the whole file stops loading.")
warnings += 1
return (errors, warnings, biggest)
def main() -> int:
p = argparse.ArgumentParser(
description="Lint GitHub Actions workflow files for the 21000 "
"character per-run-step limit.")
p.add_argument("paths", nargs="*", metavar="FILE",
help="files to check (default: all workflows and "
"composite actions under .github/)")
opts = p.parse_args()
if opts.paths:
paths = [pathlib.Path(f) for f in opts.paths]
else:
root = pathlib.Path(".github")
paths = sorted(root.glob("workflows/*.yml"))
paths += sorted(root.glob("workflows/*.yaml"))
paths += sorted(root.glob("actions/*/action.yml"))
paths += sorted(root.glob("actions/*/action.yaml"))
paths = [f for f in paths if f.is_file()]
if not paths:
print("no workflow files found", file=sys.stderr)
return 1
errors = 0
warnings = 0
biggest = 0
for path in paths:
e, w, b = check_file(path)
errors += e
warnings += w
biggest = max(biggest, b)
print(f"checked {len(paths)} files; largest run: step is {biggest} "
f"characters (limit {HARD_LIMIT})")
if errors:
print(f"FAILED: {errors} step(s) over the limit")
return 1
if warnings:
print(f"{warnings} step(s) approaching the limit")
return 0
if __name__ == "__main__":
sys.exit(main())