Files
025-squirrel/tests/test_workflow.py
T
2026-10-13 15:59:25 +08:00

168 lines
5.7 KiB
Python

"""Static checks of the CI workflow (QC-WF-001 criterion 24, MIL-001 task 7)."""
import re
from collections.abc import Hashable, Iterator
from pathlib import Path
from typing import Any
import pytest
import yaml
ROOT = Path(__file__).parents[1]
WORKFLOW_PATH = ROOT / ".gitea" / "workflows" / "ci.yml"
GITHUB_WORKFLOW_PATH = ROOT / ".github" / "workflows" / "ci.yml"
MAX_RUN_LINES = 5
EXACT_TAG = re.compile(r"@v\d+\.\d+\.\d+$")
FULL_SHA = re.compile(r"@[0-9a-f]{40}$")
# Parsed YAML is untyped data, so Any is the honest type here.
Workflow = dict[Any, Any]
class UniqueKeyLoader(yaml.SafeLoader):
"""Safe loader that rejects a mapping with a repeated key."""
def construct_mapping(
self, node: yaml.MappingNode, deep: bool = False
) -> dict[Hashable, Any]:
"""Build the mapping, raising ValueError on a duplicate key."""
seen: set[Hashable] = set()
for key_node, _ in node.value:
key = self.construct_object(key_node, deep=deep)
if key in seen:
raise ValueError(f"duplicate key {key!r} at {key_node.start_mark}")
seen.add(key)
return super().construct_mapping(node, deep=deep)
def load_workflow() -> Workflow:
"""Parse the workflow with the safe loader."""
loaded = yaml.safe_load(WORKFLOW_PATH.read_text(encoding="utf-8"))
assert isinstance(loaded, dict)
return loaded
def triggers(workflow: Workflow) -> Any:
"""Return the trigger section; YAML 1.1 reads the key `on` as True."""
return workflow["on"] if "on" in workflow else workflow[True]
def trigger_names(workflow: Workflow) -> set[str]:
"""Return the names of the events that trigger the workflow."""
section = triggers(workflow)
if isinstance(section, str):
return {section}
return {str(name) for name in section}
def jobs(workflow: Workflow) -> dict[str, Workflow]:
"""Return the jobs by id."""
found: dict[str, Workflow] = workflow["jobs"]
return found
def steps(job: Workflow) -> list[Workflow]:
"""Return the steps of a job."""
found: list[Workflow] = job.get("steps", [])
return found
def all_steps(workflow: Workflow) -> Iterator[Workflow]:
"""Yield every step of every job."""
for job in jobs(workflow).values():
yield from steps(job)
def exceeds_line_limit(script: str, limit: int = MAX_RUN_LINES) -> bool:
"""Tell whether a script has more lines than the limit, as written.
Blank lines and comments count.
"""
return len(script.splitlines()) > limit
def test_workflow_parses_with_required_keys() -> None:
"""The file is valid YAML with a name, a trigger and jobs."""
workflow = load_workflow()
assert "name" in workflow
assert "on" in workflow or True in workflow
assert "jobs" in workflow
def test_workflow_has_no_duplicate_keys() -> None:
"""No mapping in the file repeats a key."""
text = WORKFLOW_PATH.read_text(encoding="utf-8")
assert yaml.load(text, Loader=UniqueKeyLoader) is not None
def test_duplicate_key_loader_rejects_repeated_key() -> None:
"""The duplicate-key loader bites on a repeated key."""
with pytest.raises(ValueError, match="duplicate key"):
yaml.load("a: 1\na: 2\n", Loader=UniqueKeyLoader)
def test_permissions_are_declared() -> None:
"""Permissions are set at workflow level or on every job."""
workflow = load_workflow()
if "permissions" in workflow:
return
assert all("permissions" in job for job in jobs(workflow).values())
def test_every_job_has_runner_and_timeout() -> None:
"""Each job names its runner and a timeout."""
for job_id, job in jobs(load_workflow()).items():
assert "runs-on" in job, job_id
assert "timeout-minutes" in job, job_id
def test_every_step_has_a_name() -> None:
"""Each step carries a name."""
for job_id, job in jobs(load_workflow()).items():
for step in steps(job):
assert "name" in step, f"{job_id}: {step}"
def test_step_names_are_unique_within_a_job() -> None:
"""No two steps of one job share a name."""
for job_id, job in jobs(load_workflow()).items():
names = [step["name"] for step in steps(job) if "name" in step]
assert len(names) == len(set(names)), job_id
def test_uses_are_pinned_to_exact_release_or_sha() -> None:
"""Each action is pinned to an exact tag or a full commit SHA."""
for step in all_steps(load_workflow()):
if "uses" in step:
ref = str(step["uses"])
assert EXACT_TAG.search(ref) or FULL_SHA.search(ref), ref
def test_run_blocks_stay_within_line_limit() -> None:
"""No inline script is longer than the limit."""
for step in all_steps(load_workflow()):
if "run" in step:
assert not exceeds_line_limit(str(step["run"])), step.get("name")
def test_line_limit_check_rejects_six_lines() -> None:
"""The line-limit helper counts lines as written, blank lines included."""
assert exceeds_line_limit("a\nb\nc\nd\ne\nf\n")
assert exceeds_line_limit("a\nb\nc\nd\ne\n\n")
assert not exceeds_line_limit("a\nb\n\nc\nd\n")
def test_workflow_uses_no_secrets() -> None:
"""The file refers to no secret."""
assert "secrets." not in WORKFLOW_PATH.read_text(encoding="utf-8")
def test_workflow_is_not_triggered_by_pull_request_target() -> None:
"""The privileged pull_request_target trigger is not used."""
assert "pull_request_target" not in trigger_names(load_workflow())
def test_workflow_is_not_duplicated_for_github() -> None:
"""The same workflow does not also live under .github/workflows."""
assert not GITHUB_WORKFLOW_PATH.exists()