Files
rune/tests/test_workflow_script_contract.py
Nick b4ddd9d98c fix(pipeline): emit lowercase boolean env exports in auto-version.yml (#192)
Quality-gate finding 5: the convergence step printed json.load(...)['converged']
verbatim — Python True/False (capitalized) — while the safety-net steps
compared env.CONVERGED == 'false' (lowercase string). Both conditionals were
unreachable: the safety net could never bump and the drift advisory could
never fire. The export now wraps the value in str(...).lower().

Adds a static guard to tests/test_workflow_script_contract.py: every boolean
env export (CONVERGED/AUTO_BUMPED/DRIFT) in the parsed workflow run-steps
must apply .lower() or be built from lowercase literals; indirect echo
exports are resolved to their capture line.

Red evidence: guard flagged the json.load export (and its echo) on the
safety-net job. Green: 29/29 contract tests.
2026-09-07 14:52:51 +01:00

201 lines
9.1 KiB
Python

"""Env-contract guard between Gitea workflow steps and scripts/*.py (#192).
Spec-gate finding 1 discharge — the systematic guard: every step that invokes
a ``scripts/*.py`` entrypoint must provide every env var that script reads
with a hard ``os.environ[...]`` subscript. A read counts as satisfied when it
comes from any of:
* the step's own ``env:`` block (plus workflow/job-level ``env:``),
* a ``$GITHUB_ENV`` export from an earlier step in the same job (shell
``echo "NAME=..." >> "$GITHUB_ENV"`` or heredoc ``env.write(f"NAME=...")``),
* a plain shell ``export NAME=`` inside the invoking run block,
* a documented script-derived default (``SCRIPT_DERIVED`` below) — keys the
script derives itself from other env instead of requiring them.
Regression history: ``create_release.py`` subscript-read LIST_API_URL /
RELEASE_API_URL while ``auto-version.yml`` only ever passed
RELEASE_SERVER_URL/RELEASE_REPO — KeyError at runtime on both release paths.
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Any, Iterator
import pytest
import yaml
ROOT = Path(__file__).resolve().parents[1]
WORKFLOW_DIR = ROOT / ".gitea" / "workflows"
SCRIPTS_DIR = ROOT / "scripts"
WORKFLOWS = ("auto-version.yml", "maintenance.yml")
# Hard env reads: os.environ["KEY"] / os.environ['KEY'] (KeyError risk).
REQUIRED_ENV_RE = re.compile(r"""os\.environ\[["']([A-Za-z_][A-Za-z0-9_]*)["']\]""")
# Bare subscript of an allowlisted derived key — the derivation must be real.
_SUBSCRIPT_TEMPLATES = ('os.environ["{key}"', "os.environ['{key}'")
# GITHUB_ENV exports, shell form: echo "NAME=..." >> "$GITHUB_ENV"
GITHUB_ENV_SHELL_RE = re.compile(r"""([A-Za-z_][A-Za-z0-9_]*)=.*?>>\s*["']?\$\{?GITHUB_ENV\}?["']?""")
# GITHUB_ENV exports, python form: env.write(f"NAME=...") / env.write("NAME=...")
GITHUB_ENV_PY_RE = re.compile(r"""env\.write\(f?["']([A-Za-z_][A-Za-z0-9_]*)=""")
# Shell exports inside the invoking run block: export NAME=...
SHELL_EXPORT_RE = re.compile(r"^\s*export\s+([A-Za-z_][A-Za-z0-9_]*)=", re.MULTILINE)
# scripts/<name>.py invocation inside a run block
SCRIPT_INVOKE_RE = re.compile(r"scripts/([a-z_][a-z0-9_]*)\.py")
# Keys each script derives itself from other env instead of requiring them
# from the workflow. Guarded below: an allowlisted key must NOT be read with
# a bare subscript (that would KeyError when the derivation inputs are absent).
SCRIPT_DERIVED: dict[str, frozenset[str]] = {
"create_release": frozenset({"LIST_API_URL", "RELEASE_API_URL"}),
"auto_bump_pr": frozenset(),
"version_convergence": frozenset(),
"file_drift_issue": frozenset(),
}
def _load_workflow(name: str) -> dict[str, Any]:
path = WORKFLOW_DIR / name
assert path.exists(), f"workflow missing: {path}"
data = yaml.safe_load(path.read_text(encoding="utf-8"))
assert isinstance(data, dict), f"workflow did not parse to a mapping: {name}"
return data
def _invocations(workflow_name: str) -> Iterator[tuple[str, str, frozenset[str]]]:
"""Yield (step_id, script_name, provided_env) per scripts/*.py invocation.
``provided_env`` models runtime visibility faithfully: step env + same-run
shell exports are visible to the invoking step; GITHUB_ENV exports only
become visible to *subsequent* steps, so they are accumulated after the
step's own invocations are checked.
"""
workflow = _load_workflow(workflow_name)
for job_name, job in (workflow.get("jobs") or {}).items():
github_env: set[str] = set()
for idx, step in enumerate(job.get("steps") or []):
run = step.get("run") or ""
step_env = frozenset((step.get("env") or {}).keys())
shell_exports = frozenset(SHELL_EXPORT_RE.findall(run))
for match in SCRIPT_INVOKE_RE.finditer(run):
step_id = f"{workflow_name}:{job_name}/step{idx}"
yield step_id, match.group(1), step_env | shell_exports | frozenset(github_env)
github_env |= set(GITHUB_ENV_SHELL_RE.findall(run))
github_env |= set(GITHUB_ENV_PY_RE.findall(run))
def _params() -> list[tuple[str, str, frozenset[str]]]:
cases: list[tuple[str, str, frozenset[str]]] = []
for workflow_name in WORKFLOWS:
cases.extend(_invocations(workflow_name))
assert cases, "no scripts/*.py invocations found — guard is vacuous"
return cases
def _script_source(script: str) -> str:
return (SCRIPTS_DIR / f"{script}.py").read_text(encoding="utf-8")
@pytest.mark.parametrize(("step_id", "script", "provided"), _params(), ids=lambda v: str(v))
def test_step_provides_every_required_env_read(step_id: str, script: str, provided: frozenset[str]) -> None:
source = _script_source(script)
required = set(REQUIRED_ENV_RE.findall(source))
missing = required - provided - SCRIPT_DERIVED.get(script, frozenset())
assert not missing, (
f"{step_id}: scripts/{script}.py hard-reads {sorted(missing)} from os.environ "
f"but the workflow provides none of them (step/job env, $GITHUB_ENV exports, "
f"shell exports, or a documented script-derived default)"
)
@pytest.mark.parametrize(("step_id", "script", "provided"), _params(), ids=lambda v: str(v))
def test_derived_allowlist_is_honest(step_id: str, script: str, provided: frozenset[str]) -> None:
"""An allowlisted derived key must genuinely be derived: no bare subscript
read of it may remain in the script (that would KeyError at runtime)."""
source = _script_source(script)
for key in SCRIPT_DERIVED.get(script, frozenset()):
for template in _SUBSCRIPT_TEMPLATES:
assert template.format(key=key) not in source, (
f"{step_id}: scripts/{script}.py subscripts {key} directly — "
f"either provide it from the workflow or derive it in-script"
)
# ---------------------------------------------------------------------------
# Boolean env exports (quality-gate finding 5, #192)
# ---------------------------------------------------------------------------
def _run_steps(workflow_name: str) -> Iterator[tuple[str, str, int]]:
"""Yield (job_name, run_block, step_index) for every run step."""
workflow = _load_workflow(workflow_name)
for job_name, job in (workflow.get("jobs") or {}).items():
for idx, step in enumerate(job.get("steps") or []):
run = step.get("run")
if run:
yield job_name, run, idx
# A GITHUB_ENV export of a boolean-typed key must emit lowercase true/false:
# workflow conditionals compare against the string 'true'/'false', and Python
# str(True) is capitalized — auto-version.yml emitted 'True'/'False' from
# json.load while the steps compared env.CONVERGED == 'false', making the
# safety-net conditionals unreachable.
BOOLEAN_ENV_KEYS = frozenset({"CONVERGED", "AUTO_BUMPED", "DRIFT"})
_LOWERCASE_LITERAL_RE = re.compile(r"""['"](true|false)['"]""")
_JSON_BOOL_ACCESS_RE = re.compile(r"""json\.load\(|\[\s*['"](?:converged|merged)['"]\s*\]""")
def _value_expr(line: str, lines: list[str], key: str) -> str | None:
"""The value-producing expression behind a boolean key export/capture."""
capture = re.search(rf"^\s*{key}=\$\((.*)\)\s*$", line)
if capture:
return capture.group(1)
write = re.search(rf"""env\.write\(f?["']{key}=(.*?)["']\)$""", line.strip())
if write:
return write.group(1)
echo = re.search(rf'echo\s+"{key}=(\$[A-Za-z_][A-Za-z0-9_]*)"', line)
if echo: # indirect: resolve the earlier capture line in the same run block
ref = echo.group(1).lstrip("${").rstrip("}")
for other in lines:
other_capture = re.search(rf"^\s*{re.escape(ref)}=\$\((.*)\)\s*$", other)
if other_capture:
return other_capture.group(1)
return echo.group(1) # unresolvable — not provably lowercase
return None
def _emits_lowercase(expr: str) -> bool:
if ".lower()" in expr:
return True
if _LOWERCASE_LITERAL_RE.search(expr):
return True # built from explicit lowercase literals
if re.search(r"\b(True|False)\b", expr):
return False # Python bool reprs leak capitalized
if _JSON_BOOL_ACCESS_RE.search(expr):
return False # bool-typed json field without .lower()
return True # opaque shell var / workflow expression (rendered lowercase)
@pytest.mark.parametrize(
("workflow_name", "job", "run", "idx"),
[(w, j, r, i) for w in WORKFLOWS for (j, r, i) in _run_steps(w)],
)
def test_boolean_env_exports_are_lowercase(workflow_name: str, job: str, run: str, idx: int) -> None:
lines = run.splitlines()
offenders = []
for key in BOOLEAN_ENV_KEYS:
for line in lines:
if not (re.search(rf"\b{key}=", line) or f'"{key}=' in line):
continue
expr = _value_expr(line, lines, key)
if expr is not None and not _emits_lowercase(expr):
offenders.append(f"[{key}] {line.strip()}")
assert not offenders, (
f"{workflow_name}:{job}/step{idx}: boolean env exports must force lowercase "
f"(str(...).lower()) or use lowercase literals — conditionals compare "
f"env.CONVERGED == 'false'-style strings: {offenders}"
)