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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
name: CI

on:
workflow_call:
push:
branches: [main]
pull_request:
Expand Down Expand Up @@ -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
Expand Down
138 changes: 138 additions & 0 deletions .github/workflows/release-validation.yml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/):
Expand Down Expand Up @@ -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.
75 changes: 75 additions & 0 deletions tests/unit/test_release_artifacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"]
Loading