|
| 1 | +"""Jede GitHub-Action haengt an einem Commit-SHA, nicht an einem Tag. |
| 2 | +
|
| 3 | +Ein Tag ist beweglich. `actions/checkout@v7` und `pypa/...@release/v1` zeigen |
| 4 | +auf das, worauf der Herausgeber sie zeigen laesst — auch nachtraeglich, auch |
| 5 | +auf anderen Code als gestern. Bei `publish.yml` haengt daran ein Job mit |
| 6 | +`id-token: write`, der auf PyPI veroeffentlichen darf. |
| 7 | +
|
| 8 | +Der Rueckfall ist leise: Ein Tag-Pin macht kein Gate rot, er nimmt der |
| 9 | +Lieferkette nur die Nachpruefbarkeit. Deshalb steht hier ein Test. |
| 10 | +
|
| 11 | +Der Kommentar hinter dem SHA ist nicht Zierde: Ohne ihn steht in der Datei |
| 12 | +eine 40-stellige Zahl, und niemand sieht mehr, welche Version das ist oder ob |
| 13 | +sie alt ist. |
| 14 | +""" |
| 15 | + |
| 16 | +from __future__ import annotations |
| 17 | + |
| 18 | +import pathlib |
| 19 | +import re |
| 20 | + |
| 21 | +_ROOT = pathlib.Path(__file__).resolve().parents[1] |
| 22 | +_WORKFLOWS = _ROOT / ".github" / "workflows" |
| 23 | + |
| 24 | +# `uses:` mit Wert, Kommentarzeilen sind vorher raus. |
| 25 | +_USES = re.compile(r"^\s*-?\s*uses:\s*(?P<ref>\S+)\s*(?P<rest>.*)$") |
| 26 | +_SHA = re.compile(r"^[0-9a-f]{40}$") |
| 27 | +# `# v1.2.3` oder `# v1` — irgendeine lesbare Versionsangabe. |
| 28 | +_VERSIONSKOMMENTAR = re.compile(r"#\s*v?\d[\w.\-+]*") |
| 29 | + |
| 30 | + |
| 31 | +def _workflow_dateien() -> list[pathlib.Path]: |
| 32 | + """Beide Endungen: GitHub laedt `*.yml` UND `*.yaml`.""" |
| 33 | + return sorted([*_WORKFLOWS.glob("*.yml"), *_WORKFLOWS.glob("*.yaml")]) |
| 34 | + |
| 35 | + |
| 36 | +def _verwendungen() -> list[tuple[pathlib.Path, str, str]]: |
| 37 | + """(Datei, Referenz, Rest der Zeile) je `uses:`-Zeile.""" |
| 38 | + gefunden = [] |
| 39 | + for wf in _workflow_dateien(): |
| 40 | + for zeile in wf.read_text(encoding="utf-8").splitlines(): |
| 41 | + if zeile.lstrip().startswith("#"): |
| 42 | + continue |
| 43 | + treffer = _USES.match(zeile) |
| 44 | + if treffer: |
| 45 | + gefunden.append((wf, treffer.group("ref"), treffer.group("rest"))) |
| 46 | + return gefunden |
| 47 | + |
| 48 | + |
| 49 | +def test_der_scan_findet_ueberhaupt_actions() -> None: |
| 50 | + """Sichert die Zusicherungen unten gegen eine leere Liste ab. |
| 51 | +
|
| 52 | + Faende der Scan nichts — umbenanntes Verzeichnis, geaenderte Schreibweise —, |
| 53 | + waeren die Schleifen leer und beide Tests trivialerweise wahr. |
| 54 | + """ |
| 55 | + verwendungen = _verwendungen() |
| 56 | + assert len(verwendungen) >= 5, f"verdaechtig wenige `uses:` gefunden: {verwendungen}" |
| 57 | + |
| 58 | + |
| 59 | +def test_jede_action_haengt_an_einem_commit_sha() -> None: |
| 60 | + """Ein Tag kann umgehaengt werden, ein Commit-SHA nicht.""" |
| 61 | + lose = [] |
| 62 | + for wf, ref, _ in _verwendungen(): |
| 63 | + if ref.startswith("./"): |
| 64 | + continue # lokale Action im Repo, kein Fremdcode |
| 65 | + _, _, version = ref.partition("@") |
| 66 | + if not _SHA.match(version): |
| 67 | + lose.append(f"{wf.name}: {ref}") |
| 68 | + assert not lose, ( |
| 69 | + f"nicht auf einen Commit-SHA gepinnt: {lose}. Ein Tag oder Branch zeigt auf " |
| 70 | + "das, worauf der Herausgeber ihn zeigen laesst — auch nachtraeglich. " |
| 71 | + "SHA ermitteln mit: git ls-remote https://github.com/<owner>/<repo> " |
| 72 | + "'refs/tags/<tag>^{}'" |
| 73 | + ) |
| 74 | + |
| 75 | + |
| 76 | +def test_jeder_pin_sagt_welche_version_er_ist() -> None: |
| 77 | + """Sonst steht da eine 40-stellige Zahl und niemand sieht, wie alt sie ist.""" |
| 78 | + ohne = [] |
| 79 | + for wf, ref, rest in _verwendungen(): |
| 80 | + if ref.startswith("./"): |
| 81 | + continue |
| 82 | + if not _VERSIONSKOMMENTAR.search(rest): |
| 83 | + ohne.append(f"{wf.name}: {ref}") |
| 84 | + assert not ohne, ( |
| 85 | + f"Pin ohne Versionskommentar: {ohne}. Erwartet wird `uses: owner/repo@<sha> # v1.2.3`." |
| 86 | + ) |
0 commit comments