From e73928971a8f369152144377e588016c76aeda08 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 11:52:42 -0700 Subject: [PATCH 1/9] chore: prepare 2.5.0 release candidate --- .github/workflows/publish.yml | 103 +++++++++++++-- CHANGELOG.md | 7 +- SECURITY.md | 18 +++ docs/2.5-RELEASE-NOTES.md | 80 ++++++++++++ docs/RELEASE-CHECKLIST.md | 72 ++++++++-- docs/SECURITY-REVIEW.md | 14 ++ docs/STABLE-READINESS.md | 43 +++--- docs/release-state.json | 6 + pyproject.toml | 2 +- scripts/verify_release.py | 210 ++++++++++++++++++++++++++++++ server.json | 4 +- tests/test_cli.py | 2 +- tests/test_distribution.py | 14 +- tests/test_release_metadata.py | 105 +++++++++++++++ tests/test_supply_chain_policy.py | 2 + uv.lock | 2 +- 16 files changed, 634 insertions(+), 50 deletions(-) create mode 100644 docs/2.5-RELEASE-NOTES.md create mode 100644 docs/release-state.json create mode 100644 scripts/verify_release.py create mode 100644 tests/test_release_metadata.py diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 54ede75..25e7cd1 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,33 +1,114 @@ name: Publish to PyPI on: - push: - tags: - - 'v*' + workflow_dispatch: + inputs: + tag: + description: Exact release tag (for example, v2.5.0) + required: true + type: string + commit: + description: Exact 40-character commit recorded in the release approval + required: true + type: string + approval: + description: Type publish-mcp-audits to confirm this separately authorized publish + required: true + type: string permissions: contents: read jobs: - build-and-publish: - name: Build and publish to PyPI + build: + name: Verify and build exact release runs-on: ubuntu-latest - environment: pypi - permissions: - contents: read - id-token: write # OIDC trusted publishing steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ inputs.commit }} + fetch-depth: 0 - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 with: python-version: "3.11" + - name: Verify release authorization binding + env: + RELEASE_APPROVAL: ${{ inputs.approval }} + RELEASE_COMMIT: ${{ inputs.commit }} + RELEASE_TAG: ${{ inputs.tag }} + run: | + git fetch --force --tags origin main + uv run python scripts/verify_release.py \ + --tag "$RELEASE_TAG" \ + --commit "$RELEASE_COMMIT" \ + --approval-token "$RELEASE_APPROVAL" \ + --require-publishable + + - name: Install locked dependencies + run: uv sync --dev --locked + + - name: Run release quality gate + run: | + uv run pytest + uv run ruff check + uv run ruff format --check + uv run mypy . + uv lock --check + git diff --check + - name: Build wheel and sdist - run: uv build + run: uv build --clear + + - name: Verify built distributions + env: + RELEASE_COMMIT: ${{ inputs.commit }} + RELEASE_TAG: ${{ inputs.tag }} + run: | + uv run python scripts/verify_release.py \ + --tag "$RELEASE_TAG" \ + --commit "$RELEASE_COMMIT" \ + --require-publishable \ + --dist-dir dist + sha256sum dist/* | tee SHA256SUMS + + - name: Upload exact candidate artifacts + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: mcp-audits-${{ inputs.tag }} + path: | + dist/*.whl + dist/*.tar.gz + SHA256SUMS + if-no-files-found: error + retention-days: 14 + + publish: + name: Publish approved artifacts to PyPI + needs: build + runs-on: ubuntu-latest + environment: pypi + permissions: + contents: read + id-token: write # OIDC trusted publishing + + steps: + - name: Download exact candidate artifacts + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + with: + name: mcp-audits-${{ inputs.tag }} + path: release/ + + - name: Read back artifact hashes + run: | + cd release + sha256sum -c SHA256SUMS + cat SHA256SUMS - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1 with: - packages-dir: dist/ + packages-dir: release/dist/ + attestations: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 6de62af..fca0ecb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.5.0] - Unreleased + ### Security - Raised the minimum `mcp` Python SDK version to 1.28.1, closing the vulnerable @@ -990,7 +992,10 @@ real workstations; API surface may still shift before the `1.0.0` stable cut. - Risk score output with pass/warn/fail thresholds - `mcp-audit` CLI entry point -[Unreleased]: https://github.com/saagpatel/MCPAudit/compare/v2.2.1...HEAD +[Unreleased]: https://github.com/saagpatel/MCPAudit/compare/v2.4.0...HEAD +[2.5.0]: https://github.com/saagpatel/MCPAudit/compare/v2.4.0...HEAD +[2.4.0]: https://github.com/saagpatel/MCPAudit/compare/v2.3.0...v2.4.0 +[2.3.0]: https://github.com/saagpatel/MCPAudit/compare/v2.2.1...v2.3.0 [2.2.1]: https://github.com/saagpatel/MCPAudit/compare/v2.2.0...v2.2.1 [2.2.0]: https://github.com/saagpatel/MCPAudit/compare/v2.1.0...v2.2.0 [2.1.0]: https://github.com/saagpatel/MCPAudit/compare/v2.0.0...v2.1.0 diff --git a/SECURITY.md b/SECURITY.md index bf1f787..4bd3871 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -58,6 +58,24 @@ MCPAudit parses and displays content from MCP server configs and MCP server meta When the optional `anthropic` dependency is installed and `--llm-analysis` is used, MCPAudit sends selected tool names, descriptions, and parameter names to the Anthropic API for permission classification. In this mode, content from audited MCP server configs is transmitted over the network to a third-party service. Do not use `--llm-analysis` if your MCP configs contain sensitive information (API keys in args, internal hostnames, etc.). +### Proof Before Action + +`proof-before-action` compares a declared boundary with observations from a +disposable Docker execution and emits a versioned offline evidence capsule. +Verification binds the staged subject, producer revision, deterministic report +projection, and an independently supplied root hash. + +Its result is evidence about the declared and observed surfaces, not a general +sandbox or safety guarantee. Unknown, stale, masked, unmatched, incomplete, +unobservable, dirty, or authority-unverified evidence remains non-authoritative +and cannot become a passing safety claim. + +The observer does not prove complete Unix-domain socket coverage, host-kernel +isolation, or safety outside the declared surfaces. A valid capsule also does +not prove that an operator independently obtained the root hash; callers must +supply and protect that trust input themselves. See +[`docs/SECURITY-REVIEW.md`](docs/SECURITY-REVIEW.md) for the detailed boundary. + --- ## Supported versions diff --git a/docs/2.5-RELEASE-NOTES.md b/docs/2.5-RELEASE-NOTES.md new file mode 100644 index 0000000..608a8f8 --- /dev/null +++ b/docs/2.5-RELEASE-NOTES.md @@ -0,0 +1,80 @@ +# MCPAudit 2.5.0 Release Boundary + +MCPAudit 2.5.0 is a backward-compatible minor release. It packages the +capabilities added since 2.4.0 and the dependency and workflow hardening already +landed on `main`. It does not change the existing 2.x audit-report or SARIF +compatibility policy. + +## Included + +- Proof Before Action: declaration, disposable observation, comparison, + release-trust manifest, deterministic evidence capsule, offline HTML, and + independent verification. +- ProofOS PostgreSQL verification and SafeForge pre-install/runtime evidence. +- Structured handling for corrupted pin baselines. +- Preservation of escalation categories in terminal output. +- A minimum `mcp` SDK version of 1.28.1 and immutable reviewed GitHub Action + revisions. + +## Compatibility and migration + +- Python 3.11, 3.12, and 3.13 remain supported. +- The distribution remains `mcp-audits`; the installed commands are + `mcp-audit`, `mcp-audits`, and `proof-before-action`. +- Existing 2.x report consumers may continue to accept additive optional + fields. No stable field or SARIF rule identifier is removed or renamed. +- Environments that deliberately constrained `mcp<1.28.1` must upgrade that + dependency before installing 2.5.0. + +## Security posture + +Proof Before Action is local-first and fail-closed at its evidence boundaries. +Unknown, stale, masked, unmatched, incomplete, unobservable, dirty, or +authority-unverified evidence does not become a passing safety claim. Capsule +verification binds the staged subject, producer revision, report projection, +and an independently supplied root hash. + +This evidence is narrower than a general sandbox guarantee. The Docker observer +does not prove complete Unix-domain socket coverage, host-kernel isolation, or +safety outside the declared and observed surfaces. Release-trust claims remain +only as authoritative as their exact producer/subject bindings and independently +supplied root. + +## Process limitations + +- The repository currently has one eligible human collaborator. Automated + review, CodeQL, CI, and permission-diff evidence reduce risk but do not replace + independent human review. +- Continuous fuzzing is not yet integrated. Deterministic fixture and + adversarial tests cover the shipped boundaries, but they are not a substitute + for a maintained fuzzing service. +- The project is not enrolled in the OpenSSF Best Practices badge program. +- Two independent redacted field reports have not been collected, so broad + downstream environment compatibility remains unproven. + +## Rollback + +Prefer reverting the consuming configuration while retaining MCPAudit 2.5.0 and +the `mcp>=1.28.1` security floor. Do not describe the existing `v2.4.0` Action +or pre-commit tag as a security-safe rollback: its published dependency metadata +allows vulnerable MCP SDK versions. + +If an emergency package rollback is unavoidable, constrain both packages and +read back the resolved versions: + +```bash +python -m pip install "mcp-audits==2.4.0" "mcp>=1.28.1" +python -c 'from importlib.metadata import version; print(version("mcp-audits"), version("mcp"))' +``` + +Evidence produced with new Proof Before Action schemas should be retained and +verified with the matching 2.5.0 producer rather than silently downgraded. + +## Publication boundary + +Merging the release-candidate PR does not authorize a tag, GitHub Release, PyPI +publication, deployment, or external registry update. Each requires separate +approval bound to the exact landed commit and tag. The manual publish workflow +builds and exposes the exact wheel and sdist hashes before the environment-bound +publish job. Public release remains `NO-GO` until the `pypi` environment requires +an independent reviewer. diff --git a/docs/RELEASE-CHECKLIST.md b/docs/RELEASE-CHECKLIST.md index d4f00d3..2e83744 100644 --- a/docs/RELEASE-CHECKLIST.md +++ b/docs/RELEASE-CHECKLIST.md @@ -6,25 +6,50 @@ candidate, or stable release. ## Local Verifier ```bash +uv sync --dev --locked uv run pytest uv run ruff check uv run mypy . uv run ruff format --check git diff --check +uv lock --check +uv run python scripts/verify_release.py uv build --clear ``` Remove generated `dist/` artifacts after the build check unless the release is being uploaded manually. -## Metadata Check +## Candidate Metadata Check -- `pyproject.toml` version matches `CHANGELOG.md`. -- `README.md`, `SECURITY.md`, and `IMPLEMENTATION-ROADMAP.md` match live CLI +- `pyproject.toml`, `server.json`, `docs/release-state.json`, and + `CHANGELOG.md` agree on the candidate version. +- While `docs/release-state.json` has `status: candidate`, README Action and + pre-commit examples continue to name the latest existing public tag. They + must not advertise a tag that does not exist. +- `README.md`, `SECURITY.md`, `docs/OUTPUT-CONTRACT.md`, + `docs/STABLE-READINESS.md`, and the versioned release notes match live CLI behavior. - `mcp-audit --version` reports the release version. -- `mcp-audits` remains the PyPI distribution name and `mcp-audit` - remains the installed command. +- `mcp-audits` remains the PyPI distribution name and the installed + `mcp-audit`, `mcp-audits`, and `proof-before-action` commands are present. +- Wheel and sdist metadata require `mcp>=1.28.1`; their contents contain no + private paths, development caches, or generated local evidence. +- Record SHA-256 hashes for the exact wheel and sdist being considered for + publication. + +## Exact-Candidate Security Readback + +- Record the exact candidate commit and re-query open Dependabot, code-scanning, + and secret-scanning alerts against the live repository. +- Record each open alert with severity and disposition. A missing, masked, + stale, or unavailable query is `UNKNOWN`, not zero. +- Confirm the candidate has a security-focused diff review and all reportable + findings are either repaired or explicitly accepted by the release decision + maker. +- Independent human review is required for a public release. If repository + ownership makes that impossible, keep release status `NO-GO` until a reviewer + or explicit residual-risk acceptance is available. ## Clean Install Smoke @@ -47,10 +72,35 @@ python -m venv "$tmp/venv" rm -r "$tmp" ``` -## Publish +## Finalize the Release State + +Use a separate reviewed PR after the candidate has landed: + +1. Change `docs/release-state.json` to `status: release` and set + `published_version` to the candidate version. +2. Update README Action and pre-commit examples to the new public tag. +3. Replace `Unreleased` with the release date in `CHANGELOG.md` and finalize its + comparison links. +4. Rerun the full local, security, metadata, build, and installed-command gates. + +Merging a candidate or release-state PR does not authorize tagging or +publication. + +## Publish (Separately Authorized) + +1. Obtain separate publication approval naming the exact 40-character merge + commit and `vX.Y.Z` tag. Confirm the `pypi` environment requires an + independent reviewer; otherwise stop with `NO-GO`. +2. Create the tag only after that approval. Tag creation does not publish. +3. Manually dispatch `Publish to PyPI` with the exact tag, commit, and + `publish-mcp-audits` confirmation. The workflow rechecks the tag/commit/main + binding, release-state gate, lockfile, tests, style, types, package metadata, + and clean build provenance. +4. Review the build job's wheel and sdist SHA-256 values before approving the + protected `publish` job. That job downloads the exact retained artifact, + verifies its hashes, and only then requests PyPI OIDC authority. +5. Confirm the PyPI release JSON and simple index include the new version. +6. Create or update the matching GitHub Release notes. -1. Merge the release PR after CI passes. -2. Tag the merge commit as `vX.Y.Z`. -3. Confirm the `Publish to PyPI` workflow succeeds. -4. Confirm the PyPI release JSON and simple index include the new version. -5. Create or update the matching GitHub Release notes. +Never re-run or bypass a failed publish gate. Repair the release state through a +new reviewed commit and obtain a new exact approval. diff --git a/docs/SECURITY-REVIEW.md b/docs/SECURITY-REVIEW.md index c96a391..5dc817f 100644 --- a/docs/SECURITY-REVIEW.md +++ b/docs/SECURITY-REVIEW.md @@ -16,6 +16,14 @@ places where untrusted MCP metadata can affect reports. optional LLM analysis is explicitly requested. - JSON and SARIF now include target metadata for tool, prompt, and resource findings so downstream triage does not need to infer where a result came from. +- Proof Before Action observes a staged, commit-bound subject in a disposable + Docker container and requires an exact image ID before image-provided observer + tools run. Capsule verification recomputes the comparison, trust binding, and + offline HTML projection against an explicit producer commit and an + independently supplied root hash. +- Proof Before Action treats stale, masked, unmatched, unobservable, incomplete, + dirty, or authority-unverified evidence as non-passing. A complete observation + is not itself a claim that the tested action is generally safe. ## Remaining Risks @@ -25,6 +33,12 @@ places where untrusted MCP metadata can affect reports. `scan --skip-connect` for first review or untrusted configs. - Optional `--llm-analysis` sends selected metadata to a third-party API; do not use it for sensitive MCP configs. +- Proof Before Action is a bounded evidence collector, not a general sandbox. + Its current Docker observer does not establish complete Unix-domain socket + coverage, host-kernel isolation, or safety outside the declared and observed + surfaces. +- Release-trust evidence remains only as authoritative as its independently + supplied root and exact producer/subject bindings. - Composite scoring is tool-centered in the stable line. Prompt/resource findings are reportable and policy-gatable, but score migration needs calibration first. diff --git a/docs/STABLE-READINESS.md b/docs/STABLE-READINESS.md index d0ef380..eb11dad 100644 --- a/docs/STABLE-READINESS.md +++ b/docs/STABLE-READINESS.md @@ -19,25 +19,38 @@ tests, and install path agree. - Known limitations are documented in release notes and beta/stable readiness docs. -## Current Status +## Current 2.5.0 Candidate -Ready for stable release: +The 2.5.0 candidate is a backward-compatible minor release. It adds Proof Before +Action, ProofOS PostgreSQL verification, SafeForge runtime/pre-install evidence, +and the security dependency/pinning repairs recorded in the changelog. Existing +2.x audit-report and SARIF contracts remain additive. -- package install and command naming are settled; -- connected and config-only scan boundaries are documented; -- JSON/SARIF rule IDs are documented and snapshot-tested; -- older report shapes and additive future fields have compatibility fixtures; -- user feedback has a fixture-driven intake path; -- validation corpus covers common filesystem, network, shell, destructive, - exfiltration, browser, cloud, database, and messaging server shapes. -- whole-repo strict typing passes with `uv run mypy .`. +Release evidence must establish: -Release-candidate decision: +- package, `server.json`, changelog, and `docs/release-state.json` agree on the + 2.5.0 candidate while public Action and pre-commit examples continue to name + the latest existing tag; +- wheel and sdist metadata require `mcp>=1.28.1` and expose `mcp-audit`, + `mcp-audits`, and `proof-before-action`; +- the full quality gate and an installed Proof Before Action workflow pass from + an exact clean candidate commit; +- missing, stale, masked, unmatched, incomplete, or unobservable evidence never + becomes a safety claim; +- the remaining OpenSSF human-review, fuzzing, and best-practices-badge findings + have explicit evidence-backed dispositions. -- keep prompt/resource findings outside `risk_score.composite` for `1.0.0`; -- keep `non_tool_risk` additive for `1.x`; -- do not move to beta until external redacted reports confirm output-contract - stability for downstream consumers. +The candidate may be merged after its required repository checks pass. Tagging, +publishing, and external registry changes remain separate actions. Tag creation +no longer triggers publication: the publish workflow requires a manual dispatch +bound to an exact tag and commit, verifies the candidate, and exposes artifact +hashes before the environment-bound OIDC job. +Public release is NO-GO until they are explicitly authorized and the unresolved +human-review risk is accepted or satisfied. The `pypi` environment must also +require an independent reviewer; without that external protection, the +candidate remains intentionally non-publishable. The absence of two external +redacted field reports also remains a limitation: solo fixture evidence does not +prove downstream adoption or broad environment compatibility. ## Go/No-Go Checklist diff --git a/docs/release-state.json b/docs/release-state.json new file mode 100644 index 0000000..c9771c5 --- /dev/null +++ b/docs/release-state.json @@ -0,0 +1,6 @@ +{ + "schema_version": "mcp-audit.release-state.v1", + "candidate_version": "2.5.0", + "published_version": "2.4.0", + "status": "candidate" +} diff --git a/pyproject.toml b/pyproject.toml index a4fe30b..32e8f6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcp-audits" -version = "2.4.0" +version = "2.5.0" description = "MCP Permission Auditor — scan, enumerate, and risk-score all locally configured MCP servers" readme = "README.md" license = "MIT" diff --git a/scripts/verify_release.py b/scripts/verify_release.py new file mode 100644 index 0000000..b216601 --- /dev/null +++ b/scripts/verify_release.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +"""Verify that release metadata, Git identity, and distributions agree.""" + +from __future__ import annotations + +import argparse +import email.parser +import hashlib +import json +import re +import subprocess +import sys +import tarfile +import tomllib +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +RELEASE_STATE_PATH = ROOT / "docs/release-state.json" +EXPECTED_APPROVAL = "publish-mcp-audits" +VERSION_RE = re.compile(r"[0-9]+\.[0-9]+\.[0-9]+") +COMMIT_RE = re.compile(r"[0-9a-f]{40}") + + +class VerificationError(RuntimeError): + """A release invariant is not satisfied.""" + + +def _read_text(path: str) -> str: + return (ROOT / path).read_text(encoding="utf-8") + + +def _project() -> dict[str, object]: + project = tomllib.loads(_read_text("pyproject.toml"))["project"] + if not isinstance(project, dict): + raise VerificationError("pyproject.toml project table is invalid") + return project + + +def _release_state() -> dict[str, object]: + state = json.loads(RELEASE_STATE_PATH.read_text(encoding="utf-8")) + if not isinstance(state, dict): + raise VerificationError("release-state.json must contain an object") + return state + + +def _version() -> str: + version = _project().get("version") + if not isinstance(version, str) or VERSION_RE.fullmatch(version) is None: + raise VerificationError("project.version must be a stable semantic version") + return version + + +def _run_git(*args: str) -> str: + result = subprocess.run( + ["git", "-C", str(ROOT), *args], + check=False, + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode != 0: + detail = result.stderr.strip() or result.stdout.strip() or "git command failed" + raise VerificationError(detail) + return result.stdout.strip() + + +def verify_metadata(*, require_publishable: bool) -> tuple[str, dict[str, object]]: + version = _version() + state = _release_state() + server = json.loads(_read_text("server.json")) + changelog = _read_text("CHANGELOG.md") + readme = _read_text("README.md") + adoption = _read_text("docs/ADOPTION-GUIDE.md") + + if state.get("schema_version") != "mcp-audit.release-state.v1": + raise VerificationError("release-state.json schema is unsupported") + if state.get("candidate_version") != version: + raise VerificationError("candidate version does not match project.version") + published = state.get("published_version") + if not isinstance(published, str) or VERSION_RE.fullmatch(published) is None: + raise VerificationError("published version is invalid") + status = state.get("status") + if status not in {"candidate", "release"}: + raise VerificationError("release status must be candidate or release") + if server.get("version") != version or server.get("packages", [{}])[0].get("version") != version: + raise VerificationError("server.json versions do not match project.version") + if f"## [{version}] -" not in changelog: + raise VerificationError("CHANGELOG.md has no candidate release section") + + public_version = version if status == "release" else published + for path, content in (("README.md", readme), ("docs/ADOPTION-GUIDE.md", adoption)): + if f"saagpatel/MCPAudit@v{public_version}" not in content: + raise VerificationError(f"{path} does not reference the usable public release") + if f"rev: v{public_version}" not in adoption: + raise VerificationError("pre-commit example does not reference the usable public release") + + dependencies = _project().get("dependencies") + if not isinstance(dependencies, list) or "mcp>=1.28.1" not in dependencies: + raise VerificationError("project metadata does not retain the mcp>=1.28.1 security floor") + if require_publishable and status != "release": + raise VerificationError("candidate state is intentionally non-publishable") + return version, state + + +def verify_git_binding(*, tag: str, commit: str) -> None: + version = _version() + if tag != f"v{version}": + raise VerificationError(f"tag must be exactly v{version}") + if COMMIT_RE.fullmatch(commit) is None: + raise VerificationError("commit must be an exact lowercase 40-character Git object ID") + if _run_git("rev-parse", "HEAD") != commit: + raise VerificationError("checked-out HEAD does not match the approved commit") + if _run_git("rev-parse", f"refs/tags/{tag}^{{commit}}") != commit: + raise VerificationError("release tag does not resolve to the approved commit") + _run_git("merge-base", "--is-ancestor", commit, "origin/main") + if _run_git("status", "--porcelain", "--untracked-files=all"): + raise VerificationError("release checkout is dirty") + + +def _parse_metadata(raw: bytes) -> email.message.Message: + return email.parser.BytesParser().parsebytes(raw) + + +def _check_distribution_metadata(raw: bytes, *, version: str, name: str) -> None: + metadata = _parse_metadata(raw) + if metadata.get("Name") != "mcp-audits": + raise VerificationError(f"{name} has the wrong distribution name") + if metadata.get("Version") != version: + raise VerificationError(f"{name} has the wrong version") + requirements = metadata.get_all("Requires-Dist", []) + if not any(requirement.replace(" ", "").startswith("mcp>=1.28.1") for requirement in requirements): + raise VerificationError(f"{name} does not retain the mcp>=1.28.1 security floor") + + +def _check_provenance(raw: bytes, *, commit: str, name: str) -> None: + provenance = json.loads(raw) + if provenance.get("commit") != commit or provenance.get("dirty") is not False: + raise VerificationError(f"{name} is not bound to the clean approved commit") + + +def verify_distributions(*, dist_dir: Path, version: str, commit: str) -> list[tuple[str, str]]: + wheel = dist_dir / f"mcp_audits-{version}-py3-none-any.whl" + sdist = dist_dir / f"mcp_audits-{version}.tar.gz" + if not wheel.is_file() or not sdist.is_file(): + raise VerificationError("expected wheel and sdist are missing") + with zipfile.ZipFile(wheel) as archive: + _check_distribution_metadata( + archive.read(f"mcp_audits-{version}.dist-info/METADATA"), + version=version, + name=wheel.name, + ) + _check_provenance( + archive.read("mcp_audit/_build_provenance.json"), + commit=commit, + name=wheel.name, + ) + with tarfile.open(sdist, mode="r:gz") as archive: + prefix = f"mcp_audits-{version}" + metadata_file = archive.extractfile(f"{prefix}/PKG-INFO") + provenance_file = archive.extractfile(f"{prefix}/src/mcp_audit/_build_provenance.json") + if metadata_file is None or provenance_file is None: + raise VerificationError("sdist metadata or provenance is missing") + _check_distribution_metadata(metadata_file.read(), version=version, name=sdist.name) + _check_provenance(provenance_file.read(), commit=commit, name=sdist.name) + return [(path.name, hashlib.sha256(path.read_bytes()).hexdigest()) for path in (wheel, sdist)] + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--tag", help="exact v-prefixed release tag") + parser.add_argument("--commit", help="exact 40-character approved commit") + parser.add_argument("--approval-token") + parser.add_argument("--require-publishable", action="store_true") + parser.add_argument("--dist-dir", type=Path) + return parser + + +def main() -> int: + args = _parser().parse_args() + try: + version, _state = verify_metadata(require_publishable=args.require_publishable) + if args.tag is not None or args.commit is not None: + if args.tag is None or args.commit is None: + raise VerificationError("--tag and --commit must be supplied together") + verify_git_binding(tag=args.tag, commit=args.commit) + if args.approval_token is not None and args.approval_token != EXPECTED_APPROVAL: + raise VerificationError("publication approval token is invalid") + if args.require_publishable and args.approval_token is None and args.dist_dir is None: + raise VerificationError("initial publish verification requires an approval token") + hashes: list[tuple[str, str]] = [] + if args.dist_dir is not None: + if args.commit is None: + raise VerificationError("distribution verification requires --commit") + hashes = verify_distributions( + dist_dir=args.dist_dir.resolve(), + version=version, + commit=args.commit, + ) + except (OSError, KeyError, IndexError, json.JSONDecodeError, VerificationError) as exc: + print(f"release verification failed: {exc}", file=sys.stderr) + return 1 + print(f"release metadata verified for {version}") + for filename, digest in hashes: + print(f"{digest} {filename}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/server.json b/server.json index a610be8..7652115 100644 --- a/server.json +++ b/server.json @@ -7,13 +7,13 @@ "url": "https://github.com/saagpatel/MCPAudit", "source": "github" }, - "version": "2.4.0", + "version": "2.5.0", "packages": [ { "registryType": "pypi", "registryBaseUrl": "https://pypi.org", "identifier": "mcp-audits", - "version": "2.4.0", + "version": "2.5.0", "runtimeHint": "uvx", "transport": { "type": "stdio" }, "packageArguments": [{ "type": "positional", "value": "serve" }] diff --git a/tests/test_cli.py b/tests/test_cli.py index 86a8dbd..17e04a7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -38,7 +38,7 @@ def test_version_option_reports_installed_distribution_version() -> None: assert result.exit_code == 0 assert "mcp-audit, version " in result.output - assert "2.4.0" in result.output + assert "2.5.0" in result.output def test_error_messages_route_to_stderr_not_stdout() -> None: diff --git a/tests/test_distribution.py b/tests/test_distribution.py index 0a2940f..00309d3 100644 --- a/tests/test_distribution.py +++ b/tests/test_distribution.py @@ -11,8 +11,8 @@ from __future__ import annotations +import json import re -import tomllib from pathlib import Path from typing import Any @@ -23,9 +23,9 @@ SELF_AUDIT_PATH = Path(".github/workflows/self-audit.yml") -def _package_version() -> str: - data = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8")) - version = data["project"]["version"] +def _public_action_version() -> str: + data = json.loads(Path("docs/release-state.json").read_text(encoding="utf-8")) + version = data["published_version"] assert isinstance(version, str) return version @@ -96,9 +96,9 @@ def test_triggers_on_repo_local_configs(self) -> None: # Should not fire on unrelated JSON. assert not pattern.search("package.json") - def test_rev_in_docs_matches_package_version(self) -> None: - # The pre-commit usage example pins `rev: v`; keep it in sync. - version_tag = f"v{_package_version()}" + def test_rev_in_docs_matches_public_release(self) -> None: + # Candidate metadata may move ahead, but examples must name an existing public tag. + version_tag = f"v{_public_action_version()}" adoption = Path("docs/ADOPTION-GUIDE.md").read_text(encoding="utf-8") assert f"rev: {version_tag}" in adoption diff --git a/tests/test_release_metadata.py b/tests/test_release_metadata.py new file mode 100644 index 0000000..810dfa8 --- /dev/null +++ b/tests/test_release_metadata.py @@ -0,0 +1,105 @@ +"""Release metadata must distinguish a candidate from a published release.""" + +from __future__ import annotations + +import json +import re +import subprocess +import tomllib +from pathlib import Path + +import pytest + + +def _project_version() -> str: + project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"] + version = project["version"] + assert isinstance(version, str) + return version + + +def test_release_version_is_consistent_across_public_surfaces() -> None: + version = _project_version() + state = json.loads(Path("docs/release-state.json").read_text(encoding="utf-8")) + server = json.loads(Path("server.json").read_text(encoding="utf-8")) + changelog = Path("CHANGELOG.md").read_text(encoding="utf-8") + readme = Path("README.md").read_text(encoding="utf-8") + adoption = Path("docs/ADOPTION-GUIDE.md").read_text(encoding="utf-8") + + assert state == { + "schema_version": "mcp-audit.release-state.v1", + "candidate_version": version, + "published_version": "2.4.0", + "status": "candidate", + } + assert server["version"] == version + assert server["packages"][0]["version"] == version + assert f"## [{version}] - Unreleased" in changelog + assert f"[{version}]: https://github.com/saagpatel/MCPAudit/compare/" in changelog + assert "saagpatel/MCPAudit@v2.4.0" in readme + assert "saagpatel/MCPAudit@v2.4.0" in adoption + assert "rev: v2.4.0" in adoption + + +def test_release_version_is_a_stable_semantic_version() -> None: + assert re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", _project_version()) + + +def test_candidate_metadata_verifier_passes() -> None: + result = subprocess.run( + ["uv", "run", "python", "scripts/verify_release.py"], + check=False, + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0, result.stderr + assert "release metadata verified for 2.5.0" in result.stdout + + +@pytest.mark.parametrize( + ("arguments", "message"), + [ + (["--require-publishable"], "candidate state is intentionally non-publishable"), + (["--approval-token", "wrong"], "publication approval token is invalid"), + ], +) +def test_candidate_metadata_verifier_fails_closed( + arguments: list[str], + message: str, +) -> None: + result = subprocess.run( + ["uv", "run", "python", "scripts/verify_release.py", *arguments], + check=False, + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 1 + assert message in result.stderr + + +def test_publication_requires_a_separate_manual_dispatch() -> None: + workflow = Path(".github/workflows/publish.yml").read_text(encoding="utf-8") + + trigger = workflow.split("\npermissions:", maxsplit=1)[0] + assert "workflow_dispatch:" in trigger + assert "\n push:" not in trigger + assert "commit:" in trigger + assert "tag:" in trigger + assert "approval:" in trigger + + +def test_oidc_authority_is_confined_to_post_build_publish_job() -> None: + workflow = Path(".github/workflows/publish.yml").read_text(encoding="utf-8") + build_job, publish_job = workflow.split("\n publish:\n", maxsplit=1) + + assert "id-token: write" not in build_job + assert "uv run pytest" in build_job + assert "scripts/verify_release.py" in build_job + assert "actions/upload-artifact@" in build_job + assert "needs: build" in publish_job + assert "environment: pypi" in publish_job + assert "id-token: write" in publish_job + assert "sha256sum -c SHA256SUMS" in publish_job + assert publish_job.index("sha256sum -c SHA256SUMS") < publish_job.index("pypa/gh-action-pypi-publish@") diff --git a/tests/test_supply_chain_policy.py b/tests/test_supply_chain_policy.py index a3c336d..6606a9f 100644 --- a/tests/test_supply_chain_policy.py +++ b/tests/test_supply_chain_policy.py @@ -10,7 +10,9 @@ # Changing any row is an explicit supply-chain review event, not a shape-only update. REVIEWED_ACTION_RELEASES = { "actions/checkout": ("9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0", "v7.0.0"), + "actions/download-artifact": ("634f93cb2916e3fdff6788551b99b062d0335ce0", "v5.0.0"), "actions/setup-python": ("ece7cb06caefa5fff74198d8649806c4678c61a1", "v6.3.0"), + "actions/upload-artifact": ("ea165f8d65b6e75b540449e92b4886f43607fa02", "v4.6.2"), "astral-sh/setup-uv": ("37802adc94f370d6bfd71619e3f0bf239e1f3b78", "v7.6.0"), "github/codeql-action/analyze": ("7188fc363630916deb702c7fdcf4e481b751f97a", "v4.37.1"), "github/codeql-action/init": ("7188fc363630916deb702c7fdcf4e481b751f97a", "v4.37.1"), diff --git a/uv.lock b/uv.lock index 68337a6..0ab45c0 100644 --- a/uv.lock +++ b/uv.lock @@ -618,7 +618,7 @@ wheels = [ [[package]] name = "mcp-audits" -version = "2.4.0" +version = "2.5.0" source = { editable = "." } dependencies = [ { name = "anyio" }, From f7d9e11c8da84a589594a34673e9fe760b8754d9 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 11:53:11 -0700 Subject: [PATCH 2/9] fix: verify untagged candidate artifacts --- scripts/verify_release.py | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/scripts/verify_release.py b/scripts/verify_release.py index b216601..e459ed2 100644 --- a/scripts/verify_release.py +++ b/scripts/verify_release.py @@ -103,19 +103,21 @@ def verify_metadata(*, require_publishable: bool) -> tuple[str, dict[str, object return version, state -def verify_git_binding(*, tag: str, commit: str) -> None: +def verify_git_binding(*, tag: str | None, commit: str, require_landed: bool) -> None: version = _version() - if tag != f"v{version}": - raise VerificationError(f"tag must be exactly v{version}") if COMMIT_RE.fullmatch(commit) is None: raise VerificationError("commit must be an exact lowercase 40-character Git object ID") if _run_git("rev-parse", "HEAD") != commit: raise VerificationError("checked-out HEAD does not match the approved commit") - if _run_git("rev-parse", f"refs/tags/{tag}^{{commit}}") != commit: - raise VerificationError("release tag does not resolve to the approved commit") - _run_git("merge-base", "--is-ancestor", commit, "origin/main") if _run_git("status", "--porcelain", "--untracked-files=all"): raise VerificationError("release checkout is dirty") + if tag is not None: + if tag != f"v{version}": + raise VerificationError(f"tag must be exactly v{version}") + if _run_git("rev-parse", f"refs/tags/{tag}^{{commit}}") != commit: + raise VerificationError("release tag does not resolve to the approved commit") + if require_landed: + _run_git("merge-base", "--is-ancestor", commit, "origin/main") def _parse_metadata(raw: bytes) -> email.message.Message: @@ -180,10 +182,16 @@ def main() -> int: args = _parser().parse_args() try: version, _state = verify_metadata(require_publishable=args.require_publishable) - if args.tag is not None or args.commit is not None: - if args.tag is None or args.commit is None: - raise VerificationError("--tag and --commit must be supplied together") - verify_git_binding(tag=args.tag, commit=args.commit) + if args.tag is not None and args.commit is None: + raise VerificationError("--tag requires --commit") + if args.require_publishable and (args.tag is None or args.commit is None): + raise VerificationError("publish verification requires --tag and --commit") + if args.commit is not None: + verify_git_binding( + tag=args.tag, + commit=args.commit, + require_landed=args.require_publishable, + ) if args.approval_token is not None and args.approval_token != EXPECTED_APPROVAL: raise VerificationError("publication approval token is invalid") if args.require_publishable and args.approval_token is None and args.dist_dir is None: From 37aa80f88767e16ed84dcfdb082f1aa8368710ce Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 12:23:46 -0700 Subject: [PATCH 3/9] fix: close release candidate gate gaps --- docs/RELEASE-CHECKLIST.md | 53 ++++++++++++++++-------- docs/STABLE-READINESS.md | 6 +-- docs/release-state.json | 1 + scripts/verify_release.py | 69 ++++++++++++++++++++++++++++--- server.json | 4 +- src/mcp_audit/proof_cli.py | 1 + tests/test_proof_before_action.py | 7 ++++ tests/test_release_metadata.py | 53 +++++++++++++++++++++++- 8 files changed, 165 insertions(+), 29 deletions(-) diff --git a/docs/RELEASE-CHECKLIST.md b/docs/RELEASE-CHECKLIST.md index 2e83744..9f1a489 100644 --- a/docs/RELEASE-CHECKLIST.md +++ b/docs/RELEASE-CHECKLIST.md @@ -22,11 +22,12 @@ being uploaded manually. ## Candidate Metadata Check -- `pyproject.toml`, `server.json`, `docs/release-state.json`, and - `CHANGELOG.md` agree on the candidate version. -- While `docs/release-state.json` has `status: candidate`, README Action and - pre-commit examples continue to name the latest existing public tag. They - must not advertise a tag that does not exist. +- `pyproject.toml`, `docs/release-state.json`, and `CHANGELOG.md` agree on the + candidate version. +- While `docs/release-state.json` has `status: candidate`, `server.json`, + README Action examples, and pre-commit examples continue to name the latest + existing public version/tag. They must not advertise a package or tag that + does not exist. - `README.md`, `SECURITY.md`, `docs/OUTPUT-CONTRACT.md`, `docs/STABLE-READINESS.md`, and the versioned release notes match live CLI behavior. @@ -51,24 +52,29 @@ being uploaded manually. ownership makes that impossible, keep release status `NO-GO` until a reviewer or explicit residual-risk acceptance is available. -## Clean Install Smoke +## Exact Candidate Install Smoke -Use an isolated cache for `uvx` so the check does not reuse an older local -install. +Build from the exact clean candidate commit, verify its provenance, then install +the wheel and sdist directly. Do not use unversioned PyPI here: before +publication, that would exercise the previous public release. ```bash tmp="$(mktemp -d)" -UV_CACHE_DIR="$tmp" uvx --prerelease allow --from mcp-audits mcp-audit --version -rm -r "$tmp" +UV_CACHE_DIR="$tmp/cache" uv venv "$tmp/wheel" +UV_CACHE_DIR="$tmp/cache" uv pip install \ + --python "$tmp/wheel/bin/python" ./dist/mcp_audits-X.Y.Z-py3-none-any.whl +"$tmp/wheel/bin/mcp-audit" --version +"$tmp/wheel/bin/mcp-audits" --version +"$tmp/wheel/bin/proof-before-action" --version ``` -Use a temporary virtual environment for a plain pip install check. - ```bash -tmp="$(mktemp -d)" -python -m venv "$tmp/venv" -"$tmp/venv/bin/python" -m pip install mcp-audits -"$tmp/venv/bin/mcp-audit" --version +UV_CACHE_DIR="$tmp/cache" uv venv "$tmp/sdist" +UV_CACHE_DIR="$tmp/cache" uv pip install \ + --python "$tmp/sdist/bin/python" ./dist/mcp_audits-X.Y.Z.tar.gz +"$tmp/sdist/bin/mcp-audit" --version +"$tmp/sdist/bin/mcp-audits" --version +"$tmp/sdist/bin/proof-before-action" --version rm -r "$tmp" ``` @@ -78,7 +84,8 @@ Use a separate reviewed PR after the candidate has landed: 1. Change `docs/release-state.json` to `status: release` and set `published_version` to the candidate version. -2. Update README Action and pre-commit examples to the new public tag. +2. Update `server.json`, README Action examples, and pre-commit examples to the + new public version/tag. 3. Replace `Unreleased` with the release date in `CHANGELOG.md` and finalize its comparison links. 4. Rerun the full local, security, metadata, build, and installed-command gates. @@ -104,3 +111,15 @@ publication. Never re-run or bypass a failed publish gate. Repair the release state through a new reviewed commit and obtain a new exact approval. + +## Post-Publication Install Smoke + +After PyPI readback proves the new version exists, use an isolated cache so the +check cannot reuse a local candidate install: + +```bash +tmp="$(mktemp -d)" +UV_CACHE_DIR="$tmp" uvx --from "mcp-audits==X.Y.Z" mcp-audit --version +UV_CACHE_DIR="$tmp" uvx --from "mcp-audits==X.Y.Z" proof-before-action --version +rm -r "$tmp" +``` diff --git a/docs/STABLE-READINESS.md b/docs/STABLE-READINESS.md index eb11dad..ef0daa3 100644 --- a/docs/STABLE-READINESS.md +++ b/docs/STABLE-READINESS.md @@ -28,9 +28,9 @@ and the security dependency/pinning repairs recorded in the changelog. Existing Release evidence must establish: -- package, `server.json`, changelog, and `docs/release-state.json` agree on the - 2.5.0 candidate while public Action and pre-commit examples continue to name - the latest existing tag; +- package, changelog, and `docs/release-state.json` agree on the 2.5.0 + candidate while `server.json` and public Action/pre-commit examples continue + to name the latest existing public release; - wheel and sdist metadata require `mcp>=1.28.1` and expose `mcp-audit`, `mcp-audits`, and `proof-before-action`; - the full quality gate and an installed Proof Before Action workflow pass from diff --git a/docs/release-state.json b/docs/release-state.json index c9771c5..7226e94 100644 --- a/docs/release-state.json +++ b/docs/release-state.json @@ -2,5 +2,6 @@ "schema_version": "mcp-audit.release-state.v1", "candidate_version": "2.5.0", "published_version": "2.4.0", + "previous_version": "2.4.0", "status": "candidate" } diff --git a/scripts/verify_release.py b/scripts/verify_release.py index e459ed2..b2defd7 100644 --- a/scripts/verify_release.py +++ b/scripts/verify_release.py @@ -83,12 +83,14 @@ def verify_metadata(*, require_publishable: bool) -> tuple[str, dict[str, object status = state.get("status") if status not in {"candidate", "release"}: raise VerificationError("release status must be candidate or release") - if server.get("version") != version or server.get("packages", [{}])[0].get("version") != version: - raise VerificationError("server.json versions do not match project.version") - if f"## [{version}] -" not in changelog: - raise VerificationError("CHANGELOG.md has no candidate release section") - + if status == "release" and published != version: + raise VerificationError("release status requires published_version to equal the candidate") public_version = version if status == "release" else published + if ( + server.get("version") != public_version + or server.get("packages", [{}])[0].get("version") != public_version + ): + raise VerificationError("server.json does not reference the usable public release") for path, content in (("README.md", readme), ("docs/ADOPTION-GUIDE.md", adoption)): if f"saagpatel/MCPAudit@v{public_version}" not in content: raise VerificationError(f"{path} does not reference the usable public release") @@ -98,6 +100,30 @@ def verify_metadata(*, require_publishable: bool) -> tuple[str, dict[str, object dependencies = _project().get("dependencies") if not isinstance(dependencies, list) or "mcp>=1.28.1" not in dependencies: raise VerificationError("project metadata does not retain the mcp>=1.28.1 security floor") + release_notes = ROOT / f"docs/{version.rsplit('.', maxsplit=1)[0]}-RELEASE-NOTES.md" + if not release_notes.is_file() or f"MCPAudit {version}" not in release_notes.read_text(encoding="utf-8"): + raise VerificationError("versioned release notes are missing or mismatched") + if status == "candidate": + if f"## [{version}] - Unreleased" not in changelog: + raise VerificationError("candidate changelog section is not explicitly unreleased") + else: + if ( + re.search( + rf"^## \[{re.escape(version)}\] - \d{{4}}-\d{{2}}-\d{{2}}$", + changelog, + re.MULTILINE, + ) + is None + ): + raise VerificationError("release changelog section must have a final date") + if f"[Unreleased]: https://github.com/saagpatel/MCPAudit/compare/v{version}...HEAD" not in changelog: + raise VerificationError("Unreleased comparison link is not based on the final tag") + previous = state.get("previous_version") + if not isinstance(previous, str) or VERSION_RE.fullmatch(previous) is None: + raise VerificationError("release status requires a valid previous_version") + expected_link = f"[{version}]: https://github.com/saagpatel/MCPAudit/compare/v{previous}...v{version}" + if expected_link not in changelog: + raise VerificationError("release comparison link is not finalized") if require_publishable and status != "release": raise VerificationError("candidate state is intentionally non-publishable") return version, state @@ -141,6 +167,25 @@ def _check_provenance(raw: bytes, *, commit: str, name: str) -> None: raise VerificationError(f"{name} is not bound to the clean approved commit") +def _check_entry_points(raw: bytes, *, name: str) -> None: + expected = { + "mcp-audit": "mcp_audit.cli:main", + "mcp-audits": "mcp_audit.cli:main", + "proof-before-action": "mcp_audit.proof_cli:main", + } + observed: dict[str, str] = {} + in_console_scripts = False + for line in raw.decode("utf-8").splitlines(): + if line.startswith("["): + in_console_scripts = line.strip() == "[console_scripts]" + continue + if in_console_scripts and "=" in line: + command, target = line.split("=", maxsplit=1) + observed[command.strip()] = target.strip() + if observed != expected: + raise VerificationError(f"{name} console entry points do not match the release contract") + + def verify_distributions(*, dist_dir: Path, version: str, commit: str) -> list[tuple[str, str]]: wheel = dist_dir / f"mcp_audits-{version}-py3-none-any.whl" sdist = dist_dir / f"mcp_audits-{version}.tar.gz" @@ -157,6 +202,10 @@ def verify_distributions(*, dist_dir: Path, version: str, commit: str) -> list[t commit=commit, name=wheel.name, ) + _check_entry_points( + archive.read(f"mcp_audits-{version}.dist-info/entry_points.txt"), + name=wheel.name, + ) with tarfile.open(sdist, mode="r:gz") as archive: prefix = f"mcp_audits-{version}" metadata_file = archive.extractfile(f"{prefix}/PKG-INFO") @@ -165,6 +214,16 @@ def verify_distributions(*, dist_dir: Path, version: str, commit: str) -> list[t raise VerificationError("sdist metadata or provenance is missing") _check_distribution_metadata(metadata_file.read(), version=version, name=sdist.name) _check_provenance(provenance_file.read(), commit=commit, name=sdist.name) + pyproject_file = archive.extractfile(f"{prefix}/pyproject.toml") + if pyproject_file is None: + raise VerificationError("sdist pyproject.toml is missing") + scripts = tomllib.loads(pyproject_file.read().decode("utf-8")).get("project", {}).get("scripts") + if scripts != { + "mcp-audit": "mcp_audit.cli:main", + "mcp-audits": "mcp_audit.cli:main", + "proof-before-action": "mcp_audit.proof_cli:main", + }: + raise VerificationError("sdist console entry points do not match the release contract") return [(path.name, hashlib.sha256(path.read_bytes()).hexdigest()) for path in (wheel, sdist)] diff --git a/server.json b/server.json index 7652115..a610be8 100644 --- a/server.json +++ b/server.json @@ -7,13 +7,13 @@ "url": "https://github.com/saagpatel/MCPAudit", "source": "github" }, - "version": "2.5.0", + "version": "2.4.0", "packages": [ { "registryType": "pypi", "registryBaseUrl": "https://pypi.org", "identifier": "mcp-audits", - "version": "2.5.0", + "version": "2.4.0", "runtimeHint": "uvx", "transport": { "type": "stdio" }, "packageArguments": [{ "type": "positional", "value": "serve" }] diff --git a/src/mcp_audit/proof_cli.py b/src/mcp_audit/proof_cli.py index 8c6fe62..7b9ffd0 100644 --- a/src/mcp_audit/proof_cli.py +++ b/src/mcp_audit/proof_cli.py @@ -27,6 +27,7 @@ @click.group() +@click.version_option(package_name="mcp-audits", prog_name="proof-before-action") def main() -> None: """Observe first, compare with declared limits, then emit portable evidence.""" diff --git a/tests/test_proof_before_action.py b/tests/test_proof_before_action.py index edd9449..05e9f6a 100644 --- a/tests/test_proof_before_action.py +++ b/tests/test_proof_before_action.py @@ -2300,6 +2300,13 @@ def test_schema_cli_emits_the_strict_versioned_contract() -> None: assert schema["additionalProperties"] is False +def test_cli_version_reports_installed_distribution_version() -> None: + result = CliRunner().invoke(main, ["--version"]) + + assert result.exit_code == 0 + assert "proof-before-action, version 2.5.0" in result.output + + def test_capsule_index_rejects_path_expansion() -> None: from pydantic import ValidationError diff --git a/tests/test_release_metadata.py b/tests/test_release_metadata.py index 810dfa8..df888fa 100644 --- a/tests/test_release_metadata.py +++ b/tests/test_release_metadata.py @@ -4,12 +4,19 @@ import json import re +import runpy import subprocess import tomllib from pathlib import Path +from typing import Any import pytest +RELEASE_VERIFIER: dict[str, Any] = runpy.run_path( + "scripts/verify_release.py", + run_name="release_verifier_test", +) + def _project_version() -> str: project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"] @@ -30,10 +37,11 @@ def test_release_version_is_consistent_across_public_surfaces() -> None: "schema_version": "mcp-audit.release-state.v1", "candidate_version": version, "published_version": "2.4.0", + "previous_version": "2.4.0", "status": "candidate", } - assert server["version"] == version - assert server["packages"][0]["version"] == version + assert server["version"] == state["published_version"] + assert server["packages"][0]["version"] == state["published_version"] assert f"## [{version}] - Unreleased" in changelog assert f"[{version}]: https://github.com/saagpatel/MCPAudit/compare/" in changelog assert "saagpatel/MCPAudit@v2.4.0" in readme @@ -79,6 +87,47 @@ def test_candidate_metadata_verifier_fails_closed( assert message in result.stderr +def test_release_state_cannot_keep_a_stale_published_version( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setitem( + RELEASE_VERIFIER["verify_metadata"].__globals__, + "_release_state", + lambda: { + "schema_version": "mcp-audit.release-state.v1", + "candidate_version": "2.5.0", + "published_version": "2.4.0", + "previous_version": "2.4.0", + "status": "release", + }, + ) + + with pytest.raises( + RELEASE_VERIFIER["VerificationError"], + match="published_version to equal the candidate", + ): + RELEASE_VERIFIER["verify_metadata"](require_publishable=True) + + +def test_release_entry_points_are_exact() -> None: + RELEASE_VERIFIER["_check_entry_points"]( + b"""[console_scripts] +mcp-audit = mcp_audit.cli:main +mcp-audits = mcp_audit.cli:main +proof-before-action = mcp_audit.proof_cli:main +""", + name="fixture.whl", + ) + with pytest.raises( + RELEASE_VERIFIER["VerificationError"], + match="console entry points", + ): + RELEASE_VERIFIER["_check_entry_points"]( + b"[console_scripts]\nmcp-audit = mcp_audit.cli:main\n", + name="fixture.whl", + ) + + def test_publication_requires_a_separate_manual_dispatch() -> None: workflow = Path(".github/workflows/publish.yml").read_text(encoding="utf-8") From 65fe5519afccb3ab1e2013b1a6bb1aa20c577f84 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 12:44:12 -0700 Subject: [PATCH 4/9] fix verify candidate artifacts before install --- docs/RELEASE-CHECKLIST.md | 4 ++++ tests/test_release_metadata.py | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/docs/RELEASE-CHECKLIST.md b/docs/RELEASE-CHECKLIST.md index 9f1a489..01a7f86 100644 --- a/docs/RELEASE-CHECKLIST.md +++ b/docs/RELEASE-CHECKLIST.md @@ -14,7 +14,11 @@ uv run ruff format --check git diff --check uv lock --check uv run python scripts/verify_release.py +candidate_commit="$(git rev-parse HEAD)" uv build --clear +uv run python scripts/verify_release.py \ + --commit "$candidate_commit" \ + --dist-dir dist ``` Remove generated `dist/` artifacts after the build check unless the release is diff --git a/tests/test_release_metadata.py b/tests/test_release_metadata.py index df888fa..b1cd9cf 100644 --- a/tests/test_release_metadata.py +++ b/tests/test_release_metadata.py @@ -152,3 +152,15 @@ def test_oidc_authority_is_confined_to_post_build_publish_job() -> None: assert "id-token: write" in publish_job assert "sha256sum -c SHA256SUMS" in publish_job assert publish_job.index("sha256sum -c SHA256SUMS") < publish_job.index("pypa/gh-action-pypi-publish@") + + +def test_candidate_checklist_verifies_exact_built_artifacts_before_install() -> None: + checklist = Path("docs/RELEASE-CHECKLIST.md").read_text(encoding="utf-8") + + build = checklist.index("uv build --clear") + verify_artifacts = checklist.index("--dist-dir dist") + install_wheel = checklist.index("uv pip install") + + assert 'candidate_commit="$(git rev-parse HEAD)"' in checklist + assert '--commit "$candidate_commit"' in checklist + assert build < verify_artifacts < install_wheel From fccd80b462aceae0d309f33676e58f3091e50b3b Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 13:08:33 -0700 Subject: [PATCH 5/9] fix require independent publication approval --- .github/workflows/publish.yml | 33 ++++++++++++++--- docs/2.5-RELEASE-NOTES.md | 2 + docs/RELEASE-CHECKLIST.md | 12 +++--- scripts/verify_release.py | 59 +++++++++++++++++++++++++----- tests/test_release_metadata.py | 67 +++++++++++++++++++++++++++++++++- 5 files changed, 150 insertions(+), 23 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 25e7cd1..29631b6 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -11,10 +11,6 @@ on: description: Exact 40-character commit recorded in the release approval required: true type: string - approval: - description: Type publish-mcp-audits to confirm this separately authorized publish - required: true - type: string permissions: contents: read @@ -34,9 +30,16 @@ jobs: with: python-version: "3.11" + - name: Read back protected PyPI environment + run: | + curl --fail --location --silent --show-error \ + --header "Accept: application/vnd.github+json" \ + --header "X-GitHub-Api-Version: 2026-03-10" \ + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/environments/pypi" \ + --output "$RUNNER_TEMP/pypi-environment.json" + - name: Verify release authorization binding env: - RELEASE_APPROVAL: ${{ inputs.approval }} RELEASE_COMMIT: ${{ inputs.commit }} RELEASE_TAG: ${{ inputs.tag }} run: | @@ -44,7 +47,7 @@ jobs: uv run python scripts/verify_release.py \ --tag "$RELEASE_TAG" \ --commit "$RELEASE_COMMIT" \ - --approval-token "$RELEASE_APPROVAL" \ + --environment-json "$RUNNER_TEMP/pypi-environment.json" \ --require-publishable - name: Install locked dependencies @@ -70,6 +73,7 @@ jobs: uv run python scripts/verify_release.py \ --tag "$RELEASE_TAG" \ --commit "$RELEASE_COMMIT" \ + --environment-json "$RUNNER_TEMP/pypi-environment.json" \ --require-publishable \ --dist-dir dist sha256sum dist/* | tee SHA256SUMS @@ -95,6 +99,23 @@ jobs: id-token: write # OIDC trusted publishing steps: + - name: Verify protected PyPI environment is still enforced + run: | + curl --fail --location --silent --show-error \ + --header "Accept: application/vnd.github+json" \ + --header "X-GitHub-Api-Version: 2026-03-10" \ + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/environments/pypi" \ + --output "$RUNNER_TEMP/pypi-environment.json" + jq --exit-status ' + .can_admins_bypass == false + and any( + .protection_rules[]; + .type == "required_reviewers" + and .prevent_self_review == true + and (.reviewers | length) > 0 + ) + ' "$RUNNER_TEMP/pypi-environment.json" + - name: Download exact candidate artifacts uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: diff --git a/docs/2.5-RELEASE-NOTES.md b/docs/2.5-RELEASE-NOTES.md index 608a8f8..d83f0d9 100644 --- a/docs/2.5-RELEASE-NOTES.md +++ b/docs/2.5-RELEASE-NOTES.md @@ -1,5 +1,7 @@ # MCPAudit 2.5.0 Release Boundary +Release status: candidate + MCPAudit 2.5.0 is a backward-compatible minor release. It packages the capabilities added since 2.4.0 and the dependency and workflow hardening already landed on `main`. It does not change the existing 2.x audit-report or SARIF diff --git a/docs/RELEASE-CHECKLIST.md b/docs/RELEASE-CHECKLIST.md index 01a7f86..3c86c71 100644 --- a/docs/RELEASE-CHECKLIST.md +++ b/docs/RELEASE-CHECKLIST.md @@ -101,12 +101,14 @@ publication. 1. Obtain separate publication approval naming the exact 40-character merge commit and `vX.Y.Z` tag. Confirm the `pypi` environment requires an - independent reviewer; otherwise stop with `NO-GO`. + independent reviewer, prevents self-review, and disables administrator + bypass; otherwise stop with `NO-GO`. 2. Create the tag only after that approval. Tag creation does not publish. -3. Manually dispatch `Publish to PyPI` with the exact tag, commit, and - `publish-mcp-audits` confirmation. The workflow rechecks the tag/commit/main - binding, release-state gate, lockfile, tests, style, types, package metadata, - and clean build provenance. +3. Manually dispatch `Publish to PyPI` with the exact tag and commit. A typed + confirmation is not authorization. The workflow reads back the live + environment protections and rechecks the tag/commit/main binding, + release-state gate, lockfile, tests, style, types, package metadata, and + clean build provenance. 4. Review the build job's wheel and sdist SHA-256 values before approving the protected `publish` job. That job downloads the exact retained artifact, verifies its hashes, and only then requests PyPI OIDC authority. diff --git a/scripts/verify_release.py b/scripts/verify_release.py index b2defd7..9402be0 100644 --- a/scripts/verify_release.py +++ b/scripts/verify_release.py @@ -17,7 +17,6 @@ ROOT = Path(__file__).resolve().parents[1] RELEASE_STATE_PATH = ROOT / "docs/release-state.json" -EXPECTED_APPROVAL = "publish-mcp-audits" VERSION_RE = re.compile(r"[0-9]+\.[0-9]+\.[0-9]+") COMMIT_RE = re.compile(r"[0-9a-f]{40}") @@ -65,6 +64,36 @@ def _run_git(*args: str) -> str: return result.stdout.strip() +def _check_release_notes(raw: str, *, version: str, status: str) -> None: + if f"MCPAudit {version}" not in raw: + raise VerificationError("versioned release notes are missing or mismatched") + marker = "Release status: candidate" if status == "candidate" else "Release status: approved" + if marker not in raw: + raise VerificationError(f"release notes do not declare {status} status") + if status == "release" and ("`NO-GO`" in raw or "does not authorize" in raw): + raise VerificationError("release notes still contain candidate-only publication language") + + +def verify_environment_protection(raw: object) -> None: + if not isinstance(raw, dict): + raise VerificationError("PyPI environment response must be an object") + if raw.get("can_admins_bypass") is not False: + raise VerificationError("PyPI environment permits administrator bypass") + rules = raw.get("protection_rules") + if not isinstance(rules, list): + raise VerificationError("PyPI environment protection rules are unavailable") + for rule in rules: + if ( + isinstance(rule, dict) + and rule.get("type") == "required_reviewers" + and rule.get("prevent_self_review") is True + and isinstance(rule.get("reviewers"), list) + and len(rule["reviewers"]) > 0 + ): + return + raise VerificationError("PyPI environment requires a reviewer with self-review prevention") + + def verify_metadata(*, require_publishable: bool) -> tuple[str, dict[str, object]]: version = _version() state = _release_state() @@ -80,9 +109,10 @@ def verify_metadata(*, require_publishable: bool) -> tuple[str, dict[str, object published = state.get("published_version") if not isinstance(published, str) or VERSION_RE.fullmatch(published) is None: raise VerificationError("published version is invalid") - status = state.get("status") - if status not in {"candidate", "release"}: + status_value = state.get("status") + if not isinstance(status_value, str) or status_value not in {"candidate", "release"}: raise VerificationError("release status must be candidate or release") + status = status_value if status == "release" and published != version: raise VerificationError("release status requires published_version to equal the candidate") public_version = version if status == "release" else published @@ -101,8 +131,13 @@ def verify_metadata(*, require_publishable: bool) -> tuple[str, dict[str, object if not isinstance(dependencies, list) or "mcp>=1.28.1" not in dependencies: raise VerificationError("project metadata does not retain the mcp>=1.28.1 security floor") release_notes = ROOT / f"docs/{version.rsplit('.', maxsplit=1)[0]}-RELEASE-NOTES.md" - if not release_notes.is_file() or f"MCPAudit {version}" not in release_notes.read_text(encoding="utf-8"): - raise VerificationError("versioned release notes are missing or mismatched") + if not release_notes.is_file(): + raise VerificationError("versioned release notes are missing") + _check_release_notes( + release_notes.read_text(encoding="utf-8"), + version=version, + status=status, + ) if status == "candidate": if f"## [{version}] - Unreleased" not in changelog: raise VerificationError("candidate changelog section is not explicitly unreleased") @@ -231,7 +266,11 @@ def _parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--tag", help="exact v-prefixed release tag") parser.add_argument("--commit", help="exact 40-character approved commit") - parser.add_argument("--approval-token") + parser.add_argument( + "--environment-json", + type=Path, + help="live GitHub response for the protected PyPI environment", + ) parser.add_argument("--require-publishable", action="store_true") parser.add_argument("--dist-dir", type=Path) return parser @@ -245,16 +284,16 @@ def main() -> int: raise VerificationError("--tag requires --commit") if args.require_publishable and (args.tag is None or args.commit is None): raise VerificationError("publish verification requires --tag and --commit") + if args.require_publishable and args.environment_json is None: + raise VerificationError("publish verification requires live PyPI environment state") if args.commit is not None: verify_git_binding( tag=args.tag, commit=args.commit, require_landed=args.require_publishable, ) - if args.approval_token is not None and args.approval_token != EXPECTED_APPROVAL: - raise VerificationError("publication approval token is invalid") - if args.require_publishable and args.approval_token is None and args.dist_dir is None: - raise VerificationError("initial publish verification requires an approval token") + if args.environment_json is not None: + verify_environment_protection(json.loads(args.environment_json.read_text(encoding="utf-8"))) hashes: list[tuple[str, str]] = [] if args.dist_dir is not None: if args.commit is None: diff --git a/tests/test_release_metadata.py b/tests/test_release_metadata.py index b1cd9cf..0d2258d 100644 --- a/tests/test_release_metadata.py +++ b/tests/test_release_metadata.py @@ -69,7 +69,6 @@ def test_candidate_metadata_verifier_passes() -> None: ("arguments", "message"), [ (["--require-publishable"], "candidate state is intentionally non-publishable"), - (["--approval-token", "wrong"], "publication approval token is invalid"), ], ) def test_candidate_metadata_verifier_fails_closed( @@ -128,6 +127,62 @@ def test_release_entry_points_are_exact() -> None: ) +def test_release_notes_must_be_finalized_before_publication() -> None: + check = RELEASE_VERIFIER["_check_release_notes"] + + with pytest.raises( + RELEASE_VERIFIER["VerificationError"], + match="declare release status", + ): + check("# MCPAudit 2.5.0\n", version="2.5.0", status="release") + with pytest.raises( + RELEASE_VERIFIER["VerificationError"], + match="candidate-only publication language", + ): + check( + "# MCPAudit 2.5.0\n\nRelease status: approved\n\nPublic release remains `NO-GO`.\n", + version="2.5.0", + status="release", + ) + check( + "# MCPAudit 2.5.0\n\nRelease status: approved\n", + version="2.5.0", + status="release", + ) + + +def test_pypi_environment_requires_independent_non_bypassable_review() -> None: + verify = RELEASE_VERIFIER["verify_environment_protection"] + protected: dict[str, Any] = { + "can_admins_bypass": False, + "protection_rules": [ + { + "type": "required_reviewers", + "prevent_self_review": True, + "reviewers": [{"type": "User", "reviewer": {"login": "reviewer"}}], + } + ], + } + + verify(protected) + for unsafe in ( + {**protected, "can_admins_bypass": True}, + {**protected, "protection_rules": []}, + { + **protected, + "protection_rules": [ + { + "type": "required_reviewers", + "prevent_self_review": False, + "reviewers": protected["protection_rules"][0]["reviewers"], + } + ], + }, + ): + with pytest.raises(RELEASE_VERIFIER["VerificationError"]): + verify(unsafe) + + def test_publication_requires_a_separate_manual_dispatch() -> None: workflow = Path(".github/workflows/publish.yml").read_text(encoding="utf-8") @@ -136,7 +191,8 @@ def test_publication_requires_a_separate_manual_dispatch() -> None: assert "\n push:" not in trigger assert "commit:" in trigger assert "tag:" in trigger - assert "approval:" in trigger + assert "approval:" not in trigger + assert "publish-mcp-audits" not in workflow def test_oidc_authority_is_confined_to_post_build_publish_job() -> None: @@ -150,7 +206,14 @@ def test_oidc_authority_is_confined_to_post_build_publish_job() -> None: assert "needs: build" in publish_job assert "environment: pypi" in publish_job assert "id-token: write" in publish_job + assert "$RUNNER_TEMP/pypi-environment.json" in workflow + assert ".can_admins_bypass == false" in publish_job + assert ".prevent_self_review == true" in publish_job + assert "required_reviewers" in publish_job assert "sha256sum -c SHA256SUMS" in publish_job + assert publish_job.index("Verify protected PyPI environment") < publish_job.index( + "sha256sum -c SHA256SUMS" + ) assert publish_job.index("sha256sum -c SHA256SUMS") < publish_job.index("pypa/gh-action-pypi-publish@") From 2db2cada388e4897927e80c2d7a48081302662ee Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 13:25:39 -0700 Subject: [PATCH 6/9] fix reject conflicting release status --- docs/2.5-RELEASE-NOTES.md | 1 + docs/RELEASE-CHECKLIST.md | 6 ++++-- scripts/verify_release.py | 19 ++++++++++++++++--- tests/test_release_metadata.py | 21 ++++++++++++++++++--- 4 files changed, 39 insertions(+), 8 deletions(-) diff --git a/docs/2.5-RELEASE-NOTES.md b/docs/2.5-RELEASE-NOTES.md index d83f0d9..8afb786 100644 --- a/docs/2.5-RELEASE-NOTES.md +++ b/docs/2.5-RELEASE-NOTES.md @@ -1,6 +1,7 @@ # MCPAudit 2.5.0 Release Boundary Release status: candidate +Publication decision: NO-GO MCPAudit 2.5.0 is a backward-compatible minor release. It packages the capabilities added since 2.4.0 and the dependency and workflow hardening already diff --git a/docs/RELEASE-CHECKLIST.md b/docs/RELEASE-CHECKLIST.md index 3c86c71..737778c 100644 --- a/docs/RELEASE-CHECKLIST.md +++ b/docs/RELEASE-CHECKLIST.md @@ -90,9 +90,11 @@ Use a separate reviewed PR after the candidate has landed: `published_version` to the candidate version. 2. Update `server.json`, README Action examples, and pre-commit examples to the new public version/tag. -3. Replace `Unreleased` with the release date in `CHANGELOG.md` and finalize its +3. Change the versioned release-note markers to `Release status: approved` and + `Publication decision: GO`; remove candidate-only authorization language. +4. Replace `Unreleased` with the release date in `CHANGELOG.md` and finalize its comparison links. -4. Rerun the full local, security, metadata, build, and installed-command gates. +5. Rerun the full local, security, metadata, build, and installed-command gates. Merging a candidate or release-state PR does not authorize tagging or publication. diff --git a/scripts/verify_release.py b/scripts/verify_release.py index 9402be0..f565c91 100644 --- a/scripts/verify_release.py +++ b/scripts/verify_release.py @@ -67,9 +67,22 @@ def _run_git(*args: str) -> str: def _check_release_notes(raw: str, *, version: str, status: str) -> None: if f"MCPAudit {version}" not in raw: raise VerificationError("versioned release notes are missing or mismatched") - marker = "Release status: candidate" if status == "candidate" else "Release status: approved" - if marker not in raw: - raise VerificationError(f"release notes do not declare {status} status") + expected_status = "candidate" if status == "candidate" else "approved" + expected_decision = "NO-GO" if status == "candidate" else "GO" + status_markers = re.findall( + r"^Release status: (candidate|approved)$", + raw, + re.MULTILINE, + ) + decision_markers = re.findall( + r"^Publication decision: (GO|NO-GO)$", + raw, + re.MULTILINE, + ) + if status_markers != [expected_status]: + raise VerificationError("release notes have a missing or conflicting status marker") + if decision_markers != [expected_decision]: + raise VerificationError("release notes have a missing or conflicting publication decision") if status == "release" and ("`NO-GO`" in raw or "does not authorize" in raw): raise VerificationError("release notes still contain candidate-only publication language") diff --git a/tests/test_release_metadata.py b/tests/test_release_metadata.py index 0d2258d..53a40cf 100644 --- a/tests/test_release_metadata.py +++ b/tests/test_release_metadata.py @@ -132,20 +132,35 @@ def test_release_notes_must_be_finalized_before_publication() -> None: with pytest.raises( RELEASE_VERIFIER["VerificationError"], - match="declare release status", + match="status marker", ): check("# MCPAudit 2.5.0\n", version="2.5.0", status="release") + with pytest.raises( + RELEASE_VERIFIER["VerificationError"], + match="status marker", + ): + check( + "# MCPAudit 2.5.0\n\n" + "Release status: candidate\n" + "Release status: approved\n" + "Publication decision: GO\n", + version="2.5.0", + status="release", + ) with pytest.raises( RELEASE_VERIFIER["VerificationError"], match="candidate-only publication language", ): check( - "# MCPAudit 2.5.0\n\nRelease status: approved\n\nPublic release remains `NO-GO`.\n", + "# MCPAudit 2.5.0\n\n" + "Release status: approved\n" + "Publication decision: GO\n\n" + "Public release remains `NO-GO`.\n", version="2.5.0", status="release", ) check( - "# MCPAudit 2.5.0\n\nRelease status: approved\n", + "# MCPAudit 2.5.0\n\nRelease status: approved\nPublication decision: GO\n", version="2.5.0", status="release", ) From e0cb2777ce41be416f9f664c744cf5c60ab7dc3b Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 13:42:25 -0700 Subject: [PATCH 7/9] fix bind publish dispatch to main --- .github/workflows/publish.yml | 2 ++ tests/test_release_metadata.py | 3 +++ 2 files changed, 5 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 29631b6..aedbb57 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -18,6 +18,7 @@ permissions: jobs: build: name: Verify and build exact release + if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest steps: @@ -92,6 +93,7 @@ jobs: publish: name: Publish approved artifacts to PyPI needs: build + if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest environment: pypi permissions: diff --git a/tests/test_release_metadata.py b/tests/test_release_metadata.py index 53a40cf..9773e52 100644 --- a/tests/test_release_metadata.py +++ b/tests/test_release_metadata.py @@ -202,12 +202,15 @@ def test_publication_requires_a_separate_manual_dispatch() -> None: workflow = Path(".github/workflows/publish.yml").read_text(encoding="utf-8") trigger = workflow.split("\npermissions:", maxsplit=1)[0] + build_job, publish_job = workflow.split("\n publish:\n", maxsplit=1) assert "workflow_dispatch:" in trigger assert "\n push:" not in trigger assert "commit:" in trigger assert "tag:" in trigger assert "approval:" not in trigger assert "publish-mcp-audits" not in workflow + assert "if: github.ref == 'refs/heads/main'" in build_job + assert "if: github.ref == 'refs/heads/main'" in publish_job def test_oidc_authority_is_confined_to_post_build_publish_job() -> None: From aa0144d22b86b4c58f345f2460cf330beb9af8a5 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 14:00:55 -0700 Subject: [PATCH 8/9] fix fail non-main publish dispatch --- .github/workflows/publish.yml | 13 +++++++++++-- docs/RELEASE-CHECKLIST.md | 5 +++-- tests/test_release_metadata.py | 7 ++++--- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index aedbb57..8cba811 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -16,9 +16,19 @@ permissions: contents: read jobs: + validate-dispatch-ref: + name: Refuse non-main workflow definitions + runs-on: ubuntu-latest + steps: + - name: Require workflow dispatch from main + env: + DISPATCH_REF: ${{ github.ref }} + run: | + test "$DISPATCH_REF" = "refs/heads/main" + build: name: Verify and build exact release - if: github.ref == 'refs/heads/main' + needs: validate-dispatch-ref runs-on: ubuntu-latest steps: @@ -93,7 +103,6 @@ jobs: publish: name: Publish approved artifacts to PyPI needs: build - if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest environment: pypi permissions: diff --git a/docs/RELEASE-CHECKLIST.md b/docs/RELEASE-CHECKLIST.md index 737778c..dacdf32 100644 --- a/docs/RELEASE-CHECKLIST.md +++ b/docs/RELEASE-CHECKLIST.md @@ -106,8 +106,9 @@ publication. independent reviewer, prevents self-review, and disables administrator bypass; otherwise stop with `NO-GO`. 2. Create the tag only after that approval. Tag creation does not publish. -3. Manually dispatch `Publish to PyPI` with the exact tag and commit. A typed - confirmation is not authorization. The workflow reads back the live +3. From the `main` branch, manually dispatch `Publish to PyPI` with the exact + tag and commit. A tag or feature-branch dispatch fails before the build. A + typed confirmation is not authorization. The workflow reads back the live environment protections and rechecks the tag/commit/main binding, release-state gate, lockfile, tests, style, types, package metadata, and clean build provenance. diff --git a/tests/test_release_metadata.py b/tests/test_release_metadata.py index 9773e52..adbdf78 100644 --- a/tests/test_release_metadata.py +++ b/tests/test_release_metadata.py @@ -202,15 +202,16 @@ def test_publication_requires_a_separate_manual_dispatch() -> None: workflow = Path(".github/workflows/publish.yml").read_text(encoding="utf-8") trigger = workflow.split("\npermissions:", maxsplit=1)[0] - build_job, publish_job = workflow.split("\n publish:\n", maxsplit=1) + validation_job, build_job = workflow.split("\n build:\n", maxsplit=1) assert "workflow_dispatch:" in trigger assert "\n push:" not in trigger assert "commit:" in trigger assert "tag:" in trigger assert "approval:" not in trigger assert "publish-mcp-audits" not in workflow - assert "if: github.ref == 'refs/heads/main'" in build_job - assert "if: github.ref == 'refs/heads/main'" in publish_job + assert "validate-dispatch-ref:" in validation_job + assert 'test "$DISPATCH_REF" = "refs/heads/main"' in validation_job + assert "needs: validate-dispatch-ref" in build_job def test_oidc_authority_is_confined_to_post_build_publish_job() -> None: From 6cc0cafbc9c081bfeb876185f3d6aa7e7461db9e Mon Sep 17 00:00:00 2001 From: saagpatel Date: Sun, 19 Jul 2026 14:17:06 -0700 Subject: [PATCH 9/9] fix authenticate environment protection readback --- .github/workflows/publish.yml | 8 ++++++++ tests/test_release_metadata.py | 3 +++ 2 files changed, 11 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8cba811..1d96c71 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -13,6 +13,7 @@ on: type: string permissions: + actions: read contents: read jobs: @@ -42,9 +43,12 @@ jobs: python-version: "3.11" - name: Read back protected PyPI environment + env: + GH_TOKEN: ${{ github.token }} run: | curl --fail --location --silent --show-error \ --header "Accept: application/vnd.github+json" \ + --header "Authorization: Bearer $GH_TOKEN" \ --header "X-GitHub-Api-Version: 2026-03-10" \ "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/environments/pypi" \ --output "$RUNNER_TEMP/pypi-environment.json" @@ -106,14 +110,18 @@ jobs: runs-on: ubuntu-latest environment: pypi permissions: + actions: read contents: read id-token: write # OIDC trusted publishing steps: - name: Verify protected PyPI environment is still enforced + env: + GH_TOKEN: ${{ github.token }} run: | curl --fail --location --silent --show-error \ --header "Accept: application/vnd.github+json" \ + --header "Authorization: Bearer $GH_TOKEN" \ --header "X-GitHub-Api-Version: 2026-03-10" \ "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/environments/pypi" \ --output "$RUNNER_TEMP/pypi-environment.json" diff --git a/tests/test_release_metadata.py b/tests/test_release_metadata.py index adbdf78..ef72c07 100644 --- a/tests/test_release_metadata.py +++ b/tests/test_release_metadata.py @@ -224,6 +224,9 @@ def test_oidc_authority_is_confined_to_post_build_publish_job() -> None: assert "actions/upload-artifact@" in build_job assert "needs: build" in publish_job assert "environment: pypi" in publish_job + assert workflow.count("actions: read") == 2 + assert workflow.count("GH_TOKEN: ${{ github.token }}") == 2 + assert workflow.count("Authorization: Bearer $GH_TOKEN") == 2 assert "id-token: write" in publish_job assert "$RUNNER_TEMP/pypi-environment.json" in workflow assert ".can_admins_bypass == false" in publish_job