Review findings on PR #13: - release-branch-semver gives 0.1.0.devN without tags, so the 0.0 guard never fired; check_version() now requires >= 0.2 (unit-tested) - the APPSKELLINGTON_DEBUG child import could resolve the source tree from the cwd and false-pass; it now reports its location and must match - test_ci_workflow: no continue-on-error, matrix python drives container and UV_PYTHON, WIP gate compared whole (mutation-tested) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
136 lines
4.5 KiB
Python
136 lines
4.5 KiB
Python
"""Pins the CI workflow's invariants (spec: docs/superpowers/specs/
|
|
2026-10-04-modernization-design.md). CI drift shows up as a red test here."""
|
|
|
|
import pathlib
|
|
import re
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
WORKFLOW = ROOT / ".gitea" / "workflows" / "ci.yaml"
|
|
VERIFY_SCRIPT = ROOT / "scripts" / "verify_index_release.sh"
|
|
|
|
# Newest CPython the project claims. To add a version, bump this, the
|
|
# matrix, the classifiers and the verify script's list together.
|
|
NEWEST_MINOR = 14
|
|
|
|
WIP_CLAUSES = (
|
|
"!startsWith(github.event.pull_request.title, 'WIP:')",
|
|
"!startsWith(github.event.pull_request.title, '[WIP]')",
|
|
)
|
|
|
|
|
|
def load():
|
|
return yaml.safe_load(WORKFLOW.read_text())
|
|
|
|
|
|
def triggers(wf):
|
|
# PyYAML follows YAML 1.1, where the bare key `on` parses as boolean True.
|
|
return wf["on"] if "on" in wf else wf[True]
|
|
|
|
|
|
def expected_versions():
|
|
text = (ROOT / "pyproject.toml").read_text()
|
|
floor = int(re.search(r'requires-python\s*=\s*">=3\.(\d+)"', text).group(1))
|
|
return ["3.%d" % minor for minor in range(floor, NEWEST_MINOR + 1)]
|
|
|
|
|
|
def flat(text):
|
|
return " ".join(text.split())
|
|
|
|
|
|
def test_triggers():
|
|
on = triggers(load())
|
|
assert set(on["pull_request"]["types"]) == {
|
|
"opened",
|
|
"synchronize",
|
|
"reopened",
|
|
"edited",
|
|
}
|
|
assert on["push"]["branches"] == ["develop", "main"]
|
|
assert "workflow_dispatch" in on
|
|
|
|
|
|
def test_superseded_pr_runs_cancel_but_push_runs_never_do():
|
|
assert (
|
|
load()["concurrency"]["cancel-in-progress"]
|
|
== "${{ github.event_name == 'pull_request' }}"
|
|
)
|
|
|
|
|
|
def test_every_job_is_wip_gated():
|
|
# Compared whole, not by substring: `&&` turned into `||` must fail.
|
|
gate = "github.event_name != 'pull_request' || ( %s && %s )" % WIP_CLAUSES
|
|
for name, job in load()["jobs"].items():
|
|
cond = flat(job.get("if", ""))
|
|
assert cond in (gate, "always() && ( %s )" % gate), name
|
|
|
|
|
|
def test_no_gate_can_be_softened_by_continue_on_error():
|
|
# continue-on-error makes needs.<job>.result read success, so ci-ok
|
|
# would go green over a failure.
|
|
for name, job in load()["jobs"].items():
|
|
assert "continue-on-error" not in job, name
|
|
for step in job.get("steps", []):
|
|
assert "continue-on-error" not in step, name
|
|
|
|
|
|
def test_each_lane_really_runs_its_matrix_python():
|
|
test = load()["jobs"]["test"]
|
|
assert test["container"] == "python:${{ matrix.python }}"
|
|
assert test["env"]["UV_PYTHON"] == "${{ matrix.python }}"
|
|
assert test["env"]["UV_PYTHON_DOWNLOADS"] == "never"
|
|
|
|
|
|
def test_matrix_matches_requires_python():
|
|
test = load()["jobs"]["test"]
|
|
assert test["strategy"]["matrix"]["python"] == expected_versions()
|
|
assert test["strategy"]["fail-fast"] is False
|
|
|
|
|
|
def test_classifiers_match_matrix():
|
|
text = (ROOT / "pyproject.toml").read_text()
|
|
claimed = re.findall(r'"Programming Language :: Python :: (3\.\d+)"', text)
|
|
assert claimed == expected_versions()
|
|
|
|
|
|
@pytest.mark.skipif(not VERIFY_SCRIPT.exists(), reason="added with the release task")
|
|
def test_verify_script_covers_matrix():
|
|
match = re.search(r"^pythons=\(([^)]*)\)", VERIFY_SCRIPT.read_text(), re.M)
|
|
assert match.group(1).split() == expected_versions()
|
|
|
|
|
|
def test_checkout_fetches_full_history_for_setuptools_scm():
|
|
for name, job in load()["jobs"].items():
|
|
for step in job.get("steps", []):
|
|
if step.get("uses", "").startswith("actions/checkout"):
|
|
assert step.get("with", {}).get("fetch-depth") == 0, name
|
|
|
|
|
|
def test_lint_job_runs_ruff():
|
|
runs = flat(" ".join(s.get("run", "") for s in load()["jobs"]["lint"]["steps"]))
|
|
assert "uv run ruff check" in runs
|
|
assert "uv run ruff format --check" in runs
|
|
|
|
|
|
def test_test_lanes_run_locked_suite_and_smoke_test_both_artifacts():
|
|
runs = flat(" ".join(s.get("run", "") for s in load()["jobs"]["test"]["steps"]))
|
|
assert "uv sync --locked" in runs
|
|
assert "uv run pytest" in runs
|
|
assert "uv build" in runs
|
|
assert "dist/*.whl dist/*.tar.gz" in runs
|
|
assert "scripts/smoke_import.py" in runs
|
|
|
|
|
|
def test_ci_ok_fails_unless_every_gate_succeeded():
|
|
jobs = load()["jobs"]
|
|
ci_ok = jobs["ci-ok"]
|
|
# Gitea reports a skipped job as success, so ci-ok must run always()
|
|
# and check each result explicitly.
|
|
assert set(ci_ok["needs"]) == set(jobs) - {"ci-ok"}
|
|
assert flat(ci_ok["if"]).startswith("always() &&")
|
|
runs = " ".join(s.get("run", "") for s in ci_ok["steps"])
|
|
for needed in ci_ok["needs"]:
|
|
assert 'test "${{ needs.%s.result }}" = success' % needed in runs
|