diff --git a/.github/workflows/README.md b/.github/workflows/README.md index c877168..037d6ec 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -2,61 +2,133 @@ ## doku-lint.yml -Wiederverwendbare Action, die in jedem Klangschalen-Repo per `uses:` -eingebunden wird. Prueft 3 Gates: - -1. Pflicht-Dateien vorhanden (LICENSE, CHANGELOG, STATUS, CONTRIBUTING, SECURITY, ...) -2. CHANGELOG.md beruehrt bei Code-Aenderung -3. Conventional Commit Format +Der wiederverwendbare Doku-Lint prüft drei getrennte Gates: + +1. Pflicht-Dateien sind vorhanden. +2. Code-Änderungen berühren `CHANGELOG.md`. +3. Der letzte Quell-Commit nutzt ein erlaubtes Conventional-Commit-Format. + +Bei Pull Requests lädt der Workflow immer den **exakten PR-Head**. Er prüft +nicht den von GitHub erzeugten synthetischen Merge-Commit. Damit bleibt das +Ergebnis unabhängig von der Checkout-Voreinstellung und zeigt den tatsächlich +eingereichten Commit. + +### Erlaubte Commit-Typen + +Der Standard erlaubt diese Typen: + +- `feat` +- `fix` +- `docs` +- `style` +- `refactor` +- `test` +- `chore` +- `perf` +- `build` +- `ci` +- `revert` +- `policy` + +Richtlinien können direkt mit `policy:` beginnen. Alternativ passt +`docs(policy):`, wenn vor allem die Dokumentation einer Regel geändert wird. + +Beispiele: + +```text +policy: require canonical pull-request links +docs(policy): explain the canonical-link rule +fix(doku-lint): check the exact pull-request head +``` -### Einbindung in einem Repo +### Einbindung in einem Repository ```yaml -# .github/workflows/doku-lint.yml im Ziel-Repo +# .github/workflows/doku-lint.yml im Ziel-Repository name: Doku-Lint -on: [push, pull_request] + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +permissions: + contents: read + jobs: lint: - uses: Klangschalen/.github/.github/workflows/doku-lint.yml@main + uses: Klangschalen/.github/.github/workflows/doku-lint.yml@<40-STELLIGE-SHA> with: pflicht_dateien: "README.md,LICENSE,CHANGELOG.md,STATUS.md,CONTRIBUTING.md,SECURITY.md,.editorconfig,.gitignore" warn_only: true + commit_format_warn_only: false + permissions: + contents: read + secrets: inherit ``` +Eine vollständige Commit-SHA schützt vor unbemerkten Änderungen des zentralen +Workflows. `@main` verteilt neue Versionen sofort, bietet aber keine feste +Version für einen bereits geprüften Caller. + ### Modi -- `warn_only: true` (Default): meldet als Warnung, blockt PR nicht -- `warn_only: false`: meldet als Fehler, blockt PR (rote Pruefung) +- `warn_only: true` macht Gate 1 und Gate 2 zu Hinweisen. +- `warn_only: false` blockiert bei fehlenden Dateien oder fehlendem Changelog. +- `commit_format_warn_only: false` blockiert Gate 3. Das ist der Standard. +- `commit_format_warn_only: true` dient nur einer klar begrenzten Übergangsphase. -Empfehlung: 2 Wochen `warn_only: true`, dann auf `false` umstellen. +Caller dürfen `allowed_commit_types` überschreiben. Jede Abweichung muss im +Repository dokumentiert und getestet werden. Der organisationsweite Standard +enthält `policy`, damit Richtlinien-Commits nicht erneut an einer versteckten +Regex scheitern. -## claim-lint.yml +### Fehler lesen + +Die Ausgabe nennt: + +- den geprüften Quell-Commit, +- den gefundenen Commit-Titel, +- den nicht erlaubten Typ, +- alle erlaubten Typen, +- ein passendes Beispiel für Richtlinien. -Wiederverwendbare Action, die PR-Beschreibungen und neue CHANGELOG-Zeilen -gegen unbelegte Vollstaendigkeits-Behauptungen prueft ("vollstaendig", -"alles geprueft", "komplett geprueft" ohne begleitende Zahlen wie -"X von Y" oder "X/Y"). Rein deterministisch (Bash/Regex), kein LLM-Call, -keine Kosten, keine Latenz. +Ein Draft-Pull-Request bleibt unabhängig davon ein Draft. Der Doku-Lint ändert +keinen Review- oder Freigabestatus. -**Hintergrund:** portiert dieselbe Pruef-Logik wie der lokale PostToolUse- -Hook `claude-config/hooks/no-fake-completeness.sh` (seit 07.04.2026 aktiv), -der nur auf Franks Maschine feuert - nicht in Cloud-Sessions oder -Background-Subagenten. Genau dort ist am 16.08.2026 in -`engineering-principles` PR #12 eine unbelegte Vollstaendigkeits-Behauptung -durchgerutscht (siehe `engineering-principles/LEARNINGS.md` L-035/L-036). +## doku-lint-contract.yml -### Einbindung in einem Repo +Der Vertragstest läuft bei Änderungen am Doku-Lint. Er verhindert diese +Rückfälle: + +- Checkout des synthetischen Merge-Commits, +- Commit-Prüfung ohne explizite Quell-SHA, +- Rückkehr des unsicheren `HEAD~1`-Fallbacks, +- Entfernung des Typs `policy`, +- versehentliches Zurückstellen von Gate 3 auf Warnmodus, +- Abweichung zwischen Workflow und Dokumentation. + +## claim-lint.yml + +Der wiederverwendbare Claim-Lint prüft PR-Beschreibungen und neue +Changelog-Zeilen gegen unbelegte Vollständigkeitsbehauptungen. Die Prüfung +arbeitet deterministisch mit Bash und regulären Ausdrücken. + +### Einbindung ```yaml -# .github/workflows/claim-lint.yml im Ziel-Repo name: Claim-Lint + on: [pull_request] + permissions: contents: read pull-requests: read + jobs: claim-lint: - uses: Klangschalen/.github/.github/workflows/claim-lint.yml@main + uses: Klangschalen/.github/.github/workflows/claim-lint.yml@<40-STELLIGE-SHA> with: warn_only: true permissions: @@ -65,28 +137,14 @@ jobs: secrets: inherit ``` -### Modi - -- `warn_only: true` (Default): meldet als Warnung, blockt PR nicht -- `warn_only: false`: meldet als Fehler, blockt PR (rote Pruefung) - -Empfehlung: wie bei doku-lint.yml zunaechst `warn_only: true`, nach -Bewaehrung auf `false` umstellen. - ## org-action-runtime-audit.yml -Taeglicher organisationsweiter Waechter fuer aktive GitHub-Actions-Workflows. -Er liest mit `ORG_AUDIT_TOKEN` alle nicht archivierten Repositories und -aktualisiert ein einziges Sammel-Issue in `Klangschalen/.github`. - -Er meldet: - -1. JavaScript-Actions, deren `action.yml` nicht `node24` verwendet. -2. Externe Actions ohne vollständigen 40-stelligen Commit-SHA-Pin. -3. Unvollständige Scans, insbesondere wenn weniger als 41 Repositories sichtbar sind. +Der tägliche organisationsweite Wächter prüft aktive GitHub-Actions-Workflows. +Er meldet insbesondere: -Der dritte Punkt ist fail-closed: Verliert das Token Zugriff, wird der Lauf rot -und darf nicht als sauber interpretiert werden. Aktuelle Befunde bleiben -zunächst ein weicher, sichtbarer Bestand im Sammel-Issue; die einzelnen -Korrekturen laufen kontrolliert als PR je Repository. +1. JavaScript-Actions ohne Node 24. +2. Externe Actions ohne vollständigen Commit-SHA-Pin. +3. Unvollständige Scans mit zu geringer Repository-Abdeckung. +Der Abdeckungsfehler arbeitet fail-closed. Einzelne technische Befunde bleiben +sichtbar und werden kontrolliert über Pull Requests behoben. diff --git a/.github/workflows/doku-lint-contract.yml b/.github/workflows/doku-lint-contract.yml new file mode 100644 index 0000000..6b47b81 --- /dev/null +++ b/.github/workflows/doku-lint-contract.yml @@ -0,0 +1,39 @@ +name: Doku-Lint-Vertrag + +on: + push: + branches: [main] + paths: + - ".github/workflows/doku-lint.yml" + - ".github/workflows/doku-lint-contract.yml" + - ".github/workflows/README.md" + - "scripts/test_doku_lint_contract.py" + pull_request: + branches: [main] + paths: + - ".github/workflows/doku-lint.yml" + - ".github/workflows/doku-lint-contract.yml" + - ".github/workflows/README.md" + - "scripts/test_doku_lint_contract.py" + +permissions: + contents: read + +jobs: + contract: + name: doku-lint-contract + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Python einrichten + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + - name: Doku-Lint-Vertrag pruefen + run: python3 scripts/test_doku_lint_contract.py diff --git a/.github/workflows/doku-lint.yml b/.github/workflows/doku-lint.yml index fe3a0dd..5c37836 100644 --- a/.github/workflows/doku-lint.yml +++ b/.github/workflows/doku-lint.yml @@ -1,7 +1,10 @@ name: Doku-Lint (zentral, wiederverwendbar) -# Reusable Workflow - wird von anderen Repos per `uses:` eingebunden. -# Beispiel-Aufruf im Caller-Repo: +# Wiederverwendbarer Workflow fuer Klangschalen-Repositories. +# Bei Pull Requests wird immer der exakte Quell-Commit geprueft, niemals der +# von GitHub erzeugte synthetische Merge-Commit. +# +# Beispiel: # # permissions: # contents: read @@ -10,13 +13,11 @@ name: Doku-Lint (zentral, wiederverwendbar) # uses: Klangschalen/.github/.github/workflows/doku-lint.yml@ # with: # pflicht_dateien: "README.md,LICENSE,CHANGELOG.md,..." +# warn_only: true +# commit_format_warn_only: false # permissions: # contents: read # secrets: inherit -# -# WICHTIG: Caller MUSS `secrets: inherit` setzen, sonst kommt der GITHUB_TOKEN -# nicht beim cross-org Reusable-Workflow an (actions/checkout schlaegt sonst -# mit "could not read Username" fehl). on: workflow_call: @@ -26,9 +27,17 @@ on: type: string default: "README.md,LICENSE,CHANGELOG.md,STATUS.md,CONTRIBUTING.md,SECURITY.md,.editorconfig,.gitignore" warn_only: - description: "Wenn true, nur Warnung statt Fehler" + description: "Gate 1+2: wenn true, nur Warnung statt Fehler" type: boolean default: true + commit_format_warn_only: + description: "Gate 3: wenn true, nur Warnung statt Fehler" + type: boolean + default: false + allowed_commit_types: + description: "Komma-getrennte erlaubte Conventional-Commit-Typen" + type: string + default: "feat,fix,docs,style,refactor,test,chore,perf,build,ci,revert,policy" permissions: contents: read @@ -36,84 +45,140 @@ permissions: jobs: pruefen: name: Doku-Lint Pruefung - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: contents: read + env: + SOURCE_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }} + BASE_COMMIT: ${{ github.event.pull_request.base.sha || '' }} + ALLOWED_COMMIT_TYPES: ${{ inputs.allowed_commit_types }} + CONVENTIONAL_SUBJECT_PATTERN: '^([a-z][a-z0-9-]*)(\([a-z0-9-]+\))?!?:' steps: - - name: Checkout - # SHA-Pin auf v6.0.2 (Node 24 ready, vermeidet Deprecation-Warning 2026-06-02) + - name: Exakten Quell-Commit laden uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ env.SOURCE_COMMIT }} fetch-depth: 0 + persist-credentials: false token: ${{ github.token }} + - name: Geprueften Commit ausgeben + shell: bash + run: | + set -euo pipefail + echo "Ereignis: ${{ github.event_name }}" + echo "Quell-Commit: $SOURCE_COMMIT" + if [ -n "$BASE_COMMIT" ]; then + echo "Basis-Commit: $BASE_COMMIT" + fi + - name: Gate 1 - Pflicht-Dateien vorhanden shell: bash + env: + WARN_ONLY: ${{ inputs.warn_only }} run: | - set -e + set -euo pipefail IFS=',' read -ra files <<< "${{ inputs.pflicht_dateien }}" missing=() present=() - for f in "${files[@]}"; do - if [ -f "$f" ]; then - present+=("$f") + for file in "${files[@]}"; do + if [ -f "$file" ]; then + present+=("$file") else - missing+=("$f") + missing+=("$file") fi done + echo "Vorhanden (${#present[@]}): ${present[*]}" echo "Fehlen (${#missing[@]}): ${missing[*]:-keine}" - if [ ${#missing[@]} -gt 0 ]; then - if [ "${{ inputs.warn_only }}" = "true" ]; then - echo "::warning::Fehlende Pflicht-Dateien: ${missing[*]}" + + if [ "${#missing[@]}" -gt 0 ]; then + message="Fehlende Pflicht-Dateien: ${missing[*]}" + if [ "$WARN_ONLY" = "true" ]; then + echo "::warning::$message" else - echo "::error::Fehlende Pflicht-Dateien: ${missing[*]}" + echo "::error::$message" exit 1 fi fi - - name: Gate 2 - CHANGELOG-Touch bei Code-Aenderung (nur Pull Requests) + - name: Gate 2 - CHANGELOG-Touch bei Code-Aenderung if: github.event_name == 'pull_request' shell: bash + env: + WARN_ONLY: ${{ inputs.warn_only }} run: | - set -e - base="${{ github.event.pull_request.base.sha }}" - head="${{ github.event.pull_request.head.sha }}" - git fetch origin "$base" --depth=50 || true - changed=$(git diff --name-only "$base"..."$head" || git diff --name-only HEAD~1) + set -euo pipefail + + if ! git cat-file -e "$BASE_COMMIT^{commit}" 2>/dev/null; then + git fetch --no-tags --depth=1 origin "$BASE_COMMIT" + fi + git cat-file -e "$BASE_COMMIT^{commit}" + + changed="$(git diff --name-only "$BASE_COMMIT"..."$SOURCE_COMMIT")" echo "Geaenderte Dateien:" - echo "$changed" - code_changed=$(echo "$changed" | grep -E '\.(php|py|js|ts|sh|yml|yaml)$' || true) - if [ -n "$code_changed" ]; then - if echo "$changed" | grep -qE '^CHANGELOG\.md$'; then - echo "OK - CHANGELOG.md ist mit beruehrt" + printf '%s\n' "$changed" + + code_changed="$(printf '%s\n' "$changed" | grep -E '\.(php|py|js|ts|sh|yml|yaml)$' || true)" + if [ -n "$code_changed" ] && ! printf '%s\n' "$changed" | grep -qE '^CHANGELOG\.md$'; then + message="Code-Aenderung ohne CHANGELOG-Touch: $code_changed" + if [ "$WARN_ONLY" = "true" ]; then + echo "::warning::$message" else - msg="Code-Aenderung ohne CHANGELOG-Touch: $code_changed" - if [ "${{ inputs.warn_only }}" = "true" ]; then - echo "::warning::$msg" - else - echo "::error::$msg" - exit 1 - fi + echo "::error::$message" + exit 1 fi + elif [ -n "$code_changed" ]; then + echo "OK - CHANGELOG.md ist mit beruehrt" else - echo "Keine Code-Aenderung im PR, CHANGELOG-Touch nicht noetig." + echo "Keine Code-Aenderung im Pull Request." fi - - name: Gate 3 - Conventional-Commit-Format des letzten Commits + - name: Gate 3 - Conventional Head-Commit shell: bash + env: + COMMIT_FORMAT_WARN_ONLY: ${{ inputs.commit_format_warn_only }} run: | - set -e - msg=$(git log -1 --pretty=%s) - echo "Letzter Commit-Titel: $msg" - if echo "$msg" | grep -qE '^(feat|fix|docs|style|refactor|test|chore|perf|build|ci|revert)(\([a-z0-9-]+\))?!?:'; then - echo "OK - Conventional Commit" + set -euo pipefail + + message="$(git log -1 --pretty=%s "$SOURCE_COMMIT")" + echo "Gepruefter Commit-Titel: $message" + echo "Erlaubte Typen: $ALLOWED_COMMIT_TYPES" + + format_ok=false + commit_type="" + if [[ "$message" =~ $CONVENTIONAL_SUBJECT_PATTERN ]]; then + format_ok=true + commit_type="${BASH_REMATCH[1]}" + fi + + type_ok=false + if [ "$format_ok" = "true" ]; then + IFS=',' read -ra allowed_types <<< "$ALLOWED_COMMIT_TYPES" + for allowed_type in "${allowed_types[@]}"; do + allowed_type="${allowed_type//[[:space:]]/}" + if [ "$commit_type" = "$allowed_type" ]; then + type_ok=true + break + fi + done + fi + + if [ "$format_ok" = "true" ] && [ "$type_ok" = "true" ]; then + echo "OK - Conventional Head-Commit mit Typ '$commit_type'" + exit 0 + fi + + if [ "$format_ok" != "true" ]; then + problem="Head-Commit $SOURCE_COMMIT hat kein Conventional-Commit-Format: '$message'" else - warn="Commit-Titel nicht Conventional: '$msg' (erwartet: feat:/fix:/docs:/...)" - if [ "${{ inputs.warn_only }}" = "true" ]; then - echo "::warning::$warn" - else - echo "::error::$warn" - exit 1 - fi - fi \ No newline at end of file + problem="Commit-Typ '$commit_type' ist nicht erlaubt: '$message'" + fi + + hint="Erlaubt: $ALLOWED_COMMIT_TYPES. Richtlinien nutzen 'policy: ...' oder 'docs(policy): ...'." + if [ "$COMMIT_FORMAT_WARN_ONLY" = "true" ]; then + echo "::warning::$problem. $hint" + else + echo "::error::$problem. $hint" + exit 1 + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 4958d39..bdce389 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ### Geändert +- fix(doku-lint): Pull Requests werden am exakten Quell-Commit statt am synthetischen GitHub-Merge-Commit geprüft +- fix(doku-lint): Gate 3 erhält den eigenen Schalter `commit_format_warn_only` und blockiert standardmäßig +- feat(doku-lint): `policy` ist ein erlaubter Commit-Typ; Caller können die Typen über `allowed_commit_types` gezielt erweitern +- test(doku-lint): ein fail-closed Vertragstest schützt Quellbindung, Typenliste, Gate-Modus und Dokumentation vor Rückfällen +- docs(doku-lint): erlaubte Typen, Fehlerhilfe und sichere Caller-Einbindung dokumentieren - fix(security): Gitleaks prüft Pull Requests nur noch im Bereich Basis-SHA bis Quell-SHA; fremde offene Zweige können den aktuellen Pull Request nicht mehr rot färben - test(security): ein eigener Vertragslauf verhindert die Rückkehr zu `detect --source .` ohne begrenzten Git-Bereich - feat(actions): organisationsweiten Runtime-/SHA-Pin-Audit mit täglichem Sammel-Issue und fail-closed Zugriffskontrolle ergänzen diff --git a/README.md b/README.md index b58f221..1960616 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,45 @@ # Klangschalen Org-Verwaltung -Dieses Repo (`.github` in der Organisation) enthaelt organisations-weite -Standards, die andere Repos einbinden: - -- `.github/workflows/doku-lint.yml` - wiederverwendbare Doku-Lint-Action -- `.github/workflows/org-doku-audit.yml` - naechtlicher org-weiter Doku-Audit +Dieses Repository enthält organisationsweite Standards, die andere +Klangschalen-Repositories einbinden. ## Was hier liegt | Datei | Zweck | |---|---| -| `.github/workflows/doku-lint.yml` | Zentrale Doku-Linter-Action, alle Repos binden sie ein (per PR) | -| `.github/workflows/org-doku-audit.yml` | Naechtlicher Audit ueber ALLE Repos, postet Sammel-Issue | -| `.github/workflows/README.md` | Erklaerung wie Repos die Action einbinden | -| `scripts/org_doku_audit.py` | Audit-Logik (Pflichtdateien pro Repo pruefen, Bericht bauen) | -| `scripts/test_org_doku_audit.py` | Tests der Bericht-Logik (ohne Netz) | +| `.github/workflows/doku-lint.yml` | Wiederverwendbarer Doku-Lint mit Prüfung des exakten PR-Heads | +| `.github/workflows/doku-lint-contract.yml` | Fail-closed Rückfalltest für Quellbindung, Commit-Typen und Dokumentation | +| `.github/workflows/claim-lint.yml` | Prüft unbelegte Vollständigkeitsbehauptungen | +| `.github/workflows/org-doku-audit.yml` | Nächtlicher Doku-Audit über alle sichtbaren Repositories | +| `.github/workflows/org-action-runtime-audit.yml` | Prüft Action-Runtime und vollständige SHA-Pins | +| `.github/workflows/README.md` | Erklärt Einbindung, Modi und erlaubte Commit-Typen | +| `scripts/org_doku_audit.py` | Baut den organisationsweiten Doku-Bericht | +| `scripts/test_org_doku_audit.py` | Testet die Bericht-Logik ohne Netz | +| `scripts/test_doku_lint_contract.py` | Sichert den Doku-Lint-Vertrag gegen Rückfälle | ## Zwei Ebenen der Doku-Kontrolle -1. **Pro PR (sofort):** `doku-lint.yml` als reusable Workflow im jeweiligen Repo - einbinden. Prueft beim Pull Request: Pflicht-Dateien vorhanden, CHANGELOG bei - Code-Aenderung mit beruehrt, Conventional-Commit-Format. -2. **Naechtlich (Gesamtbild):** `org-doku-audit.yml` laeuft taeglich 03:00 UTC - (und manuell via *Actions -> Run workflow*) und prueft in **allen** nicht- - archivierten Repos, ob die Pflichtdateien existieren. Ergebnis: **ein** - Sammel-Issue "Org Doku-Audit (automatisch)" mit Ampel-Tabelle, das bei jedem - Lauf aktualisiert wird. +1. **Pro Pull Request:** `doku-lint.yml` prüft Pflicht-Dateien, den + `CHANGELOG.md`-Touch und das Commit-Format. Bei Pull Requests bindet er sich + an den exakten Quell-Commit statt an GitHubs synthetischen Merge-Commit. +2. **Nächtlich:** `org-doku-audit.yml` prüft alle sichtbaren, nicht + archivierten Repositories und pflegt ein gemeinsames Sammel-Issue. + +Der zentrale Commit-Standard erlaubt unter anderem `policy:`. Dadurch können +Richtlinien klar benannt werden, ohne an einer versteckten Typenliste zu +scheitern. Der eigene Vertragstest hält Workflow und Dokumentation synchron. -**Voraussetzung fuer den vollen naechtlichen Lauf:** ein Secret `ORG_AUDIT_TOKEN` -(Fine-grained PAT mit *Contents: read* org-weit + *Issues: write* auf `.github`). -Ohne dieses Secret nutzt der Workflow `GITHUB_TOKEN` und sieht nur dieses Repo. +**Voraussetzung für den vollständigen nächtlichen Lauf:** Das Secret +`ORG_AUDIT_TOKEN` braucht organisationsweit `Contents: read` sowie +`Issues: write` auf diesem Repository. Ohne das Secret sieht der Workflow nur +den Umfang des normalen `GITHUB_TOKEN`. -## Roll-out neuer Repos +## Rollout neuer Repositories -Nutze `Klangschalen/repo-template` als Vorlage (Use this template). -Die Workflow-Definition ist da bereits eingebunden. +`Klangschalen/repo-template` enthält den Caller für den zentralen Doku-Lint. +Produktive Caller sollten eine geprüfte 40-stellige Commit-SHA verwenden. +Die aktuelle Einbindung und alle Schalter stehen in +`.github/workflows/README.md`. ## Hilfe diff --git a/scripts/test_doku_lint_contract.py b/scripts/test_doku_lint_contract.py new file mode 100644 index 0000000..11cb524 --- /dev/null +++ b/scripts/test_doku_lint_contract.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""Fail-closed contract tests for the reusable Doku-Lint workflow.""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOW = ROOT / ".github" / "workflows" / "doku-lint.yml" +DOCS = ROOT / ".github" / "workflows" / "README.md" + +EXPECTED_TYPES = { + "feat", + "fix", + "docs", + "style", + "refactor", + "test", + "chore", + "perf", + "build", + "ci", + "revert", + "policy", +} + + +def require(text: str, needle: str, label: str) -> None: + if needle not in text: + raise AssertionError(f"{label} fehlt: {needle}") + + +def extract_default_types(workflow: str) -> set[str]: + match = re.search( + r"(?m)^ allowed_commit_types:\n(?: .*\n)*? default: \"([^\"]+)\"$", + workflow, + ) + if not match: + raise AssertionError("Default fuer allowed_commit_types fehlt") + return {item.strip() for item in match.group(1).split(",") if item.strip()} + + +def extract_subject_pattern(workflow: str) -> re.Pattern[str]: + match = re.search( + r"CONVENTIONAL_SUBJECT_PATTERN:\s*'([^']+)'", + workflow, + ) + if not match: + raise AssertionError("CONVENTIONAL_SUBJECT_PATTERN fehlt") + return re.compile(match.group(1)) + + +def test_exact_pr_head(workflow: str) -> None: + require( + workflow, + "SOURCE_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }}", + "PR-Head-Auswahl", + ) + require(workflow, "ref: ${{ env.SOURCE_COMMIT }}", "Checkout des PR-Heads") + require( + workflow, + 'git log -1 --pretty=%s "$SOURCE_COMMIT"', + "Commit-Pruefung gegen PR-Head", + ) + require( + workflow, + 'git diff --name-only "$BASE_COMMIT"..."$SOURCE_COMMIT"', + "Diff gegen Basis und PR-Head", + ) + if "git diff --name-only HEAD~1" in workflow: + raise AssertionError("Unsicherer HEAD~1-Fallback ist wieder vorhanden") + + +def test_commit_contract(workflow: str) -> None: + types = extract_default_types(workflow) + missing = EXPECTED_TYPES - types + if missing: + raise AssertionError(f"Erlaubte Commit-Typen fehlen: {sorted(missing)}") + + block = re.search( + r"(?m)^ commit_format_warn_only:\n(?: .*\n)*? default: (true|false)$", + workflow, + ) + if not block or block.group(1) != "false": + raise AssertionError("Gate 3 muss standardmaessig blockieren") + + pattern = extract_subject_pattern(workflow) + valid = ( + "policy: define output contract", + "docs(policy): explain output contract", + "fix!: change behavior", + "feat(agent-runtime)!: change behavior", + ) + invalid = ( + "Policy: wrong case", + "Merge pull request #12", + "free form title", + ) + + for subject in valid: + if not pattern.match(subject): + raise AssertionError(f"Gueltiger Titel wird abgelehnt: {subject}") + for subject in invalid: + if pattern.match(subject): + raise AssertionError(f"Ungueltiger Titel wird akzeptiert: {subject}") + + +def test_documentation(workflow: str, docs: str) -> None: + require(docs, "exakten PR-Head", "Dokumentation der Quellbindung") + require(docs, "`policy:`", "Dokumentation des Richtlinien-Typs") + require(docs, "`commit_format_warn_only: false`", "Dokumentation des harten Gates") + + for commit_type in sorted(extract_default_types(workflow)): + require(docs, f"`{commit_type}`", f"Dokumentierter Commit-Typ {commit_type}") + + +def main() -> int: + workflow = WORKFLOW.read_text(encoding="utf-8") + docs = DOCS.read_text(encoding="utf-8") + + test_exact_pr_head(workflow) + test_commit_contract(workflow) + test_documentation(workflow, docs) + + print("Doku-Lint-Vertrag: PASS") + print(f"Gepruefte Commit-Typen: {', '.join(sorted(extract_default_types(workflow)))}") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except AssertionError as exc: + print(f"Doku-Lint-Vertrag: FAIL - {exc}", file=sys.stderr) + raise SystemExit(1)