mirror of https://github.com/wolfSSL/wolfssl.git
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())
|