Skip to content

feat(control): separate permission to publish a PR from permission to merge #994

feat(control): separate permission to publish a PR from permission to merge

feat(control): separate permission to publish a PR from permission to merge #994

Workflow file for this run

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