diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3548982..3ec3f23 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: @@ -62,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 56a8610..90c12e3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,7 +8,13 @@ permissions: contents: read jobs: + validate: + uses: ./.github/workflows/release-validation.yml + permissions: + contents: read + 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..0e468ec 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 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. +`governance-release` remains a post-publication attestation attached to the release. + ## Commit format Follow [Conventional Commits](https://www.conventionalcommits.org/): @@ -100,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 4338f98..ac8cf0d 100644 --- a/tests/unit/test_release_artifacts.py +++ b/tests/unit/test_release_artifacts.py @@ -2,10 +2,12 @@ from __future__ import annotations +import copy import tomllib from importlib.metadata import version from pathlib import Path +import pytest import yaml import ca2a_runtime @@ -50,3 +52,76 @@ 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/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"] + 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"] == {"contents": "read"} + 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" + + +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"]