diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7bb53c0..b5c4eb5f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,7 @@ jobs: run: pytest tests/unit/ tests/conformance/ tests/integration/ tests/confinement/ -v --tb=short --cov=src --cov-report=xml - name: Upload coverage report + if: github.event_name != 'release' && github.workflow != '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 00000000..2f1a9b6f --- /dev/null +++ b/.github/workflows/release-validation.yml @@ -0,0 +1,140 @@ +name: Release validation + +# Credential-free CI mirror. test_release_validation_permissions 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 + python -m pip install --no-deps -e . + + - name: Security scan + # --skip-editable: pip-audit resolves the editable install of this package + # against PyPI and fails when the version is not published yet, so without + # it no version-bump PR can ever pass CI. Dependencies are still audited. + # + # 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 --skip-editable + + - name: Lint + run: ruff check src/ tests/ + + - name: Type check + run: mypy src/cmcp_runtime/ + + - name: Test + # Soak tests are intentionally excluded from per-PR CI. + run: pytest tests/unit/ tests/conformance/ tests/integration/ tests/confinement/ -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 cMCP 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 + python -m pip install --no-deps -e . + + - name: Install AGT as isolated CI tooling + run: | + python -m venv .agt-venv + .agt-venv/bin/python -m pip install --require-hashes -r requirements/agt.txt + .agt-venv/bin/python -m pip install --no-deps -e . + + - name: Generate evidence file + run: .agt-venv/bin/python scripts/gen_agt_evidence.py + + - name: Verify AGT runtime evidence (isolated) + run: | + set +e + output=$(.agt-venv/bin/agt verify --evidence agt-evidence.json 2>&1) + status=$? + set -e + printf '%s\n' "$output" + printf '%s\n' "$output" | grep -Fq "Runtime Evidence: 6/6 checks passed" + test "$status" -eq 0 || test "$status" -eq 1 + + - name: Save attestation JSON + run: | + set +e + .agt-venv/bin/agt --json verify --evidence agt-evidence.json > agt-attestation.json + status=$? + set -e + python -m json.tool agt-attestation.json > /dev/null + test "$status" -eq 0 || test "$status" -eq 1 + + - 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 + python -m pip install --no-deps -e . + + - name: Run benchmark (software-only, CI gate p99 < 5ms) + run: python -m cmcp_runtime.benchmarks --provider software-only --calls 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 b3ed0b73..9c1ee5d9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,8 +19,7 @@ jobs: test: permissions: contents: read - id-token: write # The existing CI uploads coverage through Codecov OIDC. - uses: ./.github/workflows/ci.yml + uses: ./.github/workflows/release-validation.yml build: needs: test diff --git a/tests/unit/test_release_validation_permissions.py b/tests/unit/test_release_validation_permissions.py new file mode 100644 index 00000000..e02db2f9 --- /dev/null +++ b/tests/unit/test_release_validation_permissions.py @@ -0,0 +1,38 @@ +"""Release validation preserves CI checks without publishing-token authority.""" + +from __future__ import annotations + +import copy +from pathlib import Path + +import yaml + + +def _workflow(name: str) -> dict: + return yaml.safe_load(Path(f".github/workflows/{name}.yml").read_text(encoding="utf-8")) + + +def test_release_validation_permissions() -> None: + ci = _workflow("ci") + validation = _workflow("release-validation") + release = _workflow("release") + 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' && github.workflow != 'Release'" + steps.remove(coverage[0]) + assert validation["jobs"] == expected + assert validation["permissions"] == {"contents": "read"} + assert set(validation[True]) == {"workflow_call"} + assert release["jobs"]["test"]["uses"] == "./.github/workflows/release-validation.yml" + assert release["jobs"]["test"]["permissions"] == {"contents": "read"} + assert release["permissions"] == {"contents": "read"} + assert [ + name for name, job in release["jobs"].items() + if job.get("permissions", {}).get("id-token") == "write" + ] == ["publish"] + for job in validation["jobs"].values(): + assert job.get("permissions", {}).get("id-token", "none") == "none" + assert not any("codecov/" in step.get("uses", "") for step in job["steps"])