|
| 1 | +"""Holds the shared module-check action to the two couplings that fail silently. |
| 2 | +
|
| 3 | +`ci.yml` and `security.yml` both need the module completeness check — CI gates golangci-lint's |
| 4 | +module matrix on it, security.yml gates govulncheck's and pip-audit's — and jobs cannot be |
| 5 | +depended on across workflows, so both run it. `.github/actions/check-modules` exists so the two |
| 6 | +callers share one definition of *how*: before it, each restated the Python pin and two action |
| 7 | +SHAs, and a diverged copy would have run the same check under a different interpreter in each |
| 8 | +workflow with nothing to say so. |
| 9 | +
|
| 10 | +That is the first coupling here: a caller that re-inlines the steps gets its own copy back, and |
| 11 | +CI stays green while it drifts. |
| 12 | +
|
| 13 | +The second is the Renovate pattern. The pin moved out of `.github/workflows/` when the steps did, |
| 14 | +and the customManager that claims `# renovate:` markers is scoped by file path — so the pattern |
| 15 | +had to widen to `.github/actions/*/action.yml` in the same change. README's "Marker-driven" |
| 16 | +records that this class of failure is silent and has already happened twice: the pin simply stops |
| 17 | +moving and Renovate says nothing. |
| 18 | +""" |
| 19 | + |
| 20 | +import json |
| 21 | +import re |
| 22 | +import unittest |
| 23 | +from pathlib import Path |
| 24 | + |
| 25 | +# Not .resolve(): every file read here is a cross-package data dep, so each lives in the runfiles |
| 26 | +# tree beside this one rather than at the source path a resolved symlink would lead back to. |
| 27 | +_ROOT = Path(__file__).parent.parent.parent |
| 28 | +# Repo-relative, because that is what Renovate matches its file patterns against — passing a |
| 29 | +# bare filename here would miss every anchored pattern and quietly claim nothing. |
| 30 | +_ACTION_PATH = ".github/actions/check-modules/action.yml" |
| 31 | +_ACTION = _ROOT / _ACTION_PATH |
| 32 | +_RENOVATE = _ROOT / "renovate.json" |
| 33 | +_CALLERS = ( |
| 34 | + _ROOT / ".github" / "workflows" / "ci.yml", |
| 35 | + _ROOT / ".github" / "workflows" / "security.yml", |
| 36 | +) |
| 37 | + |
| 38 | +# The path a workflow `uses:` to reach the action, and the script that action is the wrapper for. |
| 39 | +_USES = "uses: ./.github/actions/check-modules" |
| 40 | +_SCRIPT = "meta/scripts/check_modules.py" |
| 41 | + |
| 42 | + |
| 43 | +def job_block(text: str, job: str) -> str: |
| 44 | + """One job's lines: its key through the line before the next job key.""" |
| 45 | + start = re.search(rf"^ {re.escape(job)}:$", text, re.M) |
| 46 | + if start is None: |
| 47 | + raise AssertionError(f"no `{job}:` job") |
| 48 | + rest = text[start.end() :] |
| 49 | + end = re.search(r"^ [a-zA-Z_][\w-]*:$", rest, re.M) |
| 50 | + return rest[: end.start()] if end else rest |
| 51 | + |
| 52 | + |
| 53 | +class DelegationTest(unittest.TestCase): |
| 54 | + """Both callers reach the check through the action rather than through steps of their own.""" |
| 55 | + |
| 56 | + def test_every_caller_uses_the_action(self): |
| 57 | + for caller in _CALLERS: |
| 58 | + with self.subTest(workflow=caller.name): |
| 59 | + block = job_block(caller.read_text(encoding="utf-8"), "modules-check") |
| 60 | + self.assertIn( |
| 61 | + _USES, |
| 62 | + block, |
| 63 | + "this job runs the module check without the shared action, so its setup " |
| 64 | + "steps are a second copy that nothing holds to the first", |
| 65 | + ) |
| 66 | + |
| 67 | + def test_no_caller_reinlines_the_script(self): |
| 68 | + """The tell that a copy came back: the workflow invoking the script directly again.""" |
| 69 | + for caller in _CALLERS: |
| 70 | + with self.subTest(workflow=caller.name): |
| 71 | + block = job_block(caller.read_text(encoding="utf-8"), "modules-check") |
| 72 | + self.assertNotIn(_SCRIPT, block, "call it through the action, not beside it") |
| 73 | + |
| 74 | + def test_the_action_runs_the_script(self): |
| 75 | + """Non-vacuity guard: the assertions above mean nothing if the action does not run it.""" |
| 76 | + self.assertIn(_SCRIPT, _ACTION.read_text(encoding="utf-8")) |
| 77 | + |
| 78 | + |
| 79 | +class RenovateCoverageTest(unittest.TestCase): |
| 80 | + """The action's pin is only tracked while a customManager's file pattern reaches it.""" |
| 81 | + |
| 82 | + def setUp(self): |
| 83 | + self.action = _ACTION.read_text(encoding="utf-8") |
| 84 | + config = json.loads(_RENOVATE.read_text(encoding="utf-8")) |
| 85 | + self.managers = [m for m in config["customManagers"] if m["customType"] == "regex"] |
| 86 | + |
| 87 | + def claims(self, path: str, text: str) -> list[tuple[str | None, str]]: |
| 88 | + """Every (depName, currentValue) a manager whose pattern reaches `path` finds in `text`. |
| 89 | +
|
| 90 | + depName is None for the managers that name the dep in `depNameTemplate` instead of a |
| 91 | + capture group, so callers filter on it rather than assuming it is there. |
| 92 | + """ |
| 93 | + found = [] |
| 94 | + for manager in self.managers: |
| 95 | + patterns = [re.compile(p.strip("/")) for p in manager["managerFilePatterns"]] |
| 96 | + if not any(p.search(path) for p in patterns): |
| 97 | + continue |
| 98 | + for match in manager["matchStrings"]: |
| 99 | + # Renovate's regexes are JS-flavoured; only the named-group spelling differs. |
| 100 | + for hit in re.finditer(match.replace("(?<", "(?P<"), text): |
| 101 | + groups = hit.groupdict() |
| 102 | + found.append((groups.get("depName"), groups["currentValue"])) |
| 103 | + return found |
| 104 | + |
| 105 | + def test_the_action_carries_a_python_pin(self): |
| 106 | + """Non-vacuity guard: a pin that moved back out would pass the coverage test trivially.""" |
| 107 | + self.assertRegex( |
| 108 | + self.action, |
| 109 | + r'python-version: "\d+\.\d+"', |
| 110 | + "the action no longer pins Python; the coverage assertion below has nothing to hold", |
| 111 | + ) |
| 112 | + |
| 113 | + def test_renovate_claims_the_actions_python_pin(self): |
| 114 | + claimed = self.claims(_ACTION_PATH, self.action) |
| 115 | + self.assertIn( |
| 116 | + "python", |
| 117 | + [dep for dep, _ in claimed], |
| 118 | + "no customManager pattern reaches `.github/actions/*/action.yml`, so this pin is " |
| 119 | + "frozen — Renovate reports nothing when a marker stops being claimed", |
| 120 | + ) |
| 121 | + |
| 122 | + def test_the_pin_matches_the_workflows_that_still_carry_one(self): |
| 123 | + """Divergent copies are two dependencies to Renovate, and they drift apart separately.""" |
| 124 | + versions = { |
| 125 | + value for dep, value in self.claims(_ACTION_PATH, self.action) if dep == "python" |
| 126 | + } |
| 127 | + self.assertTrue(versions, "the action's own pin was not read; the comparison is vacuous") |
| 128 | + for caller in _CALLERS: |
| 129 | + text = caller.read_text(encoding="utf-8") |
| 130 | + versions |= { |
| 131 | + value |
| 132 | + for dep, value in self.claims(f".github/workflows/{caller.name}", text) |
| 133 | + if dep == "python" |
| 134 | + } |
| 135 | + self.assertEqual( |
| 136 | + len(versions), |
| 137 | + 1, |
| 138 | + f"the Python pin is spelled {sorted(versions)} across the action and its callers; " |
| 139 | + "keep duplicate pins byte-identical or Renovate tracks them as separate updates", |
| 140 | + ) |
| 141 | + |
| 142 | + |
| 143 | +if __name__ == "__main__": |
| 144 | + unittest.main() |
0 commit comments