From 3e735f904cfef952cd0e4eca57b385eb46133c75 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 04:12:25 +0700 Subject: [PATCH 1/5] ci(p0): add deterministic workflow cost and integrity inventory --- scripts/audit-ci-workflows.py | 302 ++++++++++++++++++++++++++++++++++ 1 file changed, 302 insertions(+) create mode 100644 scripts/audit-ci-workflows.py diff --git a/scripts/audit-ci-workflows.py b/scripts/audit-ci-workflows.py new file mode 100644 index 000000000..5ca972771 --- /dev/null +++ b/scripts/audit-ci-workflows.py @@ -0,0 +1,302 @@ +#!/usr/bin/env python3 +"""Deterministic, read-only inventory and integrity guard for GitHub Actions workflows. + +CI-P0 deliberately avoids a YAML dependency. It inspects the stable structural subset +used by this repository: tracked workflow files, top-level workflow keys, job IDs, +step names and expensive CI primitives. GitHub remains the YAML parser of record. + +The guard does not decide whether an existing workflow is semantically necessary. +It prevents accidental workflow duplication/corruption and makes CI-cost growth +explicit through a reviewed budget file. +""" +from __future__ import annotations + +import argparse +import collections +import json +import re +import subprocess +from pathlib import Path +from typing import Iterable + +WORKFLOW_PREFIX = ".github/workflows/" +WORKFLOW_SUFFIXES = (".yml", ".yaml") + +PRIMITIVES = { + "actionsCheckout": "actions/checkout@", + "actionsSetupDotnet": "actions/setup-dotnet@", + "actionsSetupPython": "actions/setup-python@", + "actionsUploadArtifact": "actions/upload-artifact@", + "actionsDownloadArtifact": "actions/download-artifact@", + "dotnetRestore": "dotnet restore", + "dotnetBuild": "dotnet build", + "dotnetTest": "dotnet test", + "dotnetPublish": "dotnet publish", + "workflowCall": "workflow_call:", + "concurrencyBlocks": "concurrency:", +} + + +def git(root: Path, *args: str) -> bytes: + return subprocess.check_output( + ("git", "-C", str(root), *args), + stderr=subprocess.PIPE, + ) + + +def tracked_paths(root: Path) -> list[str]: + return sorted( + { + item.decode("utf-8", "surrogateescape").replace("\\", "/") + for item in git(root, "ls-files", "-z").split(b"\0") + if item + } + ) + + +def workflow_paths(root: Path) -> list[str]: + return [ + path + for path in tracked_paths(root) + if path.startswith(WORKFLOW_PREFIX) + and Path(path).suffix.lower() in WORKFLOW_SUFFIXES + ] + + +def events(content: str) -> list[str]: + found: set[str] = set() + in_on = False + for line in content.splitlines(): + if re.match(r"^on:\s*(?:#.*)?$", line): + in_on = True + continue + inline = re.match(r"^on:\s*\[([^]]+)\]", line) + if inline: + found.update( + item.strip().strip("'\"") + for item in inline.group(1).split(",") + if item.strip() + ) + break + if in_on and line and not line[0].isspace() and not line.lstrip().startswith("#"): + break + if in_on: + match = re.match(r"^ ([\w-]+):(?:\s|$)", line) + if match: + found.add(match.group(1)) + return sorted(found) + + +def primitive_counts(content: str) -> dict[str, int]: + return {name: content.count(token) for name, token in PRIMITIVES.items()} + + +def structure_findings(path: str, content: str) -> tuple[dict, list[str]]: + lines = content.splitlines() + errors: list[str] = [] + + top_name_count = sum(1 for line in lines if re.match(r"^name:\s*\S", line)) + jobs_count = sum(1 for line in lines if re.match(r"^jobs:\s*(?:#.*)?$", line)) + if top_name_count != 1: + errors.append( + f"{path}: expected exactly one top-level workflow name, found {top_name_count}." + ) + if jobs_count != 1: + errors.append(f"{path}: expected exactly one top-level jobs block, found {jobs_count}.") + + in_jobs = False + current_job: str | None = None + job_ids: list[str] = [] + step_names: dict[str, list[str]] = collections.defaultdict(list) + + for line in lines: + if re.match(r"^jobs:\s*(?:#.*)?$", line): + in_jobs = True + current_job = None + continue + + if in_jobs and line and not line[0].isspace() and not line.lstrip().startswith("#"): + in_jobs = False + current_job = None + + if not in_jobs: + continue + + job_match = re.match(r"^ ([A-Za-z0-9_-]+):\s*(?:#.*)?$", line) + if job_match: + current_job = job_match.group(1) + job_ids.append(current_job) + continue + + if current_job is None: + continue + + step_match = re.match(r"^\s{6}-\s+name:\s*(.+?)\s*$", line) + if step_match: + raw_name = step_match.group(1).strip().strip("'\"") + step_names[current_job].append(raw_name) + + duplicate_jobs = sorted( + job for job, count in collections.Counter(job_ids).items() if count > 1 + ) + for job in duplicate_jobs: + errors.append(f"{path}: duplicate job id '{job}'.") + + duplicate_steps: dict[str, list[str]] = {} + for job, names in step_names.items(): + duplicates = sorted( + name for name, count in collections.Counter(names).items() if count > 1 + ) + if duplicates: + duplicate_steps[job] = duplicates + for name in duplicates: + errors.append( + f"{path}: job '{job}' contains duplicate step name '{name}'." + ) + + return { + "topLevelNameCount": top_name_count, + "topLevelJobsCount": jobs_count, + "jobIds": job_ids, + "duplicateJobIds": duplicate_jobs, + "duplicateStepNamesByJob": duplicate_steps, + }, errors + + +def inventory(root: Path) -> dict: + workflows: list[dict] = [] + structure_errors: list[str] = [] + totals = collections.Counter() + + for path in workflow_paths(root): + full = root / path + if not full.is_file(): + raise FileNotFoundError(f"Tracked workflow is missing: {path}") + content = full.read_text(encoding="utf-8-sig", errors="replace") + + metrics = primitive_counts(content) + structure, errors = structure_findings(path, content) + structure_errors.extend(errors) + totals.update(metrics) + + workflows.append( + { + "path": path, + "events": events(content), + "metrics": metrics, + "structure": structure, + } + ) + + totals["workflowFiles"] = len(workflows) + totals["workflowsWithDotnetBuild"] = sum( + 1 for workflow in workflows if workflow["metrics"]["dotnetBuild"] > 0 + ) + totals["workflowsWithDotnetTest"] = sum( + 1 for workflow in workflows if workflow["metrics"]["dotnetTest"] > 0 + ) + totals["pullRequestWorkflows"] = sum( + 1 for workflow in workflows if "pull_request" in workflow["events"] + ) + + return { + "schemaVersion": 1, + "sourceCommit": git(root, "rev-parse", "HEAD").decode("ascii").strip(), + "scope": ( + "Tracked GitHub Actions workflows only. Counts are textual CI-cost indicators; " + "they do not prove runtime duration or semantic necessity." + ), + "totals": dict(sorted(totals.items())), + "structureErrors": sorted(structure_errors), + "workflows": workflows, + } + + +def load_budget(path: Path) -> dict: + data = json.loads(path.read_text(encoding="utf-8")) + if data.get("schemaVersion") != 1: + raise ValueError("Unsupported CI workflow budget schema.") + if not isinstance(data.get("limits"), dict): + raise ValueError("CI workflow budget is missing 'limits'.") + return data + + +def budget_violations(report: dict, budget: dict) -> list[str]: + violations: list[str] = [] + totals = report["totals"] + for metric, limit in sorted(budget["limits"].items()): + if metric not in totals: + violations.append(f"Budget references unknown metric '{metric}'.") + continue + actual = int(totals[metric]) + maximum = int(limit) + if actual > maximum: + violations.append( + f"CI budget exceeded: {metric}={actual}, allowed maximum={maximum}." + ) + return violations + + +def evaluate(root: Path, budget_path: Path | None = None) -> dict: + report = inventory(root) + budget_errors: list[str] = [] + if budget_path is not None: + budget = load_budget(budget_path) + budget_errors = budget_violations(report, budget) + report["budget"] = { + "path": str(budget_path).replace("\\", "/"), + "baselineSourceCommit": budget.get("baselineSourceCommit", ""), + "violations": budget_errors, + } + else: + report["budget"] = {"violations": []} + + report["isHealthy"] = not report["structureErrors"] and not budget_errors + return report + + +def print_summary(report: dict) -> None: + totals = report["totals"] + print( + "CI workflow inventory: " + f"workflows={totals.get('workflowFiles', 0)}, " + f"dotnetBuild={totals.get('dotnetBuild', 0)}, " + f"dotnetTest={totals.get('dotnetTest', 0)}, " + f"dotnetRestore={totals.get('dotnetRestore', 0)}, " + f"setupDotnet={totals.get('actionsSetupDotnet', 0)}, " + f"checkout={totals.get('actionsCheckout', 0)}" + ) + for error in report["structureErrors"]: + print("STRUCTURE ERROR: " + error) + for error in report["budget"].get("violations", []): + print("BUDGET ERROR: " + error) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--root", + type=Path, + default=Path(__file__).resolve().parent.parent, + ) + parser.add_argument("--budget", type=Path) + parser.add_argument("--output", type=Path) + args = parser.parse_args() + + root = args.root.resolve() + budget = args.budget.resolve() if args.budget else None + report = evaluate(root, budget) + payload = json.dumps(report, indent=2, ensure_ascii=False) + "\n" + + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(payload, encoding="utf-8") + + print_summary(report) + if not report["isHealthy"]: + return 2 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From cba05a0d77dfa600914c3138c7fc1ff89bfa403e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 04:12:57 +0700 Subject: [PATCH 2/5] test(ci-p0): cover workflow corruption and budget regressions --- scripts/test-ci-workflows.py | 201 +++++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 scripts/test-ci-workflows.py diff --git a/scripts/test-ci-workflows.py b/scripts/test-ci-workflows.py new file mode 100644 index 000000000..47fa8554d --- /dev/null +++ b/scripts/test-ci-workflows.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +"""Offline tests for the CI-P0 workflow inventory and integrity guard.""" +from __future__ import annotations + +import importlib.util +import json +import subprocess +import tempfile +import unittest +from pathlib import Path + +SOURCE = Path(__file__).with_name("audit-ci-workflows.py") +spec = importlib.util.spec_from_file_location("arsas_ci_inventory", SOURCE) +assert spec and spec.loader +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + + +class CiWorkflowInventoryTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + subprocess.run(["git", "-C", str(self.root), "init", "-q"], check=True) + + def tearDown(self): + self.tmp.cleanup() + + def write(self, path: str, body: str) -> None: + dest = self.root / path + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(body, encoding="utf-8") + + def commit(self) -> None: + subprocess.run(["git", "-C", str(self.root), "add", "-A"], check=True) + subprocess.run( + [ + "git", + "-C", + str(self.root), + "-c", + "user.email=test@example.invalid", + "-c", + "user.name=CI P0 Test", + "commit", + "-qm", + "synthetic fixture", + ], + check=True, + ) + + def test_inventory_counts_expensive_primitives_deterministically(self): + self.write( + ".github/workflows/check.yml", + """name: Check +on: + pull_request: + workflow_dispatch: +jobs: + build: + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + - name: Setup + uses: actions/setup-dotnet@v6 + - name: Build + run: | + dotnet restore App.sln + dotnet build App.sln --no-restore + dotnet test Tests.csproj --no-build +""", + ) + self.commit() + + first = module.inventory(self.root) + second = module.inventory(self.root) + self.assertEqual( + json.dumps(first, sort_keys=True), + json.dumps(second, sort_keys=True), + ) + self.assertEqual(first["totals"]["workflowFiles"], 1) + self.assertEqual(first["totals"]["actionsCheckout"], 1) + self.assertEqual(first["totals"]["actionsSetupDotnet"], 1) + self.assertEqual(first["totals"]["dotnetRestore"], 1) + self.assertEqual(first["totals"]["dotnetBuild"], 1) + self.assertEqual(first["totals"]["dotnetTest"], 1) + self.assertEqual(first["totals"]["workflowsWithDotnetBuild"], 1) + self.assertEqual(first["totals"]["workflowsWithDotnetTest"], 1) + self.assertEqual(first["totals"]["pullRequestWorkflows"], 1) + self.assertEqual(first["structureErrors"], []) + + def test_duplicate_step_name_inside_same_job_is_rejected(self): + self.write( + ".github/workflows/corrupt.yml", + """name: Corrupt +on: + pull_request: +jobs: + build: + runs-on: windows-latest + steps: + - name: Setup .NET + run: echo first + - name: Setup .NET + run: echo duplicate +""", + ) + self.commit() + + report = module.inventory(self.root) + self.assertFalse(not report["structureErrors"]) + self.assertTrue( + any("duplicate step name 'Setup .NET'" in item for item in report["structureErrors"]) + ) + + def test_same_step_name_in_different_jobs_is_allowed(self): + self.write( + ".github/workflows/parallel.yml", + """name: Parallel +on: [push, pull_request] +jobs: + first: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + second: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 +""", + ) + self.commit() + + report = module.inventory(self.root) + self.assertEqual(report["structureErrors"], []) + self.assertEqual(report["totals"]["actionsCheckout"], 2) + + def test_duplicate_job_id_is_rejected(self): + self.write( + ".github/workflows/duplicate-job.yml", + """name: Duplicate Job +on: + push: +jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: First + run: echo first + validate: + runs-on: windows-latest + steps: + - name: Second + run: echo second +""", + ) + self.commit() + + report = module.inventory(self.root) + self.assertTrue( + any("duplicate job id 'validate'" in item for item in report["structureErrors"]) + ) + + def test_budget_rejects_new_duplicate_build_cost(self): + self.write( + ".github/workflows/build.yml", + """name: Build +on: + pull_request: +jobs: + build: + runs-on: windows-latest + steps: + - name: Build + run: dotnet build App.sln +""", + ) + self.write( + "budget.json", + json.dumps( + { + "schemaVersion": 1, + "baselineSourceCommit": "synthetic", + "limits": {"dotnetBuild": 0}, + } + ), + ) + self.commit() + + report = module.evaluate(self.root, self.root / "budget.json") + self.assertFalse(report["isHealthy"]) + self.assertIn( + "CI budget exceeded: dotnetBuild=1, allowed maximum=0.", + report["budget"]["violations"], + ) + + +if __name__ == "__main__": + unittest.main() From dd9b035fd3a8d6d90663df508ce610f9737b57a8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 04:13:21 +0700 Subject: [PATCH 3/5] evidence(ci-p0): freeze pre-consolidation workflow cost budget --- evidence/ci-workflow-budget.json | 36 ++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 evidence/ci-workflow-budget.json diff --git a/evidence/ci-workflow-budget.json b/evidence/ci-workflow-budget.json new file mode 100644 index 000000000..cae9d3171 --- /dev/null +++ b/evidence/ci-workflow-budget.json @@ -0,0 +1,36 @@ +{ + "schemaVersion": 1, + "baselineSourceCommit": "71e8d7e864b22c2f2146e5f8576cef0126c5f6e7", + "baselineName": "CI-P0 workflow-sprawl freeze", + "policy": "These are maximum textual CI-cost indicators after adding the lightweight CI-P0 integrity workflow. Raising a limit requires an explicit reviewed budget change. Lowering a limit is always allowed when workflows are consolidated.", + "preP0Observed": { + "workflowFiles": 28, + "actionsCheckout": 31, + "actionsSetupDotnet": 12, + "actionsSetupPython": 13, + "dotnetRestore": 15, + "dotnetBuild": 15, + "dotnetTest": 17, + "actionsUploadArtifact": 33, + "workflowsWithDotnetBuild": 12, + "workflowsWithDotnetTest": 11, + "workflowCall": 0 + }, + "limits": { + "workflowFiles": 29, + "actionsCheckout": 32, + "actionsSetupDotnet": 12, + "actionsSetupPython": 13, + "dotnetRestore": 15, + "dotnetBuild": 15, + "dotnetTest": 17, + "actionsUploadArtifact": 33, + "workflowsWithDotnetBuild": 12, + "workflowsWithDotnetTest": 11 + }, + "interpretation": [ + "The limits freeze CI sprawl; they are not performance targets and do not prove semantic necessity.", + "CI-P1 is expected to reduce restore/build/test counts and introduce reusable workflow_call contracts. Any temporary migration increase must be explicit in the same reviewed change.", + "Release, physical evidence and IEC 61850 acceptance semantics remain owned by their existing contracts during CI-P0." + ] +} From 6d99f75b822febfb05aad919b359f592ae2e87be Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 04:13:44 +0700 Subject: [PATCH 4/5] docs(ci-p0): record workflow ownership and stabilization baseline --- docs/audits/CI_P0_WORKFLOW_BASELINE.md | 149 +++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 docs/audits/CI_P0_WORKFLOW_BASELINE.md diff --git a/docs/audits/CI_P0_WORKFLOW_BASELINE.md b/docs/audits/CI_P0_WORKFLOW_BASELINE.md new file mode 100644 index 000000000..661e66d05 --- /dev/null +++ b/docs/audits/CI_P0_WORKFLOW_BASELINE.md @@ -0,0 +1,149 @@ +# CI-P0 — Workflow Stabilization Baseline + +Issue: #426 + +CI-P0 freezes the current GitHub Actions estate before any consolidation. It is deliberately non-semantic: no IEC 61850 runtime path, physical acceptance rule, release authority, or domain test contract is removed or weakened in this phase. + +## Why P0 exists + +The repository has accumulated many independently triggered workflows that repeat checkout, .NET setup, restore, build and test work. The failure mode is not only slower CI. Duplicated policy implementations drift, and an unrelated authority update can make several pipelines disagree about the same source of truth. + +The P0 goal is therefore: + +> make current CI topology observable, deterministic and corruption-resistant before optimizing it. + +## Main baseline + +Source: `71e8d7e864b22c2f2146e5f8576cef0126c5f6e7`. + +Observed before adding the CI-P0 integrity workflow: + +| Indicator | Count | +| --- | ---: | +| Tracked workflow files | 28 | +| `actions/checkout` uses | 31 | +| `actions/setup-dotnet` uses | 12 | +| `actions/setup-python` uses | 13 | +| `dotnet restore` invocations | 15 | +| `dotnet build` invocations | 15 | +| `dotnet test` invocations | 17 | +| `actions/upload-artifact` uses | 33 | +| Workflows containing a .NET build | 12 | +| Workflows containing a .NET test | 11 | +| Reusable `workflow_call` entry points | 0 | + +These are textual cost indicators, not runtime-duration measurements. They intentionally over-simplify complex workflows so growth and consolidation can be reviewed consistently. + +## CI-P0 guard + +The tracked budget lives in: + +`evidence/ci-workflow-budget.json` + +The deterministic inventory lives in: + +`scripts/audit-ci-workflows.py` + +Offline regression tests live in: + +`scripts/test-ci-workflows.py` + +The guard performs two independent checks: + +1. **Structural integrity** + - exactly one top-level workflow `name`; + - exactly one top-level `jobs` block; + - no duplicate job IDs; + - no duplicate step names inside the same job. + + Duplicate step names across different jobs remain legal. This catches the class of accidental copy/paste corruption that duplicated large sections of the R7 workflow without pretending to replace GitHub's YAML parser. + +2. **CI-sprawl budget** + - new workflows/builds/tests/checkouts cannot silently increase the frozen maxima; + - a deliberate increase must update the budget in the same reviewed change; + - reductions never require a budget increase. + +The P0 workflow itself is intentionally cheap: one checkout plus standard-library Python. It performs no .NET restore/build/test and uploads no artifact. + +## Ownership map + +### Core build / package + +- `.github/workflows/build.yml` — canonical PR/main Windows build, regression suite, portable EXE. +- `.github/workflows/installer-windows.yml` — installer-specific validation. +- `.github/workflows/release-windows.yml` — release-only Windows packaging and publication path. + +P0 does not merge these responsibilities yet. + +### IEC 61850 / SCL / physical authority + +- `interoperability-reference-guard.yml` +- `scl-interoperability-r7.yml` +- `smart-discovery-capture-build.yml` +- `smart-discovery-golden-budget-lock.yml` +- `smart-discovery-golden-provenance.yml` +- `smart-discovery-mainline-readiness.yml` +- `smart-discovery-merge-execution-guard.yml` +- `smart-discovery-post-merge-production.yml` +- `smart-discovery-production-promotion.yml` +- `smart-discovery-repeat-run-stability.yml` +- `rcb-export-guard.yml` + +These workflows currently encode multiple layers of historical, physical and promotion authority. P0 records them unchanged. P1/P2 may consolidate execution only after equivalent outcomes are proven. + +### Focused technical gates + +- `validate-io-testing.yml` +- `validate-sv-evidence.yml` +- `comtrade-viewer-integration.yml` +- `progressive-static-bench.yml` + +### Product site / adoption / measurement + +- `pages.yml` +- `adoption-proof.yml` +- `measurement-contract.yml` +- `search-growth.yml` +- `site-measurement.yml` +- `production-health.yml` + +### Release metadata / supply chain + +- `publish-verified-release.yml` +- `release-supply-chain.yml` +- `sync-release-documentation.yml` +- `sync-release-evidence.yml` + +## P0 invariants + +P0 must not: + +- retire an existing workflow; +- weaken a required physical or release gate; +- change engine/runtime source; +- reinterpret historical acceptance evidence; +- make one current engine pin silently replace a historical tested baseline; +- change product behavior merely to satisfy CI. + +P0 may: + +- add read-only inventory; +- add corruption detection; +- freeze duplicate-CI growth; +- document ownership and dependencies; +- restore a workflow to its known-good baseline when accidental text corruption is proven. + +## Handoff to CI-P1 + +CI-P1 should introduce the first reusable primitives and a single fast PR gate, but only after P0 is merged and stable. + +Recommended P1 order: + +1. reusable immutable engine resolver; +2. reusable .NET restore/build/test primitive; +3. one PR change classifier; +4. artifact reuse instead of repeated compilation; +5. repository-wide PR concurrency/cancellation; +6. shadow comparison before retiring any legacy workflow. + +The P0 budget becomes the before-state. P1 is successful when the expensive counts decrease while semantic guards remain equivalent. From 6e0aadcc81dada62141c26b53e3cb7f50740358f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 04:14:08 +0700 Subject: [PATCH 5/5] ci(p0): guard workflow structure and CI-sprawl budget --- .../workflows/ci-p0-workflow-integrity.yml | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 .github/workflows/ci-p0-workflow-integrity.yml diff --git a/.github/workflows/ci-p0-workflow-integrity.yml b/.github/workflows/ci-p0-workflow-integrity.yml new file mode 100644 index 000000000..a54c8f313 --- /dev/null +++ b/.github/workflows/ci-p0-workflow-integrity.yml @@ -0,0 +1,74 @@ +name: CI P0 Workflow Integrity + +on: + push: + branches: [ main ] + paths: + - ".github/workflows/**" + - "scripts/audit-ci-workflows.py" + - "scripts/test-ci-workflows.py" + - "evidence/ci-workflow-budget.json" + - "docs/audits/CI_P0_WORKFLOW_BASELINE.md" + pull_request: + paths: + - ".github/workflows/**" + - "scripts/audit-ci-workflows.py" + - "scripts/test-ci-workflows.py" + - "evidence/ci-workflow-budget.json" + - "docs/audits/CI_P0_WORKFLOW_BASELINE.md" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-p0-workflow-integrity-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + workflow-integrity: + name: Validate workflow structure and CI-sprawl budget + runs-on: ubuntu-latest + steps: + - name: Checkout exact candidate + uses: actions/checkout@v7 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Verify standard-library Python is available + run: python3 --version + + - name: Run offline CI workflow guard tests + run: python3 scripts/test-ci-workflows.py + + - name: Validate tracked workflow topology and budget + run: | + python3 scripts/audit-ci-workflows.py \ + --root . \ + --budget evidence/ci-workflow-budget.json \ + --output "${RUNNER_TEMP}/ci-workflow-inventory.json" + + - name: Summarize CI topology + shell: bash + run: | + python3 - <<'PY' + import json + import os + from pathlib import Path + + report = json.loads( + (Path(os.environ["RUNNER_TEMP"]) / "ci-workflow-inventory.json").read_text() + ) + totals = report["totals"] + summary = Path(os.environ["GITHUB_STEP_SUMMARY"]) + with summary.open("a", encoding="utf-8") as handle: + handle.write("## CI-P0 workflow inventory\n\n") + handle.write(f"- workflow files: {totals['workflowFiles']}\n") + handle.write(f"- .NET restore/build/test: {totals['dotnetRestore']}/{totals['dotnetBuild']}/{totals['dotnetTest']}\n") + handle.write(f"- setup-dotnet uses: {totals['actionsSetupDotnet']}\n") + handle.write(f"- checkout uses: {totals['actionsCheckout']}\n") + handle.write(f"- workflows containing .NET build: {totals['workflowsWithDotnetBuild']}\n") + handle.write(f"- workflows containing .NET test: {totals['workflowsWithDotnetTest']}\n") + handle.write("\nNo structural or budget violation was detected.\n") + PY