diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0e036f1..87f438fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,14 @@ concurrency: group: ci-${{ github.ref }} cancel-in-progress: true +env: + LC_ALL: C.UTF-8 + LANG: C.UTF-8 + +defaults: + run: + shell: bash + jobs: pr-labeling: if: github.event_name == 'pull_request' @@ -27,6 +35,7 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 + fetch-tags: true - name: Setup .NET uses: actions/setup-dotnet@v4 with: @@ -40,7 +49,7 @@ jobs: - name: Run Entry Check env: GH_TOKEN: ${{ github.token }} - run: bash tools/ci/bin/run.sh pr-labeling + run: bash -euo pipefail tools/ci/bin/run.sh pr-labeling - name: Upload Artifact if: always() uses: actions/upload-artifact@v4 @@ -56,6 +65,7 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 + fetch-tags: true - name: Setup .NET uses: actions/setup-dotnet@v4 with: @@ -67,7 +77,7 @@ jobs: with: node-version: "20" - name: Run Entry Check - run: bash tools/ci/bin/run.sh preflight + run: bash -euo pipefail tools/ci/bin/run.sh preflight - name: Upload Artifact if: always() uses: actions/upload-artifact@v4 @@ -89,7 +99,7 @@ jobs: 8.0.x 10.0.102 - name: Run Entry Check - run: bash tools/ci/bin/run.sh docs-links-full + run: bash -euo pipefail tools/ci/bin/run.sh docs-links-full - name: Upload Artifact if: always() uses: actions/upload-artifact@v4 @@ -104,6 +114,9 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true - name: Setup .NET uses: actions/setup-dotnet@v4 with: @@ -111,7 +124,7 @@ jobs: 8.0.x 10.0.102 - name: Run Entry Check - run: bash tools/ci/bin/run.sh versioning-svt + run: bash -euo pipefail tools/ci/bin/run.sh versioning-svt - name: Upload Artifact if: always() uses: actions/upload-artifact@v4 @@ -133,7 +146,7 @@ jobs: 8.0.x 10.0.102 - name: Run Entry Check - run: bash tools/ci/bin/run.sh naming-snt + run: bash -euo pipefail tools/ci/bin/run.sh naming-snt - name: Upload Artifact if: always() uses: actions/upload-artifact@v4 @@ -155,7 +168,7 @@ jobs: 8.0.x 10.0.102 - name: Run Entry Check - run: bash tools/ci/bin/run.sh build + run: bash -euo pipefail tools/ci/bin/run.sh build - name: Upload Artifact if: always() uses: actions/upload-artifact@v4 @@ -177,7 +190,7 @@ jobs: 8.0.x 10.0.102 - name: Run Entry Check - run: bash tools/ci/bin/run.sh api-contract + run: bash -euo pipefail tools/ci/bin/run.sh api-contract - name: Upload Artifact if: always() uses: actions/upload-artifact@v4 @@ -199,7 +212,7 @@ jobs: 8.0.x 10.0.102 - name: Run Entry Check - run: bash tools/ci/bin/run.sh pack + run: bash -euo pipefail tools/ci/bin/run.sh pack - name: Upload Artifact if: always() uses: actions/upload-artifact@v4 @@ -226,7 +239,7 @@ jobs: name: ci-pack path: artifacts/ci/pack - name: Run Entry Check - run: bash tools/ci/bin/run.sh consumer-smoke + run: bash -euo pipefail tools/ci/bin/run.sh consumer-smoke - name: Upload Artifact if: always() uses: actions/upload-artifact@v4 @@ -253,7 +266,7 @@ jobs: name: ci-pack path: artifacts/ci/pack - name: Run Entry Check - run: bash tools/ci/bin/run.sh package-backed-tests + run: bash -euo pipefail tools/ci/bin/run.sh package-backed-tests - name: Upload Artifact if: always() uses: actions/upload-artifact@v4 @@ -275,7 +288,7 @@ jobs: 8.0.x 10.0.102 - name: Run Entry Check - run: bash tools/ci/bin/run.sh security-nuget + run: bash -euo pipefail tools/ci/bin/run.sh security-nuget - name: Upload Artifact if: always() uses: actions/upload-artifact@v4 @@ -297,7 +310,7 @@ jobs: 8.0.x 10.0.102 - name: Run Entry Check - run: bash tools/ci/bin/run.sh tests-bdd-coverage + run: bash -euo pipefail tools/ci/bin/run.sh tests-bdd-coverage - name: Upload Artifact if: always() uses: actions/upload-artifact@v4 @@ -333,6 +346,11 @@ jobs: with: name: ci-api-contract path: artifacts/ci/api-contract + - name: Download Pack Artifact + uses: actions/download-artifact@v4 + with: + name: ci-pack + path: artifacts/ci/pack - name: Download Consumer Smoke Artifact uses: actions/download-artifact@v4 with: @@ -369,7 +387,7 @@ jobs: name: ci-tests-bdd-coverage path: artifacts/ci/tests-bdd-coverage - name: Run Entry Check - run: bash tools/ci/bin/run.sh summary + run: bash -euo pipefail tools/ci/bin/run.sh summary - name: Upload Artifact if: always() uses: actions/upload-artifact@v4 diff --git a/.github/workflows/qodana.yml b/.github/workflows/qodana.yml index 43a7defe..ea55562e 100644 --- a/.github/workflows/qodana.yml +++ b/.github/workflows/qodana.yml @@ -9,6 +9,8 @@ on: permissions: contents: read + checks: write + pull-requests: write security-events: write jobs: @@ -44,11 +46,8 @@ jobs: if [ -f .editorconfig.qodana.bak ]; then mv .editorconfig.qodana.bak .editorconfig fi - - name: Skip Qodana (missing token) - if: env.QODANA_TOKEN == '' - run: echo "QODANA_TOKEN is not set; skipping Qodana scan." - name: Run Entry Check - if: env.QODANA_TOKEN != '' + if: always() && env.QODANA_TOKEN != '' run: bash tools/ci/bin/run.sh qodana - name: Upload SARIF To Code Scanning if: github.event_name != 'pull_request' && env.QODANA_TOKEN != '' diff --git a/.github/workflows/version-policy.yml b/.github/workflows/version-policy.yml index ecddf0cf..bcbadd61 100644 --- a/.github/workflows/version-policy.yml +++ b/.github/workflows/version-policy.yml @@ -13,9 +13,29 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true - name: Setup .NET SDK uses: actions/setup-dotnet@v4 with: dotnet-version: 10.0.x - name: Run versioning SVT (PR-safe) + env: + CI_DEFER_ARTIFACT_LINK_RESOLUTION: "1" run: bash tools/ci/bin/run.sh versioning-svt + - name: Upload versioning SVT artifact (ci-versioning-svt) + if: always() + uses: actions/upload-artifact@v4 + with: + name: ci-versioning-svt + path: artifacts/ci/versioning-svt/ + if-no-files-found: error + - name: Verify ci-versioning-svt artifact exists (post-upload) + if: always() + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + out="artifacts/ci/versioning-svt/version-policy_artifacts.json" + python3 tools/ci/bin/verify_run_artifact.py --repo "${GITHUB_REPOSITORY}" --run-id "${GITHUB_RUN_ID}" --artifact-name "ci-versioning-svt" --out "$out" diff --git a/docs/ci/001_PIPELINE_CI.MD b/docs/ci/001_PIPELINE_CI.MD index b408fca8..04801e3a 100644 --- a/docs/ci/001_PIPELINE_CI.MD +++ b/docs/ci/001_PIPELINE_CI.MD @@ -1,146 +1,94 @@ -# CI-Pipeline (SSOT) - -## 1. Zweck -Diese Pipeline liefert auditierbare Nachweise für Produktionsreife: -- Konsistenz/Format -- Build-Korrektheit (`--warnaserror`) -- Paket-Sicherheit (NuGet-Vulnerabilities) -- BDD-Readable-Tests mit Coverage-Gate -- Doku-Konsistenz -- deterministisches PR-Labeling und Versionierungs-Nachweise - -## 2. Workflow-Struktur -Der Workflow `CI` hat zwei klar getrennte Verantwortungswege: -- `pull_request`: `pr-labeling` (Governance, nicht-gating) -- `pull_request`/`push`: technische Qualitätsjobs (`preflight`, `versioning-svt`, `build`, `api-contract`, `pack`, `consumer-smoke`, `package-backed-tests`, `security-nuget`, `tests-bdd-coverage`, `summary`) - -## 3. Jobs (1 Job = 1 Verantwortung) -### Job: pr-labeling (Governance) -- geänderte Dateien und aktuelle PR-Labels erfassen -- `required/actual/reason` aus Versioning-Guard ableiten -- deterministische Label-Entscheidung berechnen (`decision.json`) -- Schema validieren -- veraltete Auto-Labels entfernen und neue setzen -- Artefakte hochladen - -### Job: preflight (fail-fast) -1. Label-Engine Golden-Tests -2. Doc-Consistency-Drift-Guard -3. Format-Check - -### Job: versioning-svt (fail-closed) -1. Erzwingt Tag-SSOT (keine statischen Versionfelder im Repository) -2. Blockiert Build/Test bei Policy-Verletzung - -### Job: build -1. Restore -2. Build (`--warnaserror`) - -### Job: api-contract -1. Public-API-Snapshot-Gate (`Category=ApiContract`) -2. Fail-closed bei unbeabsichtigter API-Drift - -### Job: pack -1. `dotnet pack` erzeugt `.nupkg` (Artefakt) -2. Paket-Metadaten (`id`, `version`) werden verifiziert - -### Job: consumer-smoke -1. Restore/Build/Run eines externen Consumers gegen das gepackte Paket -2. Harte Regel: kein `ProjectReference` zur Source-Library - -### Job: package-backed-tests -1. Definierter Kern-Test-Satz laeuft gegen das gepackte Paket -2. Beweist Verhaltensidentitaet package-basiert (nicht Source-basiert) - -### Job: security-nuget -1. Vulnerability-Scan (`--include-transitive`) -2. Deprecated-Packages-Report - -### Job: tests-bdd-coverage -1. Single-Run BDD-Tests + Coverage-Gate (`Line >= 85`, `Branch >= 69`) -2. Runner-Stack: Reqnroll + `Reqnroll.xunit.v3` (xUnit v3) - -### Job: summary -- Coverage- und Security-Zusammenfassung (nur Reporting) - -## 4. Required vs. Non-Required Checks -Required in Branch-Protection: -- `preflight` -- `versioning-svt` -- `build` -- `api-contract` -- `pack` -- `consumer-smoke` -- `package-backed-tests` -- `security-nuget` -- `tests-bdd-coverage` -- optional: `qodana` (policy-abhängig) - -Nicht required: -- `pr-labeling` (Governance-Automation, fail-open) -Wenn Label-API-Aufrufe fehlschlagen, wird dies als Artefakt protokolliert, ohne technische Quality-Gates zu brechen. - -## 5. Stabile Artefaktpfade -- `artifacts/labels/decision.json` -- `artifacts/docs/doc-check.txt` -- `artifacts/versioning/versioning-check.txt` -- `artifacts/format/format-check.txt` -- `artifacts/build/build-log.txt` -- `artifacts/security/nuget-vuln.txt` -- `artifacts/security/nuget-deprecated.txt` -- `artifacts/tests/**` (TRX, readable output, raw test log) -- `artifacts/coverage/coverage.cobertura.xml` -- `artifacts/coverage/coverage-summary.txt` - -## 6. Labeling- und Versioning-SSOT -- Policy: `docs/versioning/001_POLICY_VERSIONING.MD` -- Verhalten + Diagramme: `docs/versioning/004_POLICY_LABELING.MD` -- Ownership: `docs/governance/002_POLICY_LABELING.MD` - -## 7. Qodana -Qodana bleibt ein separater Workflow und ergänzt die CI-Qualitätsgates. -Coverage-SSOT bleibt CI (Coverage-Artefakte + Gate-Enforcement). -Für `pull_request` wird SARIF als Workflow-Artefakt veröffentlicht. -Code-Scanning-SARIF-Upload erfolgt nur auf non-PR-Runs, um PR-Noise zu vermeiden. -Profil-Hinweis: In `.qodana/profiles/fileclassifier.yaml` sind nur testpfad-spezifische Excludes für reine Redundanz-Inspections gesetzt (`tests/**`), Produktionscode bleibt unverändert streng. - -### 7.1 Qodana Ergebnisse & Artefakte -- Wenn `QODANA_TOKEN` fehlt, wird Qodana im Workflow übersprungen. -- Qodana schreibt Ergebnisse in `artifacts/ci/qodana/` (inkl. `qodana.sarif.json`). -- Für `pull_request`-Runs wird das Verzeichnis als Artefakt veröffentlicht. -- Für non-PR-Runs wird `artifacts/ci/qodana/qodana.sarif.json` in GitHub Code Scanning hochgeladen. -- Artefakte: - - `artifacts/ci/qodana/qodana.sarif.json` - - Verzeichnis `artifacts/ci/qodana/` - -Branch-Protection-Hinweis: -- Der Workflow-Status `qodana` muss in GitHub als Required Check konfiguriert werden, damit PR-Merges blockiert werden. - -## 8. Lokale Reproduktion -```bash -node tools/versioning/test-compute-pr-labels.js -python3 tools/check-doc-consistency.py -python3 tools/check-docs.py -bash tools/versioning/check-versioning-svt.sh -dotnet test tests/FileTypeDetectionLib.Tests/FileTypeDetectionLib.Tests.csproj -c Release --filter "Category=ApiContract" -v minimal -dotnet pack src/FileTypeDetection/FileTypeDetectionLib.vbproj -c Release -o artifacts/nuget -v minimal -dotnet restore samples/PortableConsumer/PortableConsumer.csproj --configfile samples/PortableConsumer/NuGet.config -p:PortableConsumerPackageVersion=1.0.0 -v minimal -dotnet build samples/PortableConsumer/PortableConsumer.csproj -c Release --no-restore -p:PortableConsumerPackageVersion=1.0.0 -v minimal -dotnet restore tests/PackageBacked.Tests/PackageBacked.Tests.csproj --configfile tests/PackageBacked.Tests/NuGet.config -p:PackageBackedVersion=1.0.0 -v minimal -dotnet test tests/PackageBacked.Tests/PackageBacked.Tests.csproj -c Release --no-restore -p:PackageBackedVersion=1.0.0 -v minimal -dotnet format FileClassifier.sln --verify-no-changes -dotnet restore FileClassifier.sln -v minimal -dotnet build FileClassifier.sln --no-restore -warnaserror -v minimal - -dotnet list FileClassifier.sln package --vulnerable --include-transitive -dotnet list FileClassifier.sln package --deprecated - -TEST_BDD_OUTPUT_DIR=artifacts/tests bash tools/test-bdd-readable.sh -- \ - /p:CollectCoverage=true \ - /p:Include="[FileTypeDetectionLib]*" \ - /p:CoverletOutputFormat=cobertura \ - /p:CoverletOutput="$(pwd)/artifacts/coverage/coverage" \ - /p:Threshold=85%2c69 \ - /p:ThresholdType=line%2cbranch \ - /p:ThresholdStat=total +# CI Pipeline (SSOT) + +## Scope +This document describes the executable CI topology and artifact contract. +Normative policy thresholds and rule parameters stay in `tools/ci/policies/rules/` and `docs/governance/001_POLICY_CI.MD`. + +## Required Status Contexts +Main branch protection requires exactly these contexts (`strict: true`): `preflight`, `version-policy`, `build`, `api-contract`, `pack`, `consumer-smoke`, `package-backed-tests`, `security-nuget`, `tests-bdd-coverage`. +Evidence: branch protection API output (`required_status_checks.contexts`) and `.github/workflows/ci.yml:59-347` plus `.github/workflows/version-policy.yml`. + +## Workflow Topology +- `pr-labeling` runs only on `pull_request` and is separate from the required technical gate path (`.github/workflows/ci.yml:22-58`). +- The technical gate path starts at `preflight` and fans into downstream jobs via `needs` (`.github/workflows/ci.yml:89-427`). +- `summary` aggregates downstream artifacts and runs contract checks (`.github/workflows/ci.yml:349-427`, `tools/ci/bin/run.sh:446`). + +```mermaid +flowchart TD + pr["pr-labeling"]:::meta + pre["preflight"] --> docs["docs-links-full"] + pre --> svt["versioning-svt"] + pre --> naming["naming-snt"] + pre --> build["build"] + svt --> build + naming --> build + build --> api["api-contract"] + build --> pack["pack"] + svt --> pack + pack --> smoke["consumer-smoke"] + pack --> pkg["package-backed-tests"] + build --> sec["security-nuget"] + build --> bdd["tests-bdd-coverage"] + docs --> sum["summary"] + naming --> sum + svt --> sum + api --> sum + smoke --> sum + pkg --> sum + sec --> sum + bdd --> sum + + req["Branch protection required contexts"]:::gate + pre -.-> req + build -.-> req + api -.-> req + pack -.-> req + smoke -.-> req + pkg -.-> req + sec -.-> req + bdd -.-> req + vp["version-policy (separate workflow)"] -.-> req + + classDef gate fill:#e9f5ff,stroke:#2a6f97,color:#073b4c; + classDef meta fill:#f8f9fa,stroke:#6c757d,color:#343a40; ``` + +## Artifact Contract +Each `tools/ci/bin/run.sh ` invocation initializes and finalizes a fixed artifact set: +- `raw.log` +- `summary.md` +- `result.json` +- `diag.json` + +Evidence: +- Artifact path initialization in `tools/ci/lib/result.sh:12-20`. +- File materialization in `tools/ci/lib/result.sh:28-34`. +- Final `result.json` composition in `tools/ci/lib/result.sh:78-112`. +- Universal runner wiring in `tools/ci/bin/run.sh:16-28`. + +## Contract Matrix +| Job | Entrypoint | Artifact path | Contract validation path | Evidence | +|---|---|---|---|---| +| `preflight` | `bash tools/ci/bin/run.sh preflight` | `artifacts/ci/preflight/` | Result contract + policy bridge | `.github/workflows/ci.yml:77-87`, `tools/ci/bin/run.sh:159-171` | +| `version-policy` | `bash tools/ci/bin/run.sh versioning-svt` | `artifacts/ci/versioning-svt/` | Result contract + policy bridge | `tools/ci/bin/run.sh:242-250` | +| `build` | `bash tools/ci/bin/run.sh build` | `artifacts/ci/build/` | Result contract | `.github/workflows/ci.yml:177-187`, `tools/ci/bin/run.sh:179-183` | +| `api-contract` | `bash tools/ci/bin/run.sh api-contract` | `artifacts/ci/api-contract/` | Result contract | `.github/workflows/ci.yml:202-212`, `tools/ci/bin/run.sh:185-189` | +| `pack` | `bash tools/ci/bin/run.sh pack` | `artifacts/ci/pack/` | Result contract + package metadata checks | `.github/workflows/ci.yml:227-237`, `tools/ci/bin/run.sh:191-230` | +| `consumer-smoke` | `bash tools/ci/bin/run.sh consumer-smoke` | `artifacts/ci/consumer-smoke/` | Result contract + package-consumer execution | `.github/workflows/ci.yml:257-267`, `tools/ci/bin/run.sh:252-283` | +| `package-backed-tests` | `bash tools/ci/bin/run.sh package-backed-tests` | `artifacts/ci/package-backed-tests/` | Result contract + package-backed tests | `.github/workflows/ci.yml:287-297`, `tools/ci/bin/run.sh:285-315` | +| `security-nuget` | `bash tools/ci/bin/run.sh security-nuget` | `artifacts/ci/security-nuget/` | Result contract + High/Critical fail-close | `.github/workflows/ci.yml:312-322`, `tools/ci/bin/run.sh:317-329` | +| `tests-bdd-coverage` | `bash tools/ci/bin/run.sh tests-bdd-coverage` | `artifacts/ci/tests-bdd-coverage/` | Result contract + coverage threshold execution | `.github/workflows/ci.yml:337-347`, `tools/ci/bin/run.sh:331-348` | +| `summary` | `bash tools/ci/bin/run.sh summary` | `artifacts/ci/summary/` | Policy contract aggregation | `.github/workflows/ci.yml:417-427`, `tools/ci/bin/run.sh:424-430` | +| `pr-labeling` | `bash tools/ci/bin/run.sh pr-labeling` | `artifacts/ci/pr-labeling/` | Label decision schema + apply+verify | `.github/workflows/ci.yml:45-57`, `tools/ci/bin/run.sh:350-400` | + +## Labeling and Versioning Decision Path +- Decision generation: `compute-pr-labels.js` writes `decision.json` (`tools/ci/bin/run.sh:371-372`). +- Schema validation: `validate-label-decision.js` (`tools/ci/bin/run.sh:374`). +- Label application and post-apply verification: deterministic GitHub API PUT (curl-backed) and post-apply re-read (`tools/ci/bin/run.sh:375-399`, `tools/ci/bin/github_api.py`). +- Workflow token source: `GH_TOKEN: ${{ github.token }}` (`.github/workflows/ci.yml:46-50`). + +## Qodana Contract Position +Qodana runs in a separate workflow and is validated by `run.sh qodana`: +- Qodana action execution and SARIF output path (`.github/workflows/qodana.yml:34-40`, `.github/workflows/qodana.yml:59`). +- Contract check invocation (`.github/workflows/qodana.yml:47-48`, `tools/ci/bin/run.sh:402-422`). +- Qodana artifact upload (`.github/workflows/qodana.yml:54-60`). diff --git a/docs/governance/001_POLICY_CI.MD b/docs/governance/001_POLICY_CI.MD index b6f4d572..626535f3 100644 --- a/docs/governance/001_POLICY_CI.MD +++ b/docs/governance/001_POLICY_CI.MD @@ -11,6 +11,8 @@ Entry index: ## Global Rules - Fail-closed: no silent bypass paths. - Workflow YAML only calls entry scripts under `tools/ci/bin/`. +- Required branch-protection contexts remain fixed: `preflight`, `version-policy`, `build`, `api-contract`, `pack`, `consumer-smoke`, `package-backed-tests`, `security-nuget`, `tests-bdd-coverage`. + Evidence: branch protection API (`required_status_checks.contexts`) and `.github/workflows/ci.yml:59-347`. ## Result Contract All required checks MUST write: @@ -19,6 +21,17 @@ All required checks MUST write: - `artifacts/ci//result.json` `result.json` must comply with `tools/ci/schema/result.schema.json`. +Implementation evidence: `tools/ci/lib/result.sh:12-20`, `tools/ci/lib/result.sh:28-34`, `tools/ci/lib/result.sh:78-112`. + +## Governance View (Execution Boundary) +```mermaid +flowchart LR + yml["Workflow declarations\n.github/workflows/*.yml"] --> run["Single entrypoint\n tools/ci/bin/run.sh "] + run --> result["Result contract artifacts\nraw.log / summary.md / result.json / diag.json"] + result --> policy["Policy validators / schema\nPolicyRunner + result schema"] +``` + +The detailed job DAG and contract matrix are maintained in `docs/ci/001_PIPELINE_CI.MD` to avoid duplication. ## Rule Catalog - Rule IDs and file ownership are indexed in `docs/governance/POLICY_INDEX.md`. diff --git a/qodana.yaml b/qodana.yaml index 2591eb7a..c5db3ed1 100644 --- a/qodana.yaml +++ b/qodana.yaml @@ -2,4 +2,16 @@ version: "1.0" linter: jetbrains/qodana-dotnet:2025.3 profile: - path: .qodana/profiles/fileclassifier.yaml + path: .qodana/profiles/fileclassifier.yaml + +# Scope-limited excludes for known non-security false positives on the VB codebase. +exclude: + - name: CheckNamespace + paths: + - src/FileTypeDetection/** + - name: VBWarnings__BC40056 + paths: + - src/FileTypeDetection/** + - name: UnusedImportClause + paths: + - src/FileTypeDetection/** diff --git a/src/FileTypeDetection/FileTypeOptions.vb b/src/FileTypeDetection/FileTypeOptions.vb index b2b366f9..d0ae6c5e 100644 --- a/src/FileTypeDetection/FileTypeOptions.vb +++ b/src/FileTypeDetection/FileTypeOptions.vb @@ -50,7 +50,8 @@ Namespace Global.Tomtastisch.FileClassifier For Each p In doc.RootElement.EnumerateObject() Select Case p.Name.ToLowerInvariant() Case "headeronlynonzip" _ - : headerOnlyNonZip = ParseBoolean(p.Value, headerOnlyNonZip, p.Name, logger) + : headerOnlyNonZip = ParseBoolean( + p.Value, headerOnlyNonZip, p.Name, logger) Case "maxbytes" : maxBytes = ParsePositiveLong(p.Value, maxBytes, p.Name, logger) Case "sniffbytes" : sniffBytes = ParsePositiveInt(p.Value, sniffBytes, p.Name, logger) Case "maxzipentries" _ @@ -231,6 +232,7 @@ Namespace Global.Tomtastisch.FileClassifier ByRef includeFastHash As Boolean, ByRef materializedFileName As String, logger As ILogger) + If el.ValueKind <> JsonValueKind.Object Then LogGuard.Warn(logger, "[Config] 'deterministicHash' muss ein JSON-Objekt sein.") Return @@ -239,13 +241,27 @@ Namespace Global.Tomtastisch.FileClassifier For Each p In el.EnumerateObject() Select Case p.Name.ToLowerInvariant() Case "includepayloadcopies" - includePayloadCopies = ParseBoolean(p.Value, includePayloadCopies, $"deterministicHash.{p.Name}", - logger) + includePayloadCopies = ParseBoolean( + p.Value, + includePayloadCopies, + $"deterministicHash.{p.Name}", + logger + ) Case "includefasthash" - includeFastHash = ParseBoolean(p.Value, includeFastHash, $"deterministicHash.{p.Name}", logger) + includeFastHash = ParseBoolean( + p.Value, + includeFastHash, + $"deterministicHash.{p.Name}", + logger + ) Case "materializedfilename" - materializedFileName = ParseString(p.Value, materializedFileName, $"deterministicHash.{p.Name}", - logger) + materializedFileName = ParseString( + p.Value, + materializedFileName, + $"deterministicHash.{p.Name}", + logger + ) + Case Else LogGuard.Warn(logger, $"[Config] Unbekannter Schluessel 'deterministicHash.{p.Name}' ignoriert.") End Select diff --git a/tools/ci/bin/github_api.py b/tools/ci/bin/github_api.py new file mode 100644 index 00000000..d365639e --- /dev/null +++ b/tools/ci/bin/github_api.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from typing import Any + + +def _fail(msg: str) -> None: + print(f"ERROR: {msg}", file=sys.stderr) + raise SystemExit(1) + + +def _get_token() -> str: + token = (os.environ.get("GITHUB_TOKEN", "") or os.environ.get("GH_TOKEN", "")).strip() + if not token: + _fail("GITHUB_TOKEN/GH_TOKEN is missing; cannot call GitHub API fail-closed.") + return token + + +def _curl_json(method: str, url: str, token: str, payload_path: str | None = None) -> Any: + cmd = [ + "curl", + "--fail-with-body", + "--location", + "--silent", + "--show-error", + "--max-time", + "30", + "--request", + method, + "--header", + "Accept: application/vnd.github+json", + "--header", + "X-GitHub-Api-Version: 2022-11-28", + "--header", + f"Authorization: Bearer {token}", + "--user-agent", + "fileclassifier-github-api", + url, + ] + if payload_path is not None: + cmd.extend(["--header", "Content-Type: application/json"]) + cmd.extend(["--data-binary", f"@{payload_path}"]) + + try: + proc = subprocess.run(cmd, check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + except FileNotFoundError: + _fail("curl is not available; cannot call GitHub API fail-closed.") + + if proc.returncode != 0: + stdout = proc.stdout.decode("utf-8", errors="replace").strip() + stderr = proc.stderr.decode("utf-8", errors="replace").strip() + _fail(f"curl failed (exit={proc.returncode}). stderr={stderr!r} body_prefix={stdout[:400]!r}") + + raw = proc.stdout + try: + return json.loads(raw.decode("utf-8")) + except Exception as exc: + _fail(f"GitHub API response JSON invalid: {exc}") + raise AssertionError("unreachable") + + +def _api_base(repo: str) -> str: + repo = repo.strip() + if "/" not in repo: + _fail(f"--repo must be owner/repo, got: {repo!r}") + return f"https://api.github.com/repos/{repo}" + + +def _get_pr_files(repo: str, pr: int) -> list[str]: + token = _get_token() + base = _api_base(repo) + files: list[str] = [] + page = 1 + while True: + url = f"{base}/pulls/{pr}/files?per_page=100&page={page}" + payload = _curl_json("GET", url, token) + if not isinstance(payload, list): + _fail("PR files payload invalid: expected list") + if not payload: + break + for item in payload: + if not isinstance(item, dict): + _fail("PR files payload invalid: list item not object") + name = item.get("filename") + if not isinstance(name, str) or not name: + _fail("PR files payload invalid: missing/invalid filename") + files.append(name) + if len(payload) < 100: + break + page += 1 + if page > 50: + _fail("PR files pagination exceeded 50 pages; refusing to continue") + return files + + +def _get_issue_labels(repo: str, issue: int) -> list[str]: + token = _get_token() + base = _api_base(repo) + url = f"{base}/issues/{issue}" + payload = _curl_json("GET", url, token) + if not isinstance(payload, dict): + _fail("Issue payload invalid: expected object") + labels = payload.get("labels") + if labels is None: + # GitHub API should return labels, but fail-closed if not present. + _fail("Issue payload invalid: missing labels") + if not isinstance(labels, list): + _fail("Issue payload invalid: labels not list") + out: list[str] = [] + for l in labels: + if not isinstance(l, dict): + _fail("Issue payload invalid: label item not object") + name = l.get("name") + if not isinstance(name, str) or not name: + _fail("Issue payload invalid: label name missing/invalid") + out.append(name) + return out + + +def _get_pr_title(repo: str, pr: int) -> str: + token = _get_token() + base = _api_base(repo) + url = f"{base}/pulls/{pr}" + payload = _curl_json("GET", url, token) + if not isinstance(payload, dict): + _fail("PR payload invalid: expected object") + title = payload.get("title") + if not isinstance(title, str) or not title: + _fail("PR payload invalid: title missing/invalid") + return title + + +def _put_issue_labels(repo: str, issue: int, payload_path: str) -> None: + token = _get_token() + base = _api_base(repo) + if not os.path.isfile(payload_path): + _fail(f"--payload not found: {payload_path!r}") + url = f"{base}/issues/{issue}/labels" + payload = _curl_json("PUT", url, token, payload_path=payload_path) + if not isinstance(payload, list): + # API returns label objects list on success for this endpoint. + _fail("PUT labels response invalid: expected list") + + +def main() -> int: + parser = argparse.ArgumentParser(prog="github_api.py") + sub = parser.add_subparsers(dest="cmd", required=True) + + p_files = sub.add_parser("pr-files", help="Return JSON array of PR filenames") + p_files.add_argument("--repo", required=True, help="owner/repo") + p_files.add_argument("--pr", required=True, type=int) + + p_labels = sub.add_parser("issue-labels", help="Return JSON array of issue label names") + p_labels.add_argument("--repo", required=True, help="owner/repo") + p_labels.add_argument("--issue", required=True, type=int) + p_labels.add_argument("--sort", action="store_true", help="Sort label names deterministically") + + p_title = sub.add_parser("pr-title", help="Return PR title as a single line (raw string)") + p_title.add_argument("--repo", required=True, help="owner/repo") + p_title.add_argument("--pr", required=True, type=int) + + p_put = sub.add_parser("put-issue-labels", help="PUT labels payload to /issues/{n}/labels") + p_put.add_argument("--repo", required=True, help="owner/repo") + p_put.add_argument("--issue", required=True, type=int) + p_put.add_argument("--payload", required=True, help="Path to JSON payload file") + + args = parser.parse_args() + + if args.cmd == "pr-files": + files = _get_pr_files(args.repo, args.pr) + sys.stdout.write(json.dumps(files, separators=(",", ":"))) + return 0 + + if args.cmd == "issue-labels": + labels = _get_issue_labels(args.repo, args.issue) + if args.sort: + labels = sorted(labels) + sys.stdout.write(json.dumps(labels, separators=(",", ":"))) + return 0 + + if args.cmd == "pr-title": + title = _get_pr_title(args.repo, args.pr) + sys.stdout.write(title) + return 0 + + if args.cmd == "put-issue-labels": + _put_issue_labels(args.repo, args.issue, args.payload) + return 0 + + _fail(f"unknown command: {args.cmd!r}") + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) + diff --git a/tools/ci/bin/run.sh b/tools/ci/bin/run.sh index 7356de08..85ac84fa 100755 --- a/tools/ci/bin/run.sh +++ b/tools/ci/bin/run.sh @@ -15,15 +15,6 @@ fi OUT_DIR="artifacts/ci/${CHECK_ID}" -if [[ "$CHECK_ID" == "artifact_contract" || "$CHECK_ID" == "summary" ]]; then - cd "$ROOT_DIR" - dotnet restore --locked-mode "${ROOT_DIR}/tools/ci/checks/ResultSchemaValidator/ResultSchemaValidator.csproj" - dotnet build -c Release "${ROOT_DIR}/tools/ci/checks/ResultSchemaValidator/ResultSchemaValidator.csproj" - dotnet restore --locked-mode "${ROOT_DIR}/tools/ci/checks/PolicyRunner/PolicyRunner.csproj" - dotnet build -c Release "${ROOT_DIR}/tools/ci/checks/PolicyRunner/PolicyRunner.csproj" - exec dotnet "${ROOT_DIR}/tools/ci/checks/PolicyRunner/bin/Release/net10.0/PolicyRunner.dll" --check-id "${CHECK_ID}" --repo-root "${ROOT_DIR}" --out-dir "${OUT_DIR}" -fi - ci_result_init "$CHECK_ID" "$OUT_DIR" finalized=0 @@ -46,6 +37,24 @@ run_or_fail() { fi } +gh_retry() { + local max_attempts="${GH_RETRY_MAX_ATTEMPTS:-4}" + local delay_secs="${GH_RETRY_INITIAL_DELAY_SECS:-2}" + local attempt=1 + + while true; do + if "$@"; then + return 0 + fi + if (( attempt >= max_attempts )); then + return 1 + fi + sleep "$delay_secs" + attempt=$((attempt + 1)) + delay_secs=$((delay_secs * 2)) + done +} + log_contains_code() { local code="$1" if command -v rg >/dev/null 2>&1; then @@ -372,15 +381,50 @@ run_pr_labeling() { run_or_fail "CI-LABEL-001" "Derive required versioning decision" env MODE=required BASE_REF=origin/main HEAD_REF="$head_sha" "${ROOT_DIR}/tools/versioning/check-versioning.sh" local files_json labels_json pr_title - files_json="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${pr_number}/files" --paginate --jq '.[].filename' | jq -Rsc 'split("\n")[:-1]')" - labels_json="$(gh api "repos/${GITHUB_REPOSITORY}/issues/${pr_number}" --jq '[.labels[].name]')" - pr_title="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${pr_number}" --jq '.title')" + if ! files_json="$(gh_retry python3 "${ROOT_DIR}/tools/ci/bin/github_api.py" pr-files --repo "${GITHUB_REPOSITORY}" --pr "${pr_number}")"; then + ci_result_add_violation "CI-LABEL-001" "fail" "Failed to read PR files from GitHub API." "$CI_RAW_LOG" + return 1 + fi + if ! labels_json="$(gh_retry python3 "${ROOT_DIR}/tools/ci/bin/github_api.py" issue-labels --repo "${GITHUB_REPOSITORY}" --issue "${pr_number}")"; then + ci_result_add_violation "CI-LABEL-001" "fail" "Failed to read PR labels from GitHub API." "$CI_RAW_LOG" + return 1 + fi + if ! pr_title="$(gh_retry python3 "${ROOT_DIR}/tools/ci/bin/github_api.py" pr-title --repo "${GITHUB_REPOSITORY}" --pr "${pr_number}")"; then + ci_result_add_violation "CI-LABEL-001" "fail" "Failed to read PR title from GitHub API." "$CI_RAW_LOG" + return 1 + fi mkdir -p "${OUT_DIR}" FILES_JSON="$files_json" EXISTING_LABELS_JSON="$labels_json" PR_TITLE="$pr_title" VERSION_REQUIRED="none" VERSION_ACTUAL="none" VERSION_REASON="contract-run" VERSION_GUARD_EXIT="0" OUTPUT_PATH="${OUT_DIR}/decision.json" \ ci_run_capture "Compute deterministic labels" node "${ROOT_DIR}/tools/versioning/compute-pr-labels.js" run_or_fail "CI-LABEL-001" "Validate label decision" node "${ROOT_DIR}/tools/versioning/validate-label-decision.js" "${ROOT_DIR}/tools/versioning/label-schema.json" "${OUT_DIR}/decision.json" + local expected_json actual_json put_payload_path + expected_json="$(jq -cn \ + --argjson existing "${labels_json}" \ + --argjson add "$(jq -c '.labels_to_add // []' "${OUT_DIR}/decision.json")" \ + --argjson remove "$(jq -c '.labels_to_remove // []' "${OUT_DIR}/decision.json")" \ + '$existing + | map(select(. as $label | ($remove | index($label) | not))) + | . + $add + | unique + | sort')" + put_payload_path="${OUT_DIR}/labels-put.json" + jq -cn --argjson labels "${expected_json}" '{labels:$labels}' > "${put_payload_path}" + + run_or_fail "CI-LABEL-001" "Apply labels (single deterministic PUT)" gh_retry python3 "${ROOT_DIR}/tools/ci/bin/github_api.py" put-issue-labels --repo "${GITHUB_REPOSITORY}" --issue "${pr_number}" --payload "${put_payload_path}" + + if ! actual_json="$(gh_retry python3 "${ROOT_DIR}/tools/ci/bin/github_api.py" issue-labels --repo "${GITHUB_REPOSITORY}" --issue "${pr_number}" --sort)"; then + ci_result_add_violation "CI-LABEL-001" "fail" "Failed to re-read PR labels after apply." "$CI_RAW_LOG" + return 1 + fi + if [[ "${actual_json}" != "${expected_json}" ]]; then + ci_result_add_violation "CI-LABEL-001" "fail" "Applied labels do not match decision.json." "${OUT_DIR}/decision.json" "${CI_RAW_LOG}" + ci_result_append_summary "PR labeling apply failed verification." + return 1 + fi + + ci_run_capture "Post-apply labels confirmation" gh_retry python3 "${ROOT_DIR}/tools/ci/bin/github_api.py" issue-labels --repo "${GITHUB_REPOSITORY}" --issue "${pr_number}" --sort ci_result_append_summary "PR labeling checks completed." } @@ -406,6 +450,14 @@ run_qodana_contract() { ci_result_append_summary "Qodana contract validation completed." } +run_policy_contract() { + build_validators + if ! run_policy_runner_bridge "$CHECK_ID" "$OUT_DIR" "Policy contract check (${CHECK_ID})" "tools/ci/policies/rules"; then + return 1 + fi + ci_result_append_summary "Policy contract check '${CHECK_ID}' completed." +} + main() { cd "$ROOT_DIR" case "$CHECK_ID" in @@ -420,10 +472,7 @@ main() { build) run_build ;; security-nuget) run_security_nuget ;; tests-bdd-coverage) run_tests_bdd_coverage ;; - summary) - ci_result_add_violation "CI-RUNNER-001" "fail" "summary must be executed via PolicyRunner bridge" "tools/ci/bin/run.sh" - return 2 - ;; + summary|artifact_contract) run_policy_contract ;; pr-labeling) run_pr_labeling ;; qodana) run_qodana_contract ;; *) @@ -435,6 +484,11 @@ main() { main +if [[ "$finalized" -eq 0 ]]; then + ci_result_finalize + finalized=1 +fi + if [[ "$(cat "$CI_STATUS_FILE")" == "fail" ]]; then ci_result_append_summary "Check '${CHECK_ID}' failed." exit 1 diff --git a/tools/ci/bin/verify_run_artifact.py b/tools/ci/bin/verify_run_artifact.py new file mode 100644 index 00000000..a874cd14 --- /dev/null +++ b/tools/ci/bin/verify_run_artifact.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys + + +def _fail(msg: str) -> None: + print(f"ERROR: {msg}", file=sys.stderr) + raise SystemExit(1) + + +def _get_token() -> str: + # Fail-closed: do not attempt unauthenticated GitHub API calls. + token = (os.environ.get("GITHUB_TOKEN", "") or os.environ.get("GH_TOKEN", "")).strip() + if not token: + _fail("GITHUB_TOKEN/GH_TOKEN is missing; cannot verify artifacts fail-closed.") + return token + + +def _curl_get(url: str, token: str) -> bytes: + # Use curl to match the workflow hardening requirement and to ensure non-2xx fails the job. + cmd = [ + "curl", + "--fail-with-body", + "--location", + "--silent", + "--show-error", + "--max-time", + "30", + "--header", + "Accept: application/vnd.github+json", + "--header", + "X-GitHub-Api-Version: 2022-11-28", + "--header", + f"Authorization: Bearer {token}", + "--user-agent", + "fileclassifier-verify-run-artifact", + url, + ] + try: + proc = subprocess.run(cmd, check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + except FileNotFoundError: + _fail("curl is not available; cannot verify artifacts fail-closed.") + + if proc.returncode != 0: + # Keep error message bounded; body is in stdout for --fail-with-body. + stdout = proc.stdout.decode("utf-8", errors="replace").strip() + stderr = proc.stderr.decode("utf-8", errors="replace").strip() + _fail(f"curl failed (exit={proc.returncode}). stderr={stderr!r} body_prefix={stdout[:400]!r}") + + return proc.stdout + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--repo", required=True, help="owner/repo") + parser.add_argument("--run-id", required=True, help="GitHub Actions run id") + parser.add_argument("--artifact-name", required=True) + parser.add_argument("--out", required=True, help="Where to write the API response JSON") + args = parser.parse_args() + + token = _get_token() + + repo = args.repo.strip() + run_id = args.run_id.strip() + if "/" not in repo: + _fail(f"--repo must be owner/repo, got: {repo!r}") + if not run_id.isdigit(): + _fail(f"--run-id must be numeric, got: {run_id!r}") + + url = f"https://api.github.com/repos/{repo}/actions/runs/{run_id}/artifacts" + raw = _curl_get(url, token) + + out_path = args.out + os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True) + with open(out_path, "wb") as f: + f.write(raw) + + try: + payload = json.loads(raw.decode("utf-8")) + except Exception as exc: + _fail(f"artifact listing JSON invalid: {exc}") + + artifacts = payload.get("artifacts") + if not isinstance(artifacts, list): + _fail("artifact listing payload invalid: missing/invalid 'artifacts' list") + + want = args.artifact_name + if not any(isinstance(a, dict) and a.get("name") == want for a in artifacts): + _fail(f"required artifact {want!r} not found in run artifacts") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/ci/errors/classes.toml b/tools/ci/errors/classes.toml new file mode 100644 index 00000000..8e6a3794 --- /dev/null +++ b/tools/ci/errors/classes.toml @@ -0,0 +1,6 @@ +[classes] +missing_input = "01" +command_failed = "02" +policy_violation = "03" +schema_failure = "04" +blocking_findings = "05" diff --git a/tools/ci/errors/errors.toml b/tools/ci/errors/errors.toml new file mode 100644 index 00000000..479fc959 --- /dev/null +++ b/tools/ci/errors/errors.toml @@ -0,0 +1,16 @@ +[errors] +E0102 = "CI setup command failed for check '{check_id}' ({rule_id})." +E1002 = "Policy execution command failed for check '{check_id}' ({rule_id})." +E1003 = "Policy violation detected for check '{check_id}' ({rule_id})." +E2002 = "Build command failed for check '{check_id}' ({rule_id})." +E3002 = "Test execution failed for check '{check_id}' ({rule_id})." +E4002 = "Packaging command failed for check '{check_id}' ({rule_id})." +E5002 = "Security scan command failed for check '{check_id}' ({rule_id})." +E5005 = "Security blocking findings detected for check '{check_id}' ({rule_id})." +E6001 = "Qodana required input missing for check '{check_id}' ({rule_id})." +E6002 = "Qodana contract execution failed for check '{check_id}' ({rule_id})." +E6005 = "Qodana blocking findings detected for check '{check_id}' ({rule_id})." +E7001 = "Runner invocation is invalid for check '{check_id}' ({rule_id})." +E8004 = "Result schema validation failed for check '{check_id}' ({rule_id})." +E9002 = "CI command failed for check '{check_id}' ({rule_id})." +E9901 = "Error mapping/artifact-link failure for check '{check_id}' ({reason})." diff --git a/tools/ci/errors/steps.toml b/tools/ci/errors/steps.toml new file mode 100644 index 00000000..eeb2d748 --- /dev/null +++ b/tools/ci/errors/steps.toml @@ -0,0 +1,12 @@ +[steps] +setup = "01" +policy = "10" +build = "20" +test = "30" +pack = "40" +security = "50" +qodana = "60" +runner = "70" +schema = "80" +generic = "90" +error_ux = "99" diff --git a/tools/ci/lib/error_ux.py b/tools/ci/lib/error_ux.py new file mode 100644 index 00000000..731a6cd0 --- /dev/null +++ b/tools/ci/lib/error_ux.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import datetime as dt +import json +import os +import platform +import re +import subprocess +import sys +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + +try: + import tomllib +except Exception as exc: # pragma: no cover + print(f"FATAL: tomllib unavailable: {exc}", file=sys.stderr) + sys.exit(2) + + +def _load_toml(path: Path, key: str) -> dict[str, str]: + with path.open("rb") as f: + data = tomllib.load(f) + section = data.get(key) + if not isinstance(section, dict): + raise ValueError(f"missing [{key}] in {path.as_posix()}") + out: dict[str, str] = {} + for k, v in section.items(): + out[str(k)] = str(v) + return out + + +def _valid_id(value: str) -> bool: + return bool(re.fullmatch(r"\d{2}", value)) + + +def _dotnet_version() -> str: + try: + proc = subprocess.run( + ["dotnet", "--version"], + capture_output=True, + text=True, + check=False, + ) + text = (proc.stdout or proc.stderr or "").strip() + return text if text else "unknown" + except Exception: + return "unknown" + + +def _render(template: str, values: dict[str, str]) -> str: + return template.format(**values) + + +def _resolve_artifact_url(run_url: str, artifact_name: str) -> str: + # Support GitHub.com and GHES: CI_RUN_URL is derived from GITHUB_SERVER_URL. + parsed = urllib.parse.urlparse(run_url) + if parsed.scheme not in ("http", "https") or not parsed.netloc: + raise ValueError("run_url_not_parseable") + parts = [p for p in (parsed.path or "").split("/") if p] + if len(parts) < 5 or parts[2:4] != ["actions", "runs"] or not parts[4].isdigit(): + raise ValueError("run_url_not_parseable") + owner, repo, run_id = parts[0], parts[1], parts[4] + + api_base = (os.environ.get("GITHUB_API_URL") or "https://api.github.com").rstrip("/") + endpoint = f"{api_base}/repos/{owner}/{repo}/actions/runs/{run_id}/artifacts?name={urllib.parse.quote(artifact_name, safe='')}" + token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") + headers = { + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "fileclassifier-ci-error-ux", + } + if token: + headers["Authorization"] = f"Bearer {token}" + + req = urllib.request.Request(endpoint, headers=headers) + with urllib.request.urlopen(req, timeout=10) as resp: + payload = json.loads(resp.read().decode("utf-8")) + + artifacts = payload.get("artifacts") + if not isinstance(artifacts, list): + raise ValueError("artifacts_payload_invalid") + + exact = [a for a in artifacts if isinstance(a, dict) and a.get("name") == artifact_name] + if not exact: + raise ValueError("artifact_not_found") + + picked = sorted(exact, key=lambda a: int(a.get("id", 0)), reverse=True)[0] + artifact_id = picked.get("id") + if not artifact_id: + raise ValueError("artifact_id_missing") + + server_url = (os.environ.get("GITHUB_SERVER_URL") or f"{parsed.scheme}://{parsed.netloc}").rstrip("/") + return f"{server_url}/{owner}/{repo}/actions/runs/{run_id}/artifacts/{artifact_id}" + + +def _fallback( + errors: dict[str, str], + check_id: str, + artifact_name: str, + run_url: str, + diag_path: Path, + reason: str, + evidence_paths: list[str], +) -> int: + template = errors.get("E9901", "Error mapping/artifact-link failure for check '{check_id}' ({reason}).") + message = _render( + template, + { + "check_id": check_id, + "reason": reason, + "rule_id": "", + "artifact_name": artifact_name, + "run_url": run_url, + }, + ) + diag = { + "error_code": "9901", + "step_id": "99", + "class_id": "01", + "check_id": check_id, + "rule_id": "", + "artifact_name": artifact_name, + "artifact_url": run_url, + "message": message, + "reason": reason, + "timestamp_utc": dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "tool_versions": { + "python": platform.python_version(), + "dotnet": _dotnet_version(), + "runner_os": os.environ.get("RUNNER_OS", "unknown"), + }, + "evidence_paths": evidence_paths, + } + diag_path.parent.mkdir(parents=True, exist_ok=True) + diag_path.write_text(json.dumps(diag, indent=2) + "\n", encoding="utf-8") + + print(f"\033[31mERROR 9901: {message}\033[0m") + print(f"\033[34mARTIFACT {run_url} (artifact: {artifact_name})\033[0m") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--step-key", required=True) + parser.add_argument("--class-key", required=True) + parser.add_argument("--check-id", required=True) + parser.add_argument("--rule-id", default="") + parser.add_argument("--artifact-name", required=True) + parser.add_argument("--run-url", default="") + parser.add_argument("--diag-path", required=True) + parser.add_argument("--evidence-paths", default="") + args = parser.parse_args() + + script_dir = Path(__file__).resolve().parent + errors_dir = script_dir.parent / "errors" + steps_path = errors_dir / "steps.toml" + classes_path = errors_dir / "classes.toml" + errors_path = errors_dir / "errors.toml" + diag_path = Path(args.diag_path) + evidence_paths = [p for p in args.evidence_paths.split("|") if p] + + try: + steps = _load_toml(steps_path, "steps") + classes = _load_toml(classes_path, "classes") + errors = _load_toml(errors_path, "errors") + except Exception as exc: + return _fallback( + errors={"E9901": "Error mapping/artifact-link failure for check '{check_id}' ({reason})."}, + check_id=args.check_id, + artifact_name=args.artifact_name, + run_url=args.run_url or "https://github.com", + diag_path=diag_path, + reason=f"toml_load_failed:{exc}", + evidence_paths=evidence_paths, + ) + + run_url = args.run_url.strip() + if not run_url: + return _fallback( + errors, + args.check_id, + args.artifact_name, + "https://github.com", + diag_path, + "missing_run_url", + evidence_paths, + ) + try: + artifact_url = _resolve_artifact_url(run_url, args.artifact_name) + except (ValueError, KeyError, urllib.error.URLError, TimeoutError) as exc: + return _fallback( + errors, + args.check_id, + args.artifact_name, + run_url, + diag_path, + f"artifact_url_resolution_failed:{exc}", + evidence_paths, + ) + + step_id = steps.get(args.step_key, "") + class_id = classes.get(args.class_key, "") + if not (_valid_id(step_id) and _valid_id(class_id)): + return _fallback( + errors, + args.check_id, + args.artifact_name, + artifact_url, + diag_path, + f"invalid_mapping:{args.step_key}/{args.class_key}", + evidence_paths, + ) + + error_code = f"{step_id}{class_id}" + error_key = f"E{error_code}" + template = errors.get(error_key, "") + if not template: + return _fallback( + errors, + args.check_id, + args.artifact_name, + artifact_url, + diag_path, + f"missing_template:{error_key}", + evidence_paths, + ) + + values = { + "check_id": args.check_id, + "rule_id": args.rule_id, + "artifact_name": args.artifact_name, + "run_url": artifact_url, + "reason": "", + } + try: + message = _render(template, values) + except Exception as exc: + return _fallback( + errors, + args.check_id, + args.artifact_name, + artifact_url, + diag_path, + f"template_render_failed:{exc}", + evidence_paths, + ) + + diag = { + "error_code": error_code, + "step_id": step_id, + "class_id": class_id, + "check_id": args.check_id, + "rule_id": args.rule_id, + "artifact_name": args.artifact_name, + "artifact_url": artifact_url, + "message": message, + "timestamp_utc": dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "tool_versions": { + "python": platform.python_version(), + "dotnet": _dotnet_version(), + "runner_os": os.environ.get("RUNNER_OS", "unknown"), + }, + "evidence_paths": evidence_paths, + } + diag_path.parent.mkdir(parents=True, exist_ok=True) + diag_path.write_text(json.dumps(diag, indent=2) + "\n", encoding="utf-8") + + print(f"\033[31mERROR {error_code}: {message}\033[0m") + print(f"\033[34mARTIFACT {artifact_url} (artifact: {args.artifact_name})\033[0m") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/ci/lib/result.sh b/tools/ci/lib/result.sh index 8951b0b8..8e249937 100755 --- a/tools/ci/lib/result.sh +++ b/tools/ci/lib/result.sh @@ -7,21 +7,28 @@ source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/log.sh" ci_result_init() { local check_id="$1" local out_dir="$2" + local lib_dir export CI_CHECK_ID="$check_id" export CI_OUT_DIR="$out_dir" export CI_RAW_LOG="$out_dir/raw.log" export CI_SUMMARY_MD="$out_dir/summary.md" export CI_RESULT_JSON="$out_dir/result.json" + export CI_DIAG_JSON="$out_dir/diag.json" export CI_VIOLATIONS_NDJSON="$out_dir/.violations.ndjson" export CI_EVIDENCE_NDJSON="$out_dir/.evidence.ndjson" export CI_STATUS_FILE="$out_dir/.status" + export CI_ARTIFACT_NAME="${CI_ARTIFACT_NAME:-ci-${check_id}}" + export CI_RUN_URL="${GITHUB_SERVER_URL:-https://github.com}/${GITHUB_REPOSITORY:-}/${GITHUB_RUN_ID:+actions/runs/${GITHUB_RUN_ID}}" + lib_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + export CI_ERROR_RENDERER="${lib_dir}/error_ux.py" export CI_START_MS export CI_START_AT mkdir -p "$out_dir" : > "$CI_RAW_LOG" : > "$CI_SUMMARY_MD" + printf '{}\n' > "$CI_DIAG_JSON" : > "$CI_VIOLATIONS_NDJSON" : > "$CI_EVIDENCE_NDJSON" printf 'pass' > "$CI_STATUS_FILE" @@ -72,45 +79,187 @@ ci_result_finalize() { local finished_ms finished_at duration_ms status finished_ms="$(ci_now_ms)" finished_at="$(ci_now_utc)" - status="$(cat "$CI_STATUS_FILE")" duration_ms=$((finished_ms - CI_START_MS)) - local violations_json evidence_json artifacts_json - violations_json=$(jq -s . "$CI_VIOLATIONS_NDJSON") - evidence_json=$(jq -s 'unique' "$CI_EVIDENCE_NDJSON") - artifacts_json=$(jq -cn --arg raw "$CI_RAW_LOG" --arg summary "$CI_SUMMARY_MD" --arg result "$CI_RESULT_JSON" '[ $raw, $summary, $result ]') + ci_result_write_json() { + local write_status="$1" + local write_finished_at="$2" + local write_duration_ms="$3" + local violations_json evidence_json artifacts_json + violations_json=$(jq -s . "$CI_VIOLATIONS_NDJSON") + evidence_json=$(jq -s 'unique' "$CI_EVIDENCE_NDJSON") + artifacts_json=$(jq -cn --arg raw "$CI_RAW_LOG" --arg summary "$CI_SUMMARY_MD" --arg result "$CI_RESULT_JSON" --arg diag "$CI_DIAG_JSON" '[ $raw, $summary, $result, $diag ]') - jq -cn \ - --arg check_id "$CI_CHECK_ID" \ - --arg status "$status" \ - --arg started_at "$CI_START_AT" \ - --arg finished_at "$finished_at" \ - --argjson duration_ms "$duration_ms" \ - --argjson rule_violations "$violations_json" \ - --argjson evidence_paths "$evidence_json" \ - --argjson artifacts "$artifacts_json" \ - '{ - schema_version: 1, - check_id: $check_id, - status: $status, - rule_violations: $rule_violations, - evidence_paths: $evidence_paths, - artifacts: $artifacts, - timing: { - started_at: $started_at, - finished_at: $finished_at, - duration_ms: $duration_ms + jq -cn \ + --arg check_id "$CI_CHECK_ID" \ + --arg status "$write_status" \ + --arg started_at "$CI_START_AT" \ + --arg finished_at "$write_finished_at" \ + --argjson duration_ms "$write_duration_ms" \ + --argjson rule_violations "$violations_json" \ + --argjson evidence_paths "$evidence_json" \ + --argjson artifacts "$artifacts_json" \ + '{ + schema_version: 1, + check_id: $check_id, + status: $status, + rule_violations: $rule_violations, + evidence_paths: $evidence_paths, + artifacts: $artifacts, + timing: { + started_at: $started_at, + finished_at: $finished_at, + duration_ms: $duration_ms + } + }' > "$CI_RESULT_JSON" + } + + ci_result_validate_schema() { + local lib_dir repo_root schema_path validator_project validator_dll + lib_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + repo_root="$(cd -- "${lib_dir}/../../.." && pwd)" + schema_path="${repo_root}/tools/ci/schema/result.schema.json" + validator_project="${repo_root}/tools/ci/checks/ResultSchemaValidator/ResultSchemaValidator.csproj" + validator_dll="${repo_root}/tools/ci/checks/ResultSchemaValidator/bin/Release/net10.0/ResultSchemaValidator.dll" + + if [[ ! -f "$schema_path" ]]; then + ci_result_add_violation "CI-SCHEMA-001" "fail" "result schema missing" "$schema_path" + return 1 + fi + + if [[ ! -f "$validator_project" ]]; then + ci_result_add_violation "CI-SCHEMA-001" "fail" "ResultSchemaValidator project missing" "$validator_project" + return 1 + fi + + if [[ ! -f "$validator_dll" ]]; then + { + printf '$ dotnet restore --locked-mode %s\n' "$validator_project" + dotnet restore --locked-mode "$validator_project" + printf '$ dotnet build -c Release %s\n' "$validator_project" + dotnet build -c Release "$validator_project" + } >> "$CI_RAW_LOG" 2>&1 || { + ci_result_add_violation "CI-SCHEMA-001" "fail" "ResultSchemaValidator build failed" "$CI_RAW_LOG" + return 1 } - }' > "$CI_RESULT_JSON" + fi + + { + printf '$ dotnet %s --schema %s --result %s\n' "$validator_dll" "$schema_path" "$CI_RESULT_JSON" + dotnet "$validator_dll" --schema "$schema_path" --result "$CI_RESULT_JSON" + } >> "$CI_RAW_LOG" 2>&1 || { + ci_result_add_violation "CI-SCHEMA-001" "fail" "result.json schema validation failed" "$CI_RESULT_JSON" "$CI_RAW_LOG" + return 1 + } + } + + status="$(cat "$CI_STATUS_FILE")" + ci_result_write_json "$status" "$finished_at" "$duration_ms" + ci_result_validate_schema || true + status="$(cat "$CI_STATUS_FILE")" + ci_result_write_json "$status" "$finished_at" "$duration_ms" + + if [[ "$status" == "fail" ]]; then + ci_emit_error_ux + fi } ci_run_capture() { local description="$1" shift - ci_info "$description" { + printf '# %s\n' "$description" printf '$ %s\n' "$*" "$@" } >> "$CI_RAW_LOG" 2>&1 } + +ci_map_error_keys() { + local rule_id="$1" + case "$rule_id" in + CI-SETUP-*) echo "setup command_failed" ;; + CI-SHELL-*|CI-DOCS-*|CI-NAMING-*|CI-VERSION-*|CI-ARTIFACT-*|CI-POLICY-*) echo "policy policy_violation" ;; + CI-GRAPH-*) echo "policy command_failed" ;; + CI-BUILD-*) echo "build command_failed" ;; + CI-CONTRACT-*|CI-TEST-*|CI-PKGTEST-*|CI-SMOKE-*) echo "test command_failed" ;; + CI-PACK-*) echo "pack command_failed" ;; + CI-SECURITY-001) echo "security blocking_findings" ;; + CI-SECURITY-*) echo "security command_failed" ;; + CI-QODANA-001|CI-QODANA-002) echo "qodana missing_input" ;; + CI-QODANA-003|CI-QODANA-005) echo "qodana command_failed" ;; + CI-QODANA-004) echo "qodana blocking_findings" ;; + CI-SCHEMA-*) echo "schema schema_failure" ;; + CI-RUNNER-*) echo "runner missing_input" ;; + *) echo "generic command_failed" ;; + esac +} + +ci_emit_error_ux() { + local first_fail_json rule_id evidence_join map_out step_key class_key run_url + first_fail_json="$(jq -sc 'map(select(.severity=="fail"))[0] // {}' "$CI_VIOLATIONS_NDJSON")" + rule_id="$(jq -r '.rule_id // "CI-RUNNER-001"' <<<"$first_fail_json")" + evidence_join="$(jq -r '(.evidence_paths // []) | join("|")' <<<"$first_fail_json")" + map_out="$(ci_map_error_keys "$rule_id")" + step_key="${map_out%% *}" + class_key="${map_out##* }" + run_url="$CI_RUN_URL" + if [[ "$run_url" == *"//actions/runs/" || "$run_url" == "https://github.com/" ]]; then + run_url="https://github.com/${GITHUB_REPOSITORY:-}" + fi + + # Some workflows (e.g. version-policy) upload the per-check artifact after the check step runs. + # In that case, artifact URL resolution would be a guaranteed false-negative pre-upload. + # This flag defers artifact-link rendering to a post-upload verification step in the workflow. + if [[ "${CI_DEFER_ARTIFACT_LINK_RESOLUTION:-}" == "1" ]]; then + jq -cn \ + --arg check_id "$CI_CHECK_ID" \ + --arg artifact_name "$CI_ARTIFACT_NAME" \ + --arg run_url "$run_url" \ + --arg rule_id "$rule_id" \ + --arg ts "$(ci_now_utc)" \ + --arg msg "Artifact URL resolution deferred (CI_DEFER_ARTIFACT_LINK_RESOLUTION=1). Verify artifact existence after upload-artifact." \ + '{ + error_code:"deferred", + check_id:$check_id, + rule_id:$rule_id, + artifact_name:$artifact_name, + artifact_url:$run_url, + message:$msg, + timestamp_utc:$ts + }' > "$CI_DIAG_JSON" + printf 'INFO: %s\n' "$msg" + return 0 + fi + + if ! python3 "$CI_ERROR_RENDERER" \ + --step-key "$step_key" \ + --class-key "$class_key" \ + --check-id "$CI_CHECK_ID" \ + --rule-id "$rule_id" \ + --artifact-name "$CI_ARTIFACT_NAME" \ + --run-url "$run_url" \ + --diag-path "$CI_DIAG_JSON" \ + --evidence-paths "$evidence_join"; then + jq -cn \ + --arg check_id "$CI_CHECK_ID" \ + --arg artifact_name "$CI_ARTIFACT_NAME" \ + --arg run_url "$run_url" \ + --arg rule_id "$rule_id" \ + --arg ts "$(ci_now_utc)" \ + --arg msg "Error mapping/artifact-link failure for check '${CI_CHECK_ID}' (renderer_failed)." \ + '{ + error_code:"9901", + step_id:"99", + class_id:"01", + check_id:$check_id, + rule_id:$rule_id, + artifact_name:$artifact_name, + artifact_url:$run_url, + message:$msg, + timestamp_utc:$ts + }' > "$CI_DIAG_JSON" + printf '\033[31mERROR 9901: Error mapping/artifact-link failure for check %s (renderer_failed).\033[0m\n' "$CI_CHECK_ID" + printf '\033[34mARTIFACT %s (artifact: %s)\033[0m\n' "$run_url" "$CI_ARTIFACT_NAME" + fi +} diff --git a/tools/ci/policies/rules/artifact_contract.yaml b/tools/ci/policies/rules/artifact_contract.yaml index e6e8f13f..3dd72027 100644 --- a/tools/ci/policies/rules/artifact_contract.yaml +++ b/tools/ci/policies/rules/artifact_contract.yaml @@ -13,12 +13,16 @@ rules: artifact_root: artifacts/ci check_ids: - preflight - - docs-links-full - - versioning-svt - - naming-snt - build + - api-contract + - pack + - consumer-smoke + - package-backed-tests - security-nuget - tests-bdd-coverage + - docs-links-full + - versioning-svt + - naming-snt required_artifacts: - raw.log - summary.md