Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
140 changes: 140 additions & 0 deletions .github/workflows/release-validation.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 1 addition & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions tests/unit/test_release_validation_permissions.py
Original file line number Diff line number Diff line change
@@ -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"])
Loading