From 46633d99d177b1f153db557cd7eb25d3ce972e12 Mon Sep 17 00:00:00 2001 From: Srinivasa Consultants <78673153+dinakarjs@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:00:03 +0530 Subject: [PATCH 1/2] fix(release): require CI validation before artifact build Signed-off-by: Srinivasa Dinakar --- .github/workflows/ci.yml | 1 + .github/workflows/release.yml | 7 +++++ CONTRIBUTING.md | 8 ++++++ tests/unit/test_release_artifacts.py | 42 ++++++++++++++++++++++++++++ 4 files changed, 58 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3548982..d710ed1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,7 @@ name: CI on: + workflow_call: push: branches: [main] pull_request: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 56a8610..183271c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,7 +8,14 @@ permissions: contents: read jobs: + validate: + uses: ./.github/workflows/ci.yml + permissions: + contents: read + id-token: write # The reused CI matrix authenticates coverage uploads. + build: + needs: validate runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c866472..3135034 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,6 +53,14 @@ pytest tests/unit/ -v # unit tests All four must pass before a PR is mergeable. +### Release validation + +Release builds depend on the reusable CI workflow, including its Python 3.11, +3.12 and 3.13 matrix on Linux and Windows. Failed, cancelled or skipped validation +blocks the build and therefore publication. The existing wheel and source +distribution installation checks still run before their artifacts are uploaded. +`governance-release` remains a post-publication attestation attached to the release. + ## Commit format Follow [Conventional Commits](https://www.conventionalcommits.org/): diff --git a/tests/unit/test_release_artifacts.py b/tests/unit/test_release_artifacts.py index 4338f98..b010614 100644 --- a/tests/unit/test_release_artifacts.py +++ b/tests/unit/test_release_artifacts.py @@ -6,6 +6,7 @@ from importlib.metadata import version from pathlib import Path +import pytest import yaml import ca2a_runtime @@ -50,3 +51,44 @@ def test_publish_waits_for_both_artifact_install_smoke_tests() -> None: assert "Install and smoke-test wheel" in names assert "Install and smoke-test source distribution" in names assert workflow["jobs"]["publish"]["needs"] == "build" + + +def test_release_reuses_complete_ci_validation() -> None: + jobs = _release_workflow()["jobs"] + assert jobs["validate"]["uses"] == "./.github/workflows/ci.yml" + ci = yaml.safe_load(Path(".github/workflows/ci.yml").read_text(encoding="utf-8")) + assert "workflow_call" in ci[True] + matrix = ci["jobs"]["test"]["strategy"]["matrix"] + assert matrix["python-version"] == ["3.11", "3.12", "3.13"] + assert matrix["os"] == ["ubuntu-latest", "windows-latest"] + assert any( + "pytest tests/unit/ tests/conformance/" in step.get("run", "") + for step in ci["jobs"]["test"]["steps"] + ) + assert jobs["validate"]["permissions"]["id-token"] == "write" + assert "environment" not in jobs["validate"] + + +@pytest.mark.parametrize("validation_result", ["success", "failure", "cancelled", "skipped"]) +def test_release_dependency_graph_blocks_unsuccessful_validation(validation_result: str) -> None: + jobs = _release_workflow()["jobs"] + results = {"validate": validation_result} + for name in ("build", "publish", "governance-release"): + job = jobs[name] + # Jobs without an explicit status function retain GitHub's implicit + # success() guard. A future custom guard needs its own execution model. + assert "if" not in job + needs = job.get("needs", []) + needs = [needs] if isinstance(needs, str) else needs + assert all(dependency in results for dependency in needs) + results[name] = ( + "success" + if all(results[dependency] == "success" for dependency in needs) + else "skipped" + ) + expected = "success" if validation_result == "success" else "skipped" + assert results["build"] == expected + assert results["publish"] == expected + assert results["governance-release"] == expected + assert jobs["build"]["needs"] == "validate" + assert jobs["governance-release"]["needs"] == "publish" From 617a3b02d269c3765dbd7242a013bb3395229b8c Mon Sep 17 00:00:00 2001 From: Srinivasa Consultants <78673153+dinakarjs@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:23:39 +0530 Subject: [PATCH 2/2] fix(release): validate without OIDC or coverage upload Signed-off-by: Srinivasa Dinakar --- .github/workflows/ci.yml | 1 + .github/workflows/release-validation.yml | 138 +++++++++++++++++++++++ .github/workflows/release.yml | 3 +- CONTRIBUTING.md | 7 +- tests/unit/test_release_artifacts.py | 39 ++++++- 5 files changed, 182 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/release-validation.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d710ed1..3ec3f23 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,7 @@ jobs: run: pytest tests/unit/ tests/conformance/ -v --tb=short --cov=src --cov-report=xml - name: Upload coverage report + if: github.event_name != 'release' uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 with: use_oidc: true diff --git a/.github/workflows/release-validation.yml b/.github/workflows/release-validation.yml new file mode 100644 index 0000000..54fe293 --- /dev/null +++ b/.github/workflows/release-validation.yml @@ -0,0 +1,138 @@ +name: Release validation + +# Credential-free CI mirror. test_release_validation_matches_ci prevents drift. +on: + workflow_call: + +permissions: + contents: read +jobs: + test: + permissions: + contents: read + runs-on: ${{ matrix.os }} + strategy: + matrix: + python-version: ["3.11", "3.12", "3.13"] + os: [ubuntu-latest, windows-latest] + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + # Third-party dependencies come from the hash-pinned lock first; the + # local package then goes in with --no-deps, because pip cannot + # hash-pin an editable install in the same invocation. + run: python -m pip install --require-hashes -r requirements/dev.txt + + - name: Install local package + run: python -m pip install --no-deps -e . + + - name: Security scan + # bandit and pip-audit are already in the dev extra above, so the + # scan runs against the same pinned versions rather than fetching + # whatever is current at scan time. + run: bandit -r src/ -c pyproject.toml && pip-audit + + - name: Lint + run: ruff check src/ tests/ + + # Checked, not applied. Without this gate 34 of 77 files drifted out of + # format unnoticed, because `ruff check` does not cover formatting. + - name: Format + run: ruff format --check src/ tests/ + + - name: Type check + run: mypy src/ca2a_runtime/ src/ca2a_verify/ + + - name: Test + run: pytest tests/unit/ tests/conformance/ -v --tb=short --cov=src --cov-report=xml + + + governance: + runs-on: ubuntu-latest + needs: test + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + - name: Install dependencies + # Third-party dependencies come from the hash-pinned lock first; the + # local package then goes in with --no-deps, because pip cannot + # hash-pin an editable install in the same invocation. + run: python -m pip install --require-hashes -r requirements/dev.txt + + - name: Install local package + run: python -m pip install --no-deps -e . + + # AGT 4.1 currently constrains cryptography below 49, while the runtime + # requires 50+ for published security fixes. Keep the scanner isolated + # so its tooling constraints cannot downgrade the package under test, + # then override that stale upper bound inside the ephemeral scanner too. + - name: Install governance tooling in an isolated environment + run: | + python -m venv .agt-venv + # --no-deps because the lock already lists every transitive + # dependency, and pip would otherwise re-resolve and reject the + # cryptography override the compile deliberately applied. + .agt-venv/bin/pip install --require-hashes --no-deps -r requirements/agt.txt + + - name: Generate evidence file + run: python scripts/gen_agt_evidence.py + + # Blocking gate: the [full] toolkit provides the ASI coverage modules + # (10/10) and the evidence declares cA2A's governed capabilities, so strict + # verify is COMPLETE. A regression that drops coverage or governance fails CI. + - name: AGT governance verify (strict) + run: .agt-venv/bin/agt verify --evidence agt-evidence.json + + - name: Save attestation JSON + run: .agt-venv/bin/agt --json verify --evidence agt-evidence.json > agt-attestation.json + + - name: Upload governance artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 + with: + name: agt-governance-${{ github.sha }} + path: | + agt-evidence.json + agt-attestation.json + if-no-files-found: warn + + benchmark: + runs-on: ubuntu-latest + needs: test + if: github.ref == 'refs/heads/main' + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + - name: Install dependencies + # Third-party dependencies come from the hash-pinned lock first; the + # local package then goes in with --no-deps, because pip cannot + # hash-pin an editable install in the same invocation. + run: python -m pip install --require-hashes -r requirements/dev.txt + + - name: Install local package + run: python -m pip install --no-deps -e . + + - name: Run benchmark (software-only, CI gate p99 < 5ms) + run: python -m ca2a_runtime.benchmarks --provider software-only --hops 10000 --out benchmarks/ + + - name: Upload benchmark results + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 + with: + name: benchmark-results + path: benchmarks/ + if-no-files-found: warn diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 183271c..90c12e3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,10 +9,9 @@ permissions: jobs: validate: - uses: ./.github/workflows/ci.yml + uses: ./.github/workflows/release-validation.yml permissions: contents: read - id-token: write # The reused CI matrix authenticates coverage uploads. build: needs: validate diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3135034..0e468ec 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -55,7 +55,7 @@ All four must pass before a PR is mergeable. ### Release validation -Release builds depend on the reusable CI workflow, including its Python 3.11, +Release builds depend on a credential-free mirror of CI, including its Python 3.11, 3.12 and 3.13 matrix on Linux and Windows. Failed, cancelled or skipped validation blocks the build and therefore publication. The existing wheel and source distribution installation checks still run before their artifacts are uploaded. @@ -108,3 +108,8 @@ Do **not** open a public issue. Use [GitHub Security Advisories](https://github. ## Questions Open a [GitHub Discussion](https://github.com/orgs/agentrust-io/discussions) for design questions or proposals before writing code. + +The release validation workflow omits coverage upload and OIDC permission. A parity +test requires every other CI job and step to match, preventing validation drift. +Normal CI retains its coverage reporting; release events skip that upload. Only +the separate publish job can request a release OIDC token. diff --git a/tests/unit/test_release_artifacts.py b/tests/unit/test_release_artifacts.py index b010614..ac8cf0d 100644 --- a/tests/unit/test_release_artifacts.py +++ b/tests/unit/test_release_artifacts.py @@ -2,6 +2,7 @@ from __future__ import annotations +import copy import tomllib from importlib.metadata import version from pathlib import Path @@ -55,8 +56,10 @@ def test_publish_waits_for_both_artifact_install_smoke_tests() -> None: def test_release_reuses_complete_ci_validation() -> None: jobs = _release_workflow()["jobs"] - assert jobs["validate"]["uses"] == "./.github/workflows/ci.yml" - ci = yaml.safe_load(Path(".github/workflows/ci.yml").read_text(encoding="utf-8")) + assert jobs["validate"]["uses"] == "./.github/workflows/release-validation.yml" + ci = yaml.safe_load( + Path(".github/workflows/release-validation.yml").read_text(encoding="utf-8") + ) assert "workflow_call" in ci[True] matrix = ci["jobs"]["test"]["strategy"]["matrix"] assert matrix["python-version"] == ["3.11", "3.12", "3.13"] @@ -65,7 +68,7 @@ def test_release_reuses_complete_ci_validation() -> None: "pytest tests/unit/ tests/conformance/" in step.get("run", "") for step in ci["jobs"]["test"]["steps"] ) - assert jobs["validate"]["permissions"]["id-token"] == "write" + assert jobs["validate"]["permissions"] == {"contents": "read"} assert "environment" not in jobs["validate"] @@ -92,3 +95,33 @@ def test_release_dependency_graph_blocks_unsuccessful_validation(validation_resu assert results["governance-release"] == expected assert jobs["build"]["needs"] == "validate" assert jobs["governance-release"]["needs"] == "publish" + + +def test_release_validation_matches_ci() -> None: + ci = yaml.safe_load(Path(".github/workflows/ci.yml").read_text(encoding="utf-8")) + release_ci = yaml.safe_load( + Path(".github/workflows/release-validation.yml").read_text(encoding="utf-8") + ) + expected = copy.deepcopy(ci["jobs"]) + expected["test"]["permissions"].pop("id-token") + steps = expected["test"]["steps"] + coverage = [step for step in steps if step.get("name") == "Upload coverage report"] + assert len(coverage) == 1 + assert coverage[0]["if"] == "github.event_name != 'release'" + steps.remove(coverage[0]) + assert release_ci["jobs"] == expected + assert release_ci["permissions"] == {"contents": "read"} + assert set(release_ci[True]) == {"workflow_call"} + for job in release_ci["jobs"].values(): + assert job.get("permissions", {}).get("id-token", "none") == "none" + assert not any("codecov/" in step.get("uses", "") for step in job["steps"]) + + +def test_only_publish_can_mint_release_oidc() -> None: + workflow = _release_workflow() + assert workflow["permissions"] == {"contents": "read"} + assert [ + name + for name, job in workflow["jobs"].items() + if job.get("permissions", {}).get("id-token") == "write" + ] == ["publish"]