feat(control): separate permission to publish a PR from permission to merge #994
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| on: | |
| pull_request: | |
| push: | |
| branches: | |
| - main | |
| permissions: | |
| contents: read | |
| jobs: | |
| test: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 | |
| - name: Set up Python | |
| uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 | |
| with: | |
| python-version: "3.12" | |
| cache: pip | |
| - name: Install | |
| run: python -m pip install -e ".[dev]" | |
| - name: Lint | |
| run: python -m ruff check . | |
| - name: Compile | |
| run: python -m compileall -q src tests | |
| - name: Verify generated schemas are up to date | |
| run: python scripts/generate_schemas.py --check | |
| - name: Trust-model invariant lint (static, no user code execution) | |
| run: python -m pytest tests/test_adapter_static_only.py -q | |
| # Fails fast and visibly before the full suite when an adapter | |
| # under src/agents_shipgate/inputs/ introduces exec/eval/__import__/ | |
| # compile or a dynamic-import surface (importlib/runpy/subprocess). | |
| # The companion live-load tests in tests/test_fixture_no_import.py | |
| # run as part of the main Test step below. | |
| - name: Evidence-backed pass safety canaries (64/64 exact outcomes) | |
| run: python -m pytest tests/test_p0_safety_canaries.py -q | |
| # This is deliberately separate from the aggregate test run. A P0 | |
| # verdict regression should fail with a named canary before coverage, | |
| # integration, or performance noise can obscure the safety signal. | |
| - name: Agent binding safety canaries | |
| run: python -m pytest tests/test_p0_binding_canaries.py -q | |
| # Binding is a separate trust dimension from effect and authority. | |
| # Keep these exact outcomes visible before the aggregate coverage run. | |
| - name: Multi-host boundary safety canaries (48+ exact outcomes) | |
| run: python -m pytest tests/test_agent_boundary.py -q | |
| # Cross-host boundary completeness is a separate trust dimension. | |
| # Keep false-complete, input-integrity, and secret-redaction failures | |
| # visible before the aggregate coverage run. | |
| - name: Test | |
| # v0.21 (E7): bumped from 75 → 85. Actual aggregate coverage on | |
| # current main is ~88%, so the gate is +10pp tighter with ~3pp | |
| # headroom for day-to-day movement. The bump catches the next | |
| # time a refactor lands materially less-covered code without | |
| # a corresponding test pass. | |
| # | |
| # Excludes the `perf` marker — the latency budget tests run as | |
| # a separate step below (after the main suite finishes so their | |
| # timing isn't disturbed by parallel test load). | |
| # | |
| # The static adapter invariant lint runs in its own fail-fast | |
| # step above, so keep it out of the coverage pass to avoid | |
| # doing the same AST sweep twice on every PR. | |
| run: python -m pytest -n auto -m "not perf" --ignore=tests/test_adapter_static_only.py --cov=agents_shipgate --cov-report=term-missing --cov-fail-under=85 | |
| - name: Latency budget | |
| # Runs the `benchmark/perf/scenarios/` synthetic scans and | |
| # asserts median wallclock stays under the per-scenario budget | |
| # in `benchmark/perf/budgets.yaml`. Budgets are deliberately | |
| # generous (~10-20x measured local time) so this catches | |
| # catastrophic regressions, not noise. On failure the test | |
| # output shows a per-phase breakdown via `_perf` — start there | |
| # before profiling. See `benchmark/perf/README.md` for tuning. | |
| run: python -m pytest tests/test_latency_budget.py -m perf -v | |
| - name: Build package | |
| run: | | |
| python -m build | |
| python -m twine check dist/* | |
| - name: Dependency audit | |
| run: python -m pip_audit . | |
| - name: Generate SBOM | |
| run: cyclonedx-py environment --pyproject pyproject.toml -o sbom.json | |
| - name: Upload SBOM | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a | |
| with: | |
| name: sbom | |
| path: sbom.json | |
| release-tag-consistency: | |
| # Public install surfaces follow | |
| # `.well-known.release_status.latest_release`, while pyproject may be | |
| # ahead on a source-tree pre-release. Public-surface tests enforce that | |
| # split. What no local test could catch: the install surfaces moving | |
| # to a version that was never actually released. That happened with | |
| # v0.14.0 (pyproject and the README `@v0.14.0` Action pin landed on | |
| # main a day before the tag existed, so the copy-paste workflow | |
| # 404'd). This job checks the claim against release reality: the | |
| # tag must exist on origin. | |
| # | |
| # Runs on pushes to main only (the `push` trigger above is scoped | |
| # to main), never on pull_request — so a version-bump PR is never | |
| # blocked, but a merged bump without a pushed tag turns main red | |
| # until the tag is pushed or the claim is reverted. | |
| # | |
| # Recovery: pushing the tag triggers release.yml, not this run, so | |
| # a failed run does not clear itself on tag push. It re-checks | |
| # origin's live tag list on re-run — after pushing the tag, use | |
| # "Re-run failed jobs" on this run (or wait for the next push to | |
| # main). A tag trigger on this workflow would not help: it would | |
| # start a *new* run on the tag ref, leave the failed branch run | |
| # red on the CI badge, and re-run the full test job that | |
| # release.yml already runs on the tag. | |
| # | |
| # Deliberately tag-only: the tag is what triggers release.yml, the | |
| # canonical publisher, which itself fails loudly if the PyPI upload | |
| # or GitHub Release step breaks — so "tag exists + release.yml | |
| # green" covers /releases/latest and PyPI. Querying PyPI here would | |
| # add an external dependency that lags publish by minutes (the | |
| # /json index is CDN-cached) and can false-fail right after a | |
| # legitimate release. | |
| if: github.event_name == 'push' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 | |
| - name: Verify the claimed latest release tag exists on origin | |
| run: | | |
| latest_release="$(python3 -c 'import json; print(json.load(open(".well-known/agents-shipgate.json"))["release_status"]["latest_release"])')" | |
| if [ -z "${latest_release}" ]; then | |
| echo "::error::Could not read release_status.latest_release from .well-known/agents-shipgate.json." | |
| exit 1 | |
| fi | |
| echo "Claimed latest published release: ${latest_release}" | |
| if git ls-remote --exit-code --tags origin "refs/tags/${latest_release}" > /dev/null; then | |
| echo "OK: tag ${latest_release} exists on origin." | |
| else | |
| echo "::error::Public install surfaces claim ${latest_release} as the latest release, but that tag does not exist on origin." | |
| echo "Cut the release or restore release_status.latest_release to the latest published tag." | |
| echo "then use 'Re-run failed jobs' on this run — pushing the tag triggers release.yml, not a retest of this run, but a re-run re-checks origin's live tag list. The check also clears on the next push to main." | |
| exit 1 | |
| fi |