mirror of
https://github.com/wolfSSL/wolfssl.git
synced 2026-08-15 18:41:26 +02:00
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 onacff4d62a(21813 characters) and passes on its parentf5ace71dd, 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.
163 lines
5.7 KiB
Python
Executable File
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())
|