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.
201 lines
9.1 KiB
Python
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}"
|
|
)
|