Sperrliste in der CI: angenommene ADRs und docs/sources sind unveraenderlich
Umsetzung von FRAMEWORK-BEFUNDE FB-10. Anlass: Commit6652125hat die angenommene ADR-0022 veraendert, obwohl AGENTS.md sagt 'binding; never edited, only superseded'. Der Verstoss wurde gepusht und erst durch Zufall bemerkt. STATUS.md traegt dasselbe Verbot und wurde nie verletzt - weil gen_status --check widerspricht. Dieses Skript ist der fehlende Widerspruch. Erlaubt bleibt genau die Aenderung, die die Vorlage vorschreibt: superseded_by und status in der abgeloesten ADR setzen. Jede Aenderung am Text ist ein Verstoss. Geprueft wird nur der aktuelle Push, nicht die Historie - Altlasten wuerden die Pipeline dauerhaft rot faerben, ohne dass jemand sie beheben kann (FB-03). Beide Sperren mit Positivkontrolle belegt: mein realer Verstoss6652125wird rot (23 Zeilen), ein Wegwerf-Commit an docs/sources wird rot, ein sauberer Commit gruen, ein ungueltiger Bereich rot. ⚠️ Beim Bau ist mir FB-02 prompt wieder unterlaufen: Der erste git-Helfer verwandelte einen Fehlschlag in einen leeren String - ein ungueltiger Bereich wurde damit zu 'keine Aenderungen' und bestand stillschweigend. Aufgefallen nur, weil der Test gegen den Wurzel-Commit lief, der keinen Vorgaenger hat. Jetzt wirft git() statt zu schweigen, und der Fall ist eine eigene Kontrolle. GIT_DEPTH 0 fuer den validate-Job: Im flachen Klon faellt die Pruefung auf HEAD~1 zurueck und sagt das - genau messen ist besser als benannt lueckenhaft.
This commit is contained in:
@@ -52,6 +52,11 @@ stillstandspruefung:
|
||||
validate:
|
||||
stage: pruefen
|
||||
image: python:3.12-alpine
|
||||
variables:
|
||||
# Volle Historie: pruefe_sperrliste.py vergleicht gegen den Vorgaenger-
|
||||
# Commit. Im flachen Klon faellt es auf HEAD~1 zurueck und sagt das -
|
||||
# genau messen ist aber besser als benannt lueckenhaft. Repo ist klein.
|
||||
GIT_DEPTH: "0"
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "push"
|
||||
before_script:
|
||||
@@ -62,6 +67,9 @@ validate:
|
||||
- python3 scripts/gen_status.py --check
|
||||
- python3 scripts/pruefe_upstream_drift.py
|
||||
- python3 scripts/pruefe_prosa.py
|
||||
# Sperrliste (FRAMEWORK-BEFUNDE FB-10): angenommene ADRs und docs/sources
|
||||
# sind unveraenderlich. Braucht den Vorgaenger-Commit im Klon.
|
||||
- python3 scripts/pruefe_sperrliste.py
|
||||
allow_failure: false
|
||||
|
||||
# Verbund-Prüfung (ADR-0012/0013): Gruppenliste vs. docs/components/,
|
||||
|
||||
+24
-4
@@ -29,7 +29,7 @@
|
||||
| FB-07 | Werkzeuge messen unbemerkt die falsche Instanz | Fehlerklasse | offen |
|
||||
| FB-08 | Dokumente werden angelesen, nicht durchgelesen | Arbeitsweise | offen |
|
||||
| FB-09 | Gelesene Anweisungen werden nicht befolgt | Arbeitsweise | offen |
|
||||
| FB-10 | Gesperrte Dateien werden bearbeitet | Arbeitsweise | offen |
|
||||
| FB-10 | Gesperrte Dateien werden bearbeitet | Arbeitsweise | **Werkzeug steht** |
|
||||
|
||||
---
|
||||
|
||||
@@ -230,9 +230,29 @@ Zum Vergleich: `STATUS.md` trägt dieselbe Art Verbot („nicht von Hand ändern
|
||||
kein einziges Mal verletzt, weil `gen_status.py --check` widerspricht. Derselbe Befund wie
|
||||
FB-01, an anderem Gegenstand.
|
||||
|
||||
**Vorschlag.** Sperrliste in der CI: Änderungen an `docs/adr/*` mit `status: accepted`,
|
||||
an `docs/sources/**` und an generierten Dateien brechen die Pipeline, sofern der Commit
|
||||
nicht ausdrücklich eine Ablösung ist (neue ADR mit `supersedes`).
|
||||
**Umgesetzt 2026-08-20.** `scripts/pruefe_sperrliste.py`, im `validate`-Job bei jedem
|
||||
Push. Gesperrt sind `docs/sources/**` und jede ADR, die vor dem Push `status: accepted`
|
||||
trug; erlaubt bleibt genau die von der Vorlage vorgeschriebene Änderung — `superseded_by`
|
||||
und `status` setzen, wenn eine neue ADR ablöst.
|
||||
|
||||
**Beide Sperren mit Positivkontrolle belegt, nicht nur gebaut:**
|
||||
|
||||
| Fall | Ergebnis |
|
||||
|---|---|
|
||||
| Commit `6652125` (mein realer Verstoß an ADR-0022) | rot, 23 beanstandete Zeilen |
|
||||
| Wegwerf-Commit an `docs/sources/…/AGENTS.md` | rot |
|
||||
| sauberer Commit | grün |
|
||||
| ungültiger Vergleichsbereich | rot — „ungeprüft ist nicht bestanden" |
|
||||
|
||||
⚠️ **Beim Bau ist mir dieselbe Klasse prompt wieder unterlaufen:** Der erste `git`-Helfer
|
||||
verwandelte einen Fehlschlag in einen leeren String — ein ungültiger Bereich wurde damit zu
|
||||
„keine Änderungen" und **bestand stillschweigend**. Aufgefallen nur, weil der Test gegen
|
||||
den Wurzel-Commit des Repos lief, der keinen Vorgänger hat. Das ist FB-02 im Werkzeug
|
||||
gegen FB-10; der Fall steht als vierte Zeile in der Tabelle oben, weil er jetzt geprüft
|
||||
wird.
|
||||
|
||||
**Nicht abgedeckt:** generierte Dateien. `STATUS.md` bleibt durch `gen_status --check`
|
||||
geschützt, andere Generate haben heute keine Entsprechung.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env python3
|
||||
"""pruefe_sperrliste.py — Aenderungen an gesperrten Pfaden im Push finden.
|
||||
|
||||
Ergaenzt pruefe_upstream_drift.py: jenes schuetzt die uebernommenen
|
||||
Framework-Dateien gegen stilles Umschreiben, dieses die im Repo selbst
|
||||
getroffenen bindenden Entscheidungen.
|
||||
|
||||
Anlass (FRAMEWORK-BEFUNDE FB-09/FB-10): Commit 6652125 hat die angenommene
|
||||
ADR-0022 veraendert, obwohl AGENTS.md sagt "binding; never edited, only
|
||||
superseded". Der Verstoss wurde gepusht und erst durch Zufall bemerkt.
|
||||
STATUS.md traegt dasselbe Verbot und wurde nie verletzt - weil
|
||||
gen_status --check widerspricht. Diese Pruefung ist der fehlende Widerspruch.
|
||||
|
||||
Gesperrt:
|
||||
* docs/sources/** unveraenderlich (AGENTS.md: read-only for agents)
|
||||
* docs/adr/*.md, sofern die Datei VOR dem Push status: accepted trug.
|
||||
Erlaubt bleibt genau die Aenderung, die die Vorlage vorschreibt: das
|
||||
Setzen von superseded_by (und der Statuswechsel auf superseded), wenn
|
||||
eine neue ADR sie abloest. Jede Aenderung am Text ist ein Verstoss.
|
||||
|
||||
Geprueft wird nur der aktuelle Push, nicht die Historie. Altlasten wuerden
|
||||
sonst die Pipeline dauerhaft rot faerben, ohne dass jemand sie beheben kann -
|
||||
genau die Klasse, die FB-03 beschreibt.
|
||||
|
||||
⚠️ Laesst sich der Vergleichsbereich nicht bestimmen, ist das ein FEHLER,
|
||||
kein Skip (Stillstandspruefungs-Regel: eine Pruefung ohne Gegenseite ist
|
||||
ungeprueft).
|
||||
|
||||
Usage:
|
||||
python scripts/pruefe_sperrliste.py [repo-root]
|
||||
python scripts/pruefe_sperrliste.py --bereich <alt>..<neu> [repo-root]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
GESPERRT_VOLL = ("docs/sources/",)
|
||||
NULL_SHA = "0" * 40
|
||||
# Genau diese Frontmatter-Schluessel darf eine angenommene ADR noch aendern.
|
||||
ABLOESE_SCHLUESSEL = {"status", "superseded_by"}
|
||||
|
||||
|
||||
class GitFehler(RuntimeError):
|
||||
"""git hat die Frage nicht beantwortet - das ist kein leeres Ergebnis."""
|
||||
|
||||
|
||||
def git(root: Path, *args: str, leer_erlaubt: bool = False) -> str:
|
||||
"""Ruft git auf. Ein Fehlschlag wirft, statt "" zu liefern.
|
||||
|
||||
⚠️ Das ist der Kern der Pruefung: Ein ungueltiger Bereich (etwa
|
||||
<wurzel-commit>^..<wurzel-commit>) laesst git scheitern. Wer den Fehler in
|
||||
einen leeren String verwandelt, macht daraus "keine Aenderungen" und damit
|
||||
ein stilles Bestanden - genau die Fehlerklasse, gegen die dieses Skript
|
||||
antritt (FRAMEWORK-BEFUNDE FB-02). Beim Bau ist mir das prompt passiert.
|
||||
"""
|
||||
ergebnis = subprocess.run(["git", "-C", str(root), *args],
|
||||
capture_output=True, text=True)
|
||||
if ergebnis.returncode != 0:
|
||||
if leer_erlaubt:
|
||||
return ""
|
||||
raise GitFehler(f"git {' '.join(args)}: {ergebnis.stderr.strip()[:120]}")
|
||||
return ergebnis.stdout
|
||||
|
||||
|
||||
def bereich_bestimmen(root: Path, vorgabe: str | None) -> str:
|
||||
if vorgabe:
|
||||
return vorgabe
|
||||
vorher = os.environ.get("CI_COMMIT_BEFORE_SHA", "")
|
||||
if vorher and vorher != NULL_SHA:
|
||||
# Im flachen Klon kann der Vorgaenger fehlen. Dann auf HEAD~1
|
||||
# zurueckfallen und das SAGEN - ein Fehlalarm waere hier schlimmer als
|
||||
# eine benannte Luecke (FRAMEWORK-BEFUNDE FB-03).
|
||||
if _existiert(root, vorher):
|
||||
return f"{vorher}..HEAD"
|
||||
print(f"pruefe_sperrliste: {vorher[:12]} liegt nicht im Klon "
|
||||
f"(flacher Clone) - pruefe nur den letzten Commit.")
|
||||
# Kein CI-Kontext oder neuer Branch: der letzte Commit.
|
||||
if git(root, "rev-parse", "--verify", "HEAD~1", leer_erlaubt=True).strip():
|
||||
return "HEAD~1..HEAD"
|
||||
return ""
|
||||
|
||||
|
||||
def _existiert(root: Path, sha: str) -> bool:
|
||||
ergebnis = subprocess.run(["git", "-C", str(root), "cat-file", "-e",
|
||||
f"{sha}^{{commit}}"], capture_output=True)
|
||||
return ergebnis.returncode == 0
|
||||
|
||||
|
||||
def frontmatter_wert(text: str, schluessel: str) -> str | None:
|
||||
treffer = re.search(rf"^{schluessel}:\s*(.+?)\s*$", text, re.M)
|
||||
return treffer.group(1).strip().strip('"') if treffer else None
|
||||
|
||||
|
||||
def geaenderte_zeilen(root: Path, bereich: str, pfad: str) -> list[str]:
|
||||
roh = git(root, "diff", "--unified=0", bereich, "--", pfad)
|
||||
return [z for z in roh.splitlines()
|
||||
if (z.startswith("+") or z.startswith("-"))
|
||||
and not z.startswith(("+++", "---"))]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
argv = [a for a in sys.argv[1:] if not a.startswith("--")]
|
||||
bereich_vorgabe = None
|
||||
if "--bereich" in sys.argv:
|
||||
i = sys.argv.index("--bereich")
|
||||
if i + 1 < len(sys.argv):
|
||||
bereich_vorgabe = sys.argv[i + 1]
|
||||
argv = [a for a in argv if a != bereich_vorgabe]
|
||||
root = Path(argv[0]) if argv else Path.cwd()
|
||||
|
||||
bereich = bereich_bestimmen(root, bereich_vorgabe)
|
||||
if not bereich:
|
||||
print("pruefe_sperrliste: Vergleichsbereich nicht bestimmbar - "
|
||||
"ungeprueft ist nicht bestanden. Mit --bereich <alt>..<neu> aufrufen.")
|
||||
return 1
|
||||
|
||||
try:
|
||||
geaendert = [z for z in git(root, "diff", "--name-only", bereich).splitlines() if z]
|
||||
except GitFehler as fehler:
|
||||
print(f"pruefe_sperrliste: Bereich {bereich} nicht auswertbar - {fehler}")
|
||||
print("Ungeprueft ist nicht bestanden.")
|
||||
return 1
|
||||
if not geaendert:
|
||||
print(f"pruefe_sperrliste: keine Aenderungen im Bereich {bereich}.")
|
||||
return 0
|
||||
|
||||
befunde: list[str] = []
|
||||
|
||||
for pfad in geaendert:
|
||||
# 1. Unveraenderliche Quellen
|
||||
if any(pfad.startswith(p) for p in GESPERRT_VOLL):
|
||||
befunde.append(f"{pfad}: unveraenderlich (AGENTS.md: read-only for agents)")
|
||||
continue
|
||||
|
||||
# 2. Angenommene ADRs
|
||||
if not (pfad.startswith("docs/adr/") and pfad.endswith(".md")):
|
||||
continue
|
||||
if pfad.endswith("template.md"):
|
||||
continue
|
||||
|
||||
alt = git(root, "show", f"{bereich.split('..')[0]}:{pfad}", leer_erlaubt=True)
|
||||
if not alt:
|
||||
continue # neu angelegt - erlaubt
|
||||
if frontmatter_wert(alt, "status") != "accepted":
|
||||
continue # proposed darf noch bearbeitet werden
|
||||
|
||||
zeilen = geaenderte_zeilen(root, bereich, pfad)
|
||||
verletzend = []
|
||||
for zeile in zeilen:
|
||||
inhalt = zeile[1:].strip()
|
||||
if not inhalt:
|
||||
continue
|
||||
schluessel = inhalt.split(":", 1)[0].strip() if ":" in inhalt else ""
|
||||
if schluessel in ABLOESE_SCHLUESSEL:
|
||||
continue
|
||||
verletzend.append(zeile)
|
||||
if verletzend:
|
||||
befunde.append(
|
||||
f"{pfad}: angenommene ADR veraendert "
|
||||
f"({len(verletzend)} Zeile(n) ausserhalb von "
|
||||
f"{'/'.join(sorted(ABLOESE_SCHLUESSEL))})")
|
||||
for z in verletzend[:3]:
|
||||
befunde.append(f" {z[:96]}")
|
||||
|
||||
if befunde:
|
||||
print(f"pruefe_sperrliste: {len([b for b in befunde if not b.startswith(' ')])} "
|
||||
f"Verstoss/Verstoesse im Bereich {bereich}")
|
||||
for b in befunde:
|
||||
print(f" {b}")
|
||||
print()
|
||||
print("Eine angenommene ADR wird nicht editiert, sondern durch eine neue")
|
||||
print("abgeloest (AGENTS.md, docs/adr/template.md). Erlaubt ist nur, in der")
|
||||
print("alten superseded_by und status zu setzen.")
|
||||
return 1
|
||||
|
||||
print(f"pruefe_sperrliste: keine Verstoesse im Bereich {bereich}.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user