diff --git a/ci/README.d b/ci/README.d new file mode 100644 index 0000000..77d230b --- /dev/null +++ b/ci/README.d @@ -0,0 +1,309 @@ +# Entwicklerhinweise: Anforderungen bei Push und Pull Request + +Dieses Repository verwendet Jenkins-basierte PR- und Branch-Policies. +Ziel ist, dass Code bereits **vor dem Merge** bestimmte Qualitätsanforderungen erfüllt. + +Diese Datei beschreibt, **was bei einem Push erwartet wird**, **was in Jenkins geprüft wird** und **wie du Fehler lokal vermeiden kannst**. + +--- + +## Überblick + +Bei Pushes und Pull Requests werden automatisierte Policies ausgeführt. + +Je nach Build-Typ gelten leicht unterschiedliche Vergleichsregeln: + +- **bei Pull Requests** wird gegen den Zielbranch verglichen, z. B. `origin/main` +- **bei normalen Branch-Builds** wird auf eine lokale Vergleichsbasis zurückgefallen, z. B. `HEAD~1`, falls kein Zielbranch-Ref verfügbar ist + +Die eigentlichen Qualitätsanforderungen bleiben gleich. + +--- + +## Was beim Push / PR geprüft wird + +### 1. `.clang-format` muss vorhanden sein +Im Repository-Root muss eine Datei `.clang-format` liegen. + +Ohne diese Datei schlägt der Policy-Check fehl. + +--- + +### 2. Geänderte C/C++-Dateien müssen korrekt formatiert sein +Für geänderte C/C++-Dateien wird `clang-format` geprüft. + +Betroffen sind insbesondere Dateien mit diesen Endungen: + +- `.c` +- `.cc` +- `.cpp` +- `.cxx` +- `.h` +- `.hh` +- `.hpp` +- `.hxx` + +Wenn eine Datei nicht korrekt formatiert ist, schlägt der Build fehl. + +#### Lokal prüfen +```bash +clang-format --dry-run --Werror +``` + +#### Lokal automatisch formatieren +```bash +clang-format -i +``` + +Beispiel: +```bash +clang-format -i main.cpp tests/test_main.cpp +``` + +--- + +### 3. Zu geänderten C/C++-Dateien muss ein passender Test existieren +Wenn du eine C/C++-Datei änderst, muss ein passender Test vorhanden sein. + +Es wird nach Tests in diesen Verzeichnissen gesucht: + +- `tests/` +- `test/` +- `qt6-after/qt6-migrated-benchmark/tests/` + +Unterstützte typische Testnamen sind zum Beispiel: + +- `test_.cpp` +- `_test.cpp` +- `test.cpp` +- `gtest.cpp` + +Zusätzlich erkennt die Policy auch ähnliche Dateinamen heuristisch, solange `test` oder `gtest` im Dateinamen vorkommt. + +#### Beispiel +Wenn du `main.cpp` änderst, ist z. B. passend: + +```text +tests/test_main.cpp +``` + +Wenn kein passender Test gefunden wird, schlägt der Build fehl. + +--- + +### 4. Nur gültig SSH-signierte Commits sind erlaubt +Neue Commits im geprüften Bereich müssen gültig **mit SSH signiert** sein. + +Die Jenkins-Policy prüft Commit-Signaturen gegen eine erlaubte Signiererdatei: + +```text +ci/allowed_signers +``` + +Wenn ein Commit: + +- nicht signiert ist +- nicht per SSH signiert ist +- mit einem nicht erlaubten SSH-Schlüssel signiert ist + +dann schlägt der Build fehl. + +--- + +## SSH-signierte Commits einrichten + +### 1. SSH-Signing-Key erzeugen +Falls du noch keinen dedizierten Signing-Key hast: + +```bash +ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_signing -C "dein.name@example.com" +``` + +--- + +### 2. Git für SSH-Signing konfigurieren + +```bash +git config --global gpg.format ssh +git config --global user.signingkey ~/.ssh/id_ed25519_signing.pub +git config --global commit.gpgsign true +``` + +Damit werden neue Commits automatisch mit deinem SSH-Key signiert. + +--- + +### 3. Öffentlichen Schlüssel zur Allowlist hinzufügen +Dein öffentlicher Schlüssel muss in `ci/allowed_signers` eingetragen sein. + +Format: + +```text + +``` + +Beispiel: + +```text +max.mustermann@example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... +``` + +Ohne Eintrag in `ci/allowed_signers` wird dein signierter Commit in Jenkins trotzdem abgelehnt. + +--- + +### 4. Optional: lokale Verifikation aktivieren +Wenn du lokal dieselbe Vertrauenskette wie Jenkins verwenden willst: + +```bash +mkdir -p ~/.config/git +cp ci/allowed_signers ~/.config/git/allowed_signers +git config --global gpg.ssh.allowedSignersFile ~/.config/git/allowed_signers +``` + +--- + +### 5. Signierten Commit erstellen +```bash +git add . +git commit -S -m "Meine Änderung" +git push +``` + +Wenn `commit.gpgsign=true` gesetzt ist, signiert Git Commits in der Regel automatisch. + +--- + +### 6. Signatur lokal prüfen +```bash +git log --show-signature -1 +git verify-commit HEAD +``` + +--- + +## Lokaler Minimal-Check vor dem Push + +Vor dem Push solltest du mindestens diese Schritte ausführen: + +### Format anwenden +```bash +clang-format -i main.cpp tests/test_main.cpp +``` + +### Policy lokal ausführen +```bash +python3 ci/check_pr_policies.py +``` + +### Projekt lokal bauen +```bash +cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug +cmake --build build --parallel +ctest --test-dir build --output-on-failure +``` + +--- + +## Verhalten in Jenkins + +Die Jenkins-Pipeline umfasst typischerweise diese Stages: + +1. **Checkout** +2. **Policy Checks** +3. **Configure** +4. **Build** +5. **Test** +6. **Cleanup** + +Wenn die Policy-Checks fehlschlagen, werden die späteren Build- und Test-Stages nicht mehr ausgeführt. + +--- + +## Typische Fehlerursachen + +### Formatfehler +Beispiel: +```text +ERROR: Formatfehler in tests/test_main.cpp +``` + +#### Lösung +```bash +clang-format -i tests/test_main.cpp +``` + +--- + +### Kein passender Test gefunden +Beispiel: +```text +ERROR: Kein passender Test unter tests/, test/ oder qt6-after/qt6-migrated-benchmark/tests für main.cpp gefunden. +``` + +#### Lösung +- passenden Test anlegen +- Testdatei passend benennen +- Test in einem der erlaubten Testverzeichnisse ablegen + +--- + +### Commit nicht gültig SSH-signiert +Beispiel: +```text +ERROR: Commit ist nicht gültig mit erlaubtem SSH-Signierer signiert: +``` + +#### Lösung +- SSH-Signing lokal korrekt konfigurieren +- prüfen, ob der richtige Schlüssel verwendet wird +- prüfen, ob der öffentliche Schlüssel in `ci/allowed_signers` eingetragen ist +- Commit neu erstellen oder neu signieren + +--- + +### `.clang-format` fehlt +Beispiel: +```text +ERROR: .clang-format fehlt im Repository-Root. +``` + +#### Lösung +- `.clang-format` im Repository-Root anlegen oder wiederherstellen + +--- + +## Empfohlener Workflow + +1. Feature-Branch erstellen +2. Änderungen implementieren +3. Test ergänzen oder anpassen +4. Code formatieren +5. Commit SSH-signiert erstellen +6. lokal Policy, Build und Tests ausführen +7. pushen +8. Pull Request öffnen +9. Jenkins-Ergebnis prüfen +10. erst bei grünem Status mergen + +--- + +## Kurz-Checkliste vor jedem Push + +- [ ] C/C++-Dateien sind formatiert +- [ ] passender Test existiert +- [ ] Commit ist SSH-signiert +- [ ] öffentlicher Schlüssel steht in `ci/allowed_signers` +- [ ] `python3 ci/check_pr_policies.py` läuft erfolgreich +- [ ] Build und Tests laufen lokal + +--- + +## Ziel der Regeln + +Die Policies sollen sicherstellen, dass: + +- Code konsistent formatiert ist +- Änderungen nachvollziehbar getestet sind +- nur autorisierte Signierer Commits einbringen +- PRs frühzeitig automatisiert validiert werden \ No newline at end of file diff --git a/ci/allowed_signers b/ci/allowed_signers new file mode 100644 index 0000000..2087f6a --- /dev/null +++ b/ci/allowed_signers @@ -0,0 +1 @@ +torsten.wierschin@life-maps.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIG+sdfrCTkAHvZCwBGPGUaT4VFkX8YvnqprCpCrLajSH diff --git a/ci/check_pr_policies.py b/ci/check_pr_policies.py old mode 100755 new mode 100644 index 6df2f89..6f3538f --- a/ci/check_pr_policies.py +++ b/ci/check_pr_policies.py @@ -11,6 +11,7 @@ TEST_DIRS = [ ROOT / "qt6-after" / "qt6-migrated-benchmark" / "tests", ] CPP_EXTENSIONS = {".c", ".cc", ".cpp", ".cxx", ".h", ".hh", ".hpp", ".hxx"} +ALLOWED_SIGNERS_FILE = ROOT / "ci" / "allowed_signers" TEST_PATTERNS = [ "test_{name}.cpp", @@ -23,9 +24,11 @@ TEST_PATTERNS = [ "{name}gtest.h", ] + def run(cmd: str) -> str: return subprocess.check_output(cmd, shell=True, text=True).strip() + def git_ref_exists(ref: str) -> bool: try: subprocess.check_call( @@ -38,9 +41,11 @@ def git_ref_exists(ref: str) -> bool: except subprocess.CalledProcessError: return False + def is_pull_request_build() -> bool: return bool(os.environ.get("CHANGE_ID")) + def resolve_target_ref() -> str | None: raw = os.environ.get("CHANGE_TARGET", "").strip() @@ -61,13 +66,15 @@ def resolve_target_ref() -> str | None: return None -def changed_files(): + +def changed_files_and_base() -> tuple[list[str], str]: target = resolve_target_ref() if target: print(f"Vergleiche gegen Ziel-Ref: {target}") out = run(f"git diff --name-only {target}...HEAD") - return [line.strip() for line in out.splitlines() if line.strip()] + files = [line.strip() for line in out.splitlines() if line.strip()] + return files, target if is_pull_request_build(): print("ERROR: Kein gültiger Ziel-Branch-Ref für PR-Build gefunden.") @@ -76,23 +83,46 @@ def changed_files(): if git_ref_exists("HEAD~1"): print("Kein Ziel-Branch-Ref gefunden, verwende HEAD~1 für Branch-Build.") out = run("git diff --name-only HEAD~1..HEAD") - return [line.strip() for line in out.splitlines() if line.strip()] + files = [line.strip() for line in out.splitlines() if line.strip()] + return files, "HEAD~1" print("Keine Vergleichsbasis gefunden, behandle alle versionierten Dateien als geändert.") out = run("git ls-files") + files = [line.strip() for line in out.splitlines() if line.strip()] + return files, "" + + +def commits_in_range(base: str) -> list[str]: + if base: + if base == "HEAD~1": + out = run("git rev-list HEAD~1..HEAD") + else: + out = run(f"git rev-list {base}..HEAD") + else: + out = run("git rev-list HEAD") return [line.strip() for line in out.splitlines() if line.strip()] + def is_cpp_file(path: Path) -> bool: return path.suffix.lower() in CPP_EXTENSIONS + def require_clang_format(): if not (ROOT / ".clang-format").exists(): print("ERROR: .clang-format fehlt im Repository-Root.") sys.exit(1) + +def require_allowed_signers(): + if not ALLOWED_SIGNERS_FILE.exists(): + print(f"ERROR: {ALLOWED_SIGNERS_FILE} fehlt.") + sys.exit(1) + + def check_format(path: Path): subprocess.check_call(f"clang-format --dry-run --Werror '{path}'", shell=True) + def candidate_names(path: Path): stem = path.stem.lower() names = {stem} @@ -101,11 +131,13 @@ def candidate_names(path: Path): names.add(stem[:-len(suffix)]) return sorted(n for n in names if n) + def iter_test_files(): for test_dir in TEST_DIRS: if test_dir.exists(): yield from (p for p in test_dir.rglob("*") if p.is_file()) + def has_related_test(path: Path) -> bool: test_files = list(iter_test_files()) if not test_files: @@ -122,14 +154,46 @@ def has_related_test(path: Path) -> bool: return True return False + +def verify_commit_signature(commit: str): + env = os.environ.copy() + env["GIT_CONFIG_COUNT"] = "1" + env["GIT_CONFIG_KEY_0"] = "gpg.ssh.allowedSignersFile" + env["GIT_CONFIG_VALUE_0"] = str(ALLOWED_SIGNERS_FILE) + + subprocess.check_call( + f"git verify-commit {commit}", + shell=True, + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + def main(): require_clang_format() + require_allowed_signers() - files = changed_files() + files, base = changed_files_and_base() if not files: print("Keine geänderten Dateien erkannt.") return + commits = commits_in_range(base) + if not commits: + print("Keine neuen Commits im Vergleichsbereich erkannt.") + else: + for commit in commits: + print(f"Prüfe SSH-Signatur: {commit}") + try: + verify_commit_signature(commit) + except subprocess.CalledProcessError: + print( + "ERROR: Commit ist nicht gültig mit erlaubtem SSH-Signierer signiert: " + f"{commit}" + ) + sys.exit(1) + for rel in files: path = ROOT / rel if not path.exists(): @@ -143,10 +207,6 @@ def main(): sys.exit(1) if not has_related_test(path): - print(f"ERROR: Kein passender Test unter tests/, test/ oder qt6-after/qt6-migrated-benchmark/tests für {rel} gefunden.") - sys.exit(1) - - print("Policy-Checks erfolgreich.") - -if __name__ == "__main__": - main() + print( + "ERROR: Kein passender Test unter tests/, test/ oder " + f