|
47 | 47 | import re |
48 | 48 |
|
49 | 49 | import pytest |
| 50 | +import yaml |
50 | 51 | from click_extra import schema_field_infos |
51 | 52 |
|
52 | 53 | from repomatic.bundle import get_data_content |
53 | 54 | from repomatic.config import Config |
54 | 55 | from repomatic.prepare_release import SELF_PIN_COOLDOWN_EXEMPTION |
55 | 56 | from repomatic.registry import COMPONENTS_BY_NAME, SKILL_FILENAME |
56 | 57 |
|
57 | | -from .conftest import PROJECT_ROOT |
| 58 | +from .conftest import PROJECT_ROOT, WORKFLOWS_DIR |
58 | 59 |
|
59 | 60 | CODE_SPAN_RE = re.compile(r"`([^`\n]+)`") |
60 | 61 | """Inline code span, the only context these checks read. |
|
87 | 88 | MODULE_PATH_RE = re.compile(r"^(repomatic/[a-z0-9_/]+\.py)$") |
88 | 89 | """A package module quoted as a repository-relative path.""" |
89 | 90 |
|
| 91 | +WORKFLOW_FILE_RE = re.compile(r"^(_?[a-z][a-z0-9-]*\.yaml)$") |
| 92 | +"""A workflow filename quoted on its own.""" |
| 93 | + |
| 94 | +WORKFLOW_JOB_RE = re.compile( |
| 95 | + r"`(?P<workflow>_?[a-z][a-z0-9-]*\.yaml)` workflow's `(?P<job>[a-z][a-z0-9-]*)` job" |
| 96 | +) |
| 97 | +"""An asset attributing a job to the workflow that declares it. |
| 98 | +
|
| 99 | +Matched across the prose rather than inside one code span, because the |
| 100 | +claim spans two of them. The possessive phrasing is what makes it safe to |
| 101 | +read as an assertion: a line merely listing several workflows and a job |
| 102 | +name never takes this shape, so the rule stays as under-inclusive as its |
| 103 | +siblings while still pinning the one form that states ownership. |
| 104 | +""" |
| 105 | + |
90 | 106 |
|
91 | 107 | def bundled_assets() -> list[tuple[str, str]]: |
92 | 108 | """Every bundled skill and agent, as `(asset id, body)` pairs. |
@@ -187,3 +203,42 @@ def test_module_paths_exist(asset_id: str, body: str) -> None: |
187 | 203 | assert (PROJECT_ROOT / module).is_file(), ( |
188 | 204 | f"{asset_id} points at {module}, which no longer exists." |
189 | 205 | ) |
| 206 | + |
| 207 | + |
| 208 | +@bundled_asset |
| 209 | +def test_workflow_files_exist(asset_id: str, body: str) -> None: |
| 210 | + """Every workflow filename quoted in an asset is still a workflow. |
| 211 | +
|
| 212 | + Assets name workflows constantly: which one to poll, which one runs a |
| 213 | + fix, which one to dispatch. A renamed or retired workflow leaves those |
| 214 | + sentences pointing at a file no `gh workflow run` will find. |
| 215 | + """ |
| 216 | + for span in CODE_SPAN_RE.findall(body): |
| 217 | + match = WORKFLOW_FILE_RE.match(span.strip()) |
| 218 | + if not match: |
| 219 | + continue |
| 220 | + workflow = match.group(1) |
| 221 | + assert (WORKFLOWS_DIR / workflow).is_file(), ( |
| 222 | + f"{asset_id} names {workflow}, which is not a workflow file." |
| 223 | + ) |
| 224 | + |
| 225 | + |
| 226 | +@bundled_asset |
| 227 | +def test_attributed_jobs_live_in_the_named_workflow(asset_id: str, body: str) -> None: |
| 228 | + """A job an asset attributes to a workflow is declared by that workflow. |
| 229 | +
|
| 230 | + Both halves of the claim can outlive the claim itself: `update-docs` |
| 231 | + moved from `docs.yaml` to `autofix.yaml` in `5.7.1`, and the |
| 232 | + `sphinx-docs` agent kept crediting `docs.yaml` for eleven releases, |
| 233 | + because the workflow still existed and so did the job. Only the pairing |
| 234 | + was wrong, which is exactly what no existence check can see. |
| 235 | + """ |
| 236 | + for match in WORKFLOW_JOB_RE.finditer(body): |
| 237 | + workflow, job = match.group("workflow"), match.group("job") |
| 238 | + path = WORKFLOWS_DIR / workflow |
| 239 | + assert path.is_file(), f"{asset_id} names {workflow}, which is not a workflow." |
| 240 | + jobs = yaml.safe_load(path.read_text(encoding="UTF-8")).get("jobs", {}) |
| 241 | + assert job in jobs, ( |
| 242 | + f"{asset_id} credits {workflow} with a {job!r} job, which it does not " |
| 243 | + f"declare. Its jobs are: {', '.join(sorted(jobs))}." |
| 244 | + ) |
0 commit comments