From 9b6f75ae14206580edc133f40602e9eb2608da74 Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 20 Aug 2026 11:58:51 -0400 Subject: [PATCH 1/5] ci: bind release registry parity --- .github/workflows/release.yml | 93 ++++- docs/DISTRIBUTION.md | 17 +- ...-lifecycle-admission-candidate.schema.json | 138 ++++++ scripts/verify_release_registries.py | 392 ++++++++++++++++++ tests/test_release_registries.py | 232 +++++++++++ 5 files changed, 866 insertions(+), 6 deletions(-) create mode 100644 schemas/production-lifecycle-admission-candidate.schema.json create mode 100644 scripts/verify_release_registries.py create mode 100644 tests/test_release_registries.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 937bb6e..8f73a0e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,9 +27,12 @@ on: - "src/openadapt_agent/mcpb_entry.py" - "scripts/check_mcpb.py" - "scripts/check_release_artifacts.py" + - "scripts/verify_release_registries.py" + - "schemas/production-lifecycle-admission-candidate.schema.json" - "scripts/check_dist.py" - "scripts/check_source_boundary.py" - "source-policy.public.json" + - "tests/test_release_registries.py" - ".github/workflows/release.yml" concurrency: @@ -57,7 +60,7 @@ jobs: python -m pip install --upgrade build "twine>=6.1" "packaging>=24.2" jsonschema python -m pip install -e ".[dev]" - name: Version-consistency guard (registry / MCPB / package / runtime) - run: python -m pytest tests/test_distribution.py -q + run: python -m pytest tests/test_distribution.py tests/test_release_registries.py -q - name: Build sdist + wheel run: python -m build - name: License and source-policy checks on built archives @@ -180,8 +183,19 @@ jobs: shell: bash run: | set -euo pipefail + if [ "${{ github.event_name }}" = "release" ]; then + tag="${{ github.event.release.tag_name }}" + else + tag="${GITHUB_REF#refs/tags/}" + fi pkg_version="$(grep -m1 -E '^version = ' pyproject.toml | sed -E 's/version = "(.*)"/\1/')" + if [ "${tag#v}" != "${pkg_version}" ]; then + echo "::error::Release tag '${tag}' does not match package version '${pkg_version}'." >&2 + exit 1 + fi echo "version=${pkg_version}" >> "$GITHUB_OUTPUT" + echo "tag=${tag}" >> "$GITHUB_OUTPUT" + echo "source_commit=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - name: Wait for the PyPI package to be installable shell: bash run: | @@ -205,10 +219,15 @@ jobs: shell: bash run: | set -euo pipefail - os="$(uname -s | tr '[:upper:]' '[:lower:]')" - arch="$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')" - curl -L "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_${os}_${arch}.tar.gz" \ - | tar xz mcp-publisher + publisher_version="1.8.1" + archive="mcp-publisher_linux_amd64.tar.gz" + expected_sha256="a06c9096dcb9727c13555b6be26c7effa707b01f06a4c561ba7a3635443cf2cc" + curl --fail --location --retry 3 --retry-delay 2 \ + --output "${archive}" \ + "https://github.com/modelcontextprotocol/registry/releases/download/v${publisher_version}/${archive}" + printf '%s %s\n' "${expected_sha256}" "${archive}" | sha256sum --check --strict + tar xzf "${archive}" mcp-publisher + ./mcp-publisher --version - name: Fail loud if token method selected without a token if: ${{ vars.MCP_PUBLISH_METHOD == 'token' }} env: @@ -228,3 +247,67 @@ jobs: run: ./mcp-publisher login github --token "${{ secrets.MCP_GITHUB_TOKEN }}" - name: Publish server.json run: ./mcp-publisher publish + + registry-parity: + name: Verify registries + retain admission input + needs: [validate, mcp-registry-publish] + if: ${{ (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || github.event_name == 'release' }} + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Resolve the exact release identity + id: ver + shell: bash + run: | + set -euo pipefail + if [ "${{ github.event_name }}" = "release" ]; then + tag="${{ github.event.release.tag_name }}" + else + tag="${GITHUB_REF#refs/tags/}" + fi + pkg_version="$(grep -m1 -E '^version = ' pyproject.toml | sed -E 's/version = "(.*)"/\1/')" + if [ "${tag#v}" != "${pkg_version}" ]; then + echo "::error::Release tag '${tag}' does not match package version '${pkg_version}'." >&2 + exit 1 + fi + echo "version=${pkg_version}" >> "$GITHUB_OUTPUT" + echo "tag=${tag}" >> "$GITHUB_OUTPUT" + echo "source_commit=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + - name: Download the exact published Python distributions + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: dist + path: dist/ + - name: Checkout the canonical lifecycle policy + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: OpenAdaptAI/.github + ref: main + path: .production-lifecycle + persist-credentials: false + - name: Verify PyPI and MCP registry parity; write an unadmitted candidate + shell: bash + run: | + set -euo pipefail + lifecycle_commit="$(git -C .production-lifecycle rev-parse HEAD)" + python scripts/verify_release_registries.py \ + --dist dist \ + --server-json server.json \ + --version "${{ steps.ver.outputs.version }}" \ + --tag "${{ steps.ver.outputs.tag }}" \ + --source-commit "${{ steps.ver.outputs.source_commit }}" \ + --lifecycle-policy .production-lifecycle/production-lifecycle-policy.json \ + --lifecycle-source-commit "${lifecycle_commit}" \ + --output release-metadata/production-admission-candidate.json \ + --attempts 20 \ + --retry-seconds 15 + sha256sum release-metadata/production-admission-candidate.json + - name: Retain the content-bound admission candidate + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: production-admission-candidate-${{ steps.ver.outputs.version }} + path: release-metadata/production-admission-candidate.json + if-no-files-found: error + retention-days: 30 diff --git a/docs/DISTRIBUTION.md b/docs/DISTRIBUTION.md index 64f208a..839a191 100644 --- a/docs/DISTRIBUTION.md +++ b/docs/DISTRIBUTION.md @@ -104,7 +104,22 @@ except on a deliberate version tag / GitHub Release. to be live on PyPI (the registry validates package existence), then `mcp-publisher login github-oidc` + `mcp-publisher publish` — no token, because the repo lives under the `OpenAdaptAI` org that owns the - `io.github.OpenAdaptAI` namespace. + `io.github.OpenAdaptAI` namespace. The workflow downloads a fixed + `mcp-publisher` version and verifies its SHA-256 before execution. +- **Registry parity and admission input** run after both publishes. The + workflow downloads each PyPI artifact and proves byte-for-byte equality + with the archives produced by the protected build. It also proves that the + exact and `latest` MCP registry records equal the reviewed `server.json`. + Only then does it retain a 30-day + `production-admission-candidate-` artifact. That record binds the + source commit, artifact hashes, both public registry observations, and one + exact commit of the canonical lifecycle policy. + +The retained record is explicitly `not_admitted`. It has no admission ID, +release sequence, or Production channel selector. PyPI `latest` and MCP +`latest` are distribution checks only. They never grant Production status. +The active, signed ledger in `OpenAdaptAI/.github` is the sole Production +authority and requires its separate acceptance evidence and activation. To cut a release: bump the synchronized version fields (see §3.1), merge, then `git tag vX.Y.Z && git push origin vX.Y.Z` (or publish a Release with that diff --git a/schemas/production-lifecycle-admission-candidate.schema.json b/schemas/production-lifecycle-admission-candidate.schema.json new file mode 100644 index 0000000..a46e2fd --- /dev/null +++ b/schemas/production-lifecycle-admission-candidate.schema.json @@ -0,0 +1,138 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/OpenAdaptAI/openadapt-agent/blob/main/schemas/production-lifecycle-admission-candidate.schema.json", + "title": "OpenAdapt Agent Production admission candidate", + "description": "An exact release parity record and evidence input. This document is not a Production admission or selector.", + "type": "object", + "additionalProperties": false, + "required": [ + "$schema", + "schema_version", + "candidate_role", + "admission_status", + "production_authority", + "release", + "registry_parity", + "verified_at" + ], + "properties": { + "$schema": {"type": "string", "format": "uri"}, + "schema_version": {"const": "openadapt.production-lifecycle-admission-candidate/v1"}, + "candidate_role": {"const": "production_admission_input"}, + "admission_status": {"const": "not_admitted"}, + "production_authority": { + "type": "object", + "additionalProperties": false, + "required": [ + "repository", + "source_commit", + "policy_path", + "policy_sha256", + "policy_revision", + "target", + "claim_scope" + ], + "properties": { + "repository": {"const": "OpenAdaptAI/.github"}, + "source_commit": {"$ref": "#/$defs/commit"}, + "policy_path": {"const": "production-lifecycle-policy.json"}, + "policy_sha256": {"$ref": "#/$defs/digest"}, + "policy_revision": {"type": "integer", "minimum": 1}, + "target": {"const": "agent"}, + "claim_scope": {"const": "qualified_agent_bridge_release"} + } + }, + "release": {"$ref": "#/$defs/release"}, + "registry_parity": { + "type": "object", + "additionalProperties": false, + "required": ["pypi", "mcp"], + "properties": { + "pypi": {"$ref": "#/$defs/pypiParity"}, + "mcp": {"$ref": "#/$defs/mcpParity"} + } + }, + "verified_at": {"$ref": "#/$defs/timestamp"} + }, + "$defs": { + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "timestamp": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$" + }, + "artifact": { + "type": "object", + "additionalProperties": false, + "required": ["name", "kind", "authority", "url", "sha256", "size_bytes"], + "properties": { + "name": {"type": "string", "minLength": 1}, + "kind": {"enum": ["sdist", "wheel"]}, + "authority": {"const": "pypi"}, + "url": {"type": "string", "format": "uri", "pattern": "^https://files\\.pythonhosted\\.org/"}, + "sha256": {"$ref": "#/$defs/digest"}, + "size_bytes": {"type": "integer", "minimum": 1} + } + }, + "release": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "version", "tag", "source_commit", "immutable_release_url", "artifacts"], + "properties": { + "kind": {"const": "public_package"}, + "version": {"type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$"}, + "tag": {"type": "string", "minLength": 1}, + "source_commit": {"$ref": "#/$defs/commit"}, + "immutable_release_url": {"type": "string", "format": "uri"}, + "artifacts": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": {"$ref": "#/$defs/artifact"} + } + } + }, + "pypiParity": { + "type": "object", + "additionalProperties": false, + "required": [ + "project", + "version", + "version_metadata_url", + "version_metadata_sha256", + "latest_metadata_url", + "latest_metadata_sha256" + ], + "properties": { + "project": {"const": "openadapt-agent"}, + "version": {"type": "string", "minLength": 1}, + "version_metadata_url": {"type": "string", "format": "uri"}, + "version_metadata_sha256": {"$ref": "#/$defs/digest"}, + "latest_metadata_url": {"type": "string", "format": "uri"}, + "latest_metadata_sha256": {"$ref": "#/$defs/digest"} + } + }, + "mcpParity": { + "type": "object", + "additionalProperties": false, + "required": [ + "server_name", + "version", + "version_url", + "version_response_sha256", + "latest_url", + "latest_response_sha256", + "server_sha256" + ], + "properties": { + "server_name": {"const": "io.github.OpenAdaptAI/openadapt-agent"}, + "version": {"type": "string", "minLength": 1}, + "version_url": {"type": "string", "format": "uri"}, + "version_response_sha256": {"$ref": "#/$defs/digest"}, + "latest_url": {"type": "string", "format": "uri"}, + "latest_response_sha256": {"$ref": "#/$defs/digest"}, + "server_sha256": {"$ref": "#/$defs/digest"} + } + } + } +} diff --git a/scripts/verify_release_registries.py b/scripts/verify_release_registries.py new file mode 100644 index 0000000..90ee333 --- /dev/null +++ b/scripts/verify_release_registries.py @@ -0,0 +1,392 @@ +#!/usr/bin/env python3 +"""Verify exact public registry parity and emit an admission candidate. + +This script proves that PyPI and the official MCP registry expose the exact +release bytes and metadata. It then writes a content-bound input for the +canonical OpenAdapt Production lifecycle process. It does not select, sign, +or activate a Production release. +""" + +from __future__ import annotations + +import argparse +import copy +import hashlib +import json +import re +import time +import urllib.error +import urllib.request +from collections.abc import Callable, Mapping +from datetime import datetime, timezone +from pathlib import Path +from typing import Any +from urllib.parse import quote, urlsplit + +PYPI_PROJECT = "openadapt-agent" +MCP_SERVER_NAME = "io.github.OpenAdaptAI/openadapt-agent" +MCP_REGISTRY = "https://registry.modelcontextprotocol.io" +CENTRAL_REPOSITORY = "OpenAdaptAI/.github" +TARGET_ID = "agent" +CLAIM_SCOPE = "qualified_agent_bridge_release" +SEMVER = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$") +HEX40 = re.compile(r"^[0-9a-f]{40}$") +MAX_RESPONSE_BYTES = 64 * 1024 * 1024 + + +class ReleaseVerificationError(RuntimeError): + """The public release differs from its exact local candidate.""" + + +def _digest_bytes(value: bytes) -> str: + return "sha256:" + hashlib.sha256(value).hexdigest() + + +def _canonical_bytes(value: object) -> bytes: + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode( + "utf-8" + ) + + +def _load_object(path: Path, label: str) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ReleaseVerificationError(f"{label} is missing or invalid: {exc}") from exc + if not isinstance(value, dict): + raise ReleaseVerificationError(f"{label} must be a JSON object") + return value + + +def _fetch_url(url: str) -> bytes: + request = urllib.request.Request( + url, + headers={"User-Agent": "openadapt-agent-release-verifier/1"}, + ) + with urllib.request.urlopen(request, timeout=30) as response: + body = response.read(MAX_RESPONSE_BYTES + 1) + if len(body) > MAX_RESPONSE_BYTES: + raise ReleaseVerificationError(f"response exceeds size limit: {url}") + return body + + +def _fetch_object(url: str, fetch: Callable[[str], bytes]) -> tuple[dict[str, Any], bytes]: + try: + body = fetch(url) + value = json.loads(body) + except (OSError, urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc: + raise ReleaseVerificationError(f"could not fetch valid JSON from {url}: {exc}") from exc + if not isinstance(value, dict): + raise ReleaseVerificationError(f"registry response must be an object: {url}") + return value, body + + +def _artifact_kind(path: Path) -> str: + if path.suffix == ".whl": + return "wheel" + if path.name.endswith(".tar.gz"): + return "sdist" + raise ReleaseVerificationError(f"unsupported distribution artifact: {path.name}") + + +def _local_artifacts(dist: Path) -> list[dict[str, Any]]: + if not dist.is_dir(): + raise ReleaseVerificationError(f"distribution directory does not exist: {dist}") + paths = sorted([*dist.glob("*.whl"), *dist.glob("*.tar.gz")]) + artifacts = [ + { + "name": path.name, + "kind": _artifact_kind(path), + "sha256": _digest_bytes(path.read_bytes()), + "size_bytes": path.stat().st_size, + "path": path, + } + for path in paths + ] + kinds = [item["kind"] for item in artifacts] + if sorted(kinds) != ["sdist", "wheel"]: + raise ReleaseVerificationError("release must contain exactly one sdist and one wheel") + return sorted(artifacts, key=lambda item: (item["kind"], item["name"])) + + +def verify_pypi( + dist: Path, + version: str, + *, + fetch: Callable[[str], bytes] = _fetch_url, +) -> tuple[list[dict[str, Any]], dict[str, Any]]: + """Verify local bytes against the exact PyPI release and latest selector.""" + + local = _local_artifacts(dist) + version_url = f"https://pypi.org/pypi/{PYPI_PROJECT}/{quote(version, safe='')}/json" + latest_url = f"https://pypi.org/pypi/{PYPI_PROJECT}/json" + metadata, metadata_body = _fetch_object(version_url, fetch) + latest, latest_body = _fetch_object(latest_url, fetch) + if metadata.get("info", {}).get("version") != version: + raise ReleaseVerificationError("PyPI version metadata differs from the release") + if latest.get("info", {}).get("version") != version: + raise ReleaseVerificationError( + "PyPI latest is not the exact release; a newer candidate won the race" + ) + remote_files = metadata.get("urls") + if not isinstance(remote_files, list): + raise ReleaseVerificationError("PyPI release file inventory is invalid") + expected_names = {item["name"] for item in local} + actual_names = {item.get("filename") for item in remote_files if isinstance(item, dict)} + if actual_names != expected_names or len(remote_files) != len(local): + raise ReleaseVerificationError("PyPI release file inventory differs from the build") + + verified_artifacts: list[dict[str, Any]] = [] + for artifact in local: + matches = [ + item + for item in remote_files + if isinstance(item, dict) and item.get("filename") == artifact["name"] + ] + if len(matches) != 1: + raise ReleaseVerificationError(f"PyPI artifact is missing: {artifact['name']}") + remote = matches[0] + remote_url = remote.get("url") + parsed = urlsplit(remote_url) if isinstance(remote_url, str) else None + if ( + parsed is None + or parsed.scheme != "https" + or parsed.netloc != "files.pythonhosted.org" + or parsed.query + or parsed.fragment + ): + raise ReleaseVerificationError(f"PyPI artifact URL is invalid: {artifact['name']}") + expected = { + "size": artifact["size_bytes"], + "sha256": artifact["sha256"].removeprefix("sha256:"), + "yanked": False, + } + actual = { + "size": remote.get("size"), + "sha256": remote.get("digests", {}).get("sha256"), + "yanked": remote.get("yanked"), + } + if actual != expected: + raise ReleaseVerificationError( + f"PyPI metadata differs for {artifact['name']}: {actual}" + ) + public_bytes = fetch(remote_url) + if ( + len(public_bytes) != artifact["size_bytes"] + or _digest_bytes(public_bytes) != artifact["sha256"] + ): + raise ReleaseVerificationError(f"PyPI bytes differ from the build: {artifact['name']}") + verified_artifacts.append( + { + "name": artifact["name"], + "kind": artifact["kind"], + "authority": "pypi", + "url": remote_url, + "sha256": artifact["sha256"], + "size_bytes": artifact["size_bytes"], + } + ) + return verified_artifacts, { + "project": PYPI_PROJECT, + "version": version, + "version_metadata_url": version_url, + "version_metadata_sha256": _digest_bytes(metadata_body), + "latest_metadata_url": latest_url, + "latest_metadata_sha256": _digest_bytes(latest_body), + } + + +def _normalized_server(value: Mapping[str, Any]) -> dict[str, Any]: + """Remove only defaults that the official registry omits on read.""" + + normalized = copy.deepcopy(dict(value)) + packages = normalized.get("packages") + if isinstance(packages, list): + for package in packages: + if not isinstance(package, dict): + continue + variables = package.get("environmentVariables") + if isinstance(variables, list): + for variable in variables: + if isinstance(variable, dict) and variable.get("isRequired") is False: + variable.pop("isRequired") + return normalized + + +def _official_metadata(value: Mapping[str, Any]) -> dict[str, Any]: + metadata = value.get("_meta") + if not isinstance(metadata, dict): + raise ReleaseVerificationError("MCP registry metadata is missing") + official = metadata.get("io.modelcontextprotocol.registry/official") + if not isinstance(official, dict): + raise ReleaseVerificationError("official MCP registry metadata is missing") + return official + + +def verify_mcp_registry( + server_json: Path, + version: str, + *, + fetch: Callable[[str], bytes] = _fetch_url, +) -> dict[str, Any]: + """Verify the exact version and the MCP registry's current default.""" + + expected = _load_object(server_json, "server.json") + if expected.get("name") != MCP_SERVER_NAME or expected.get("version") != version: + raise ReleaseVerificationError("server.json identity or version differs") + encoded_name = quote(MCP_SERVER_NAME, safe="") + encoded_version = quote(version, safe="") + version_url = f"{MCP_REGISTRY}/v0.1/servers/{encoded_name}/versions/{encoded_version}" + latest_url = f"{MCP_REGISTRY}/v0.1/servers/{encoded_name}/versions/latest" + version_response, version_body = _fetch_object(version_url, fetch) + latest_response, latest_body = _fetch_object(latest_url, fetch) + expected_normalized = _normalized_server(expected) + for label, response in (("version", version_response), ("latest", latest_response)): + server = response.get("server") + if not isinstance(server, dict) or _normalized_server(server) != expected_normalized: + raise ReleaseVerificationError(f"MCP registry {label} metadata differs") + official = _official_metadata(response) + if official.get("status") != "active" or official.get("isLatest") is not True: + raise ReleaseVerificationError(f"MCP registry {label} is not the active latest release") + return { + "server_name": MCP_SERVER_NAME, + "version": version, + "version_url": version_url, + "version_response_sha256": _digest_bytes(version_body), + "latest_url": latest_url, + "latest_response_sha256": _digest_bytes(latest_body), + "server_sha256": _digest_bytes(_canonical_bytes(expected_normalized)), + } + + +def _policy_binding(policy_path: Path, source_commit: str) -> dict[str, Any]: + if HEX40.fullmatch(source_commit) is None: + raise ReleaseVerificationError("lifecycle source commit must be a full commit SHA") + policy_bytes = policy_path.read_bytes() + policy = _load_object(policy_path, "Production lifecycle policy") + targets = policy.get("targets") + if not isinstance(targets, list): + raise ReleaseVerificationError("Production lifecycle targets are invalid") + matches = [item for item in targets if isinstance(item, dict) and item.get("id") == TARGET_ID] + if len(matches) != 1: + raise ReleaseVerificationError("canonical Agent lifecycle target is missing") + target = matches[0] + expected = { + "source_repository": "OpenAdaptAI/openadapt-agent", + "release_kind": "public_package", + "required_claim_scope": CLAIM_SCOPE, + "required_artifact_kinds": ["sdist", "wheel"], + "package_index_project": PYPI_PROJECT, + "artifact_authority_by_kind": {"sdist": "pypi", "wheel": "pypi"}, + } + actual = {key: target.get(key) for key in expected} + if actual != expected: + raise ReleaseVerificationError("canonical Agent lifecycle target differs") + revision = policy.get("revision") + if not isinstance(revision, int) or isinstance(revision, bool) or revision < 1: + raise ReleaseVerificationError("Production lifecycle policy revision is invalid") + return { + "repository": CENTRAL_REPOSITORY, + "source_commit": source_commit, + "policy_path": "production-lifecycle-policy.json", + "policy_sha256": _digest_bytes(policy_bytes), + "policy_revision": revision, + "target": TARGET_ID, + "claim_scope": CLAIM_SCOPE, + } + + +def build_candidate( + *, + version: str, + tag: str, + source_commit: str, + artifacts: list[dict[str, Any]], + pypi: dict[str, Any], + mcp_registry: dict[str, Any], + lifecycle_authority: dict[str, Any], + verified_at: datetime | None = None, +) -> dict[str, Any]: + """Build an unadmitted evidence input. Never derive a Production selector.""" + + if SEMVER.fullmatch(version) is None or tag not in {version, f"v{version}"}: + raise ReleaseVerificationError("release version or tag is invalid") + if HEX40.fullmatch(source_commit) is None: + raise ReleaseVerificationError("release source commit must be a full commit SHA") + instant = (verified_at or datetime.now(timezone.utc)).astimezone(timezone.utc) + return { + "$schema": ( + "https://raw.githubusercontent.com/OpenAdaptAI/openadapt-agent/" + f"{source_commit}/schemas/production-lifecycle-admission-candidate.schema.json" + ), + "schema_version": "openadapt.production-lifecycle-admission-candidate/v1", + "candidate_role": "production_admission_input", + "admission_status": "not_admitted", + "production_authority": lifecycle_authority, + "release": { + "kind": "public_package", + "version": version, + "tag": tag, + "source_commit": source_commit, + "immutable_release_url": ( + f"https://github.com/OpenAdaptAI/openadapt-agent/commit/{source_commit}" + ), + "artifacts": artifacts, + }, + "registry_parity": {"pypi": pypi, "mcp": mcp_registry}, + "verified_at": instant.replace(microsecond=0).isoformat().replace("+00:00", "Z"), + } + + +def verify_and_write(args: argparse.Namespace) -> str: + artifacts, pypi = verify_pypi(args.dist, args.version) + mcp = verify_mcp_registry(args.server_json, args.version) + authority = _policy_binding(args.lifecycle_policy, args.lifecycle_source_commit) + candidate = build_candidate( + version=args.version, + tag=args.tag, + source_commit=args.source_commit, + artifacts=artifacts, + pypi=pypi, + mcp_registry=mcp, + lifecycle_authority=authority, + ) + args.output.parent.mkdir(parents=True, exist_ok=True) + payload = json.dumps(candidate, indent=2, sort_keys=True) + "\n" + args.output.write_text(payload, encoding="utf-8") + return _digest_bytes(payload.encode("utf-8")) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--dist", type=Path, required=True) + parser.add_argument("--server-json", type=Path, required=True) + parser.add_argument("--version", required=True) + parser.add_argument("--tag", required=True) + parser.add_argument("--source-commit", required=True) + parser.add_argument("--lifecycle-policy", type=Path, required=True) + parser.add_argument("--lifecycle-source-commit", required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--attempts", type=int, default=1) + parser.add_argument("--retry-seconds", type=int, default=15) + args = parser.parse_args() + if args.attempts < 1 or args.retry_seconds < 0: + parser.error("attempts must be positive and retry-seconds cannot be negative") + + for attempt in range(1, args.attempts + 1): + try: + digest = verify_and_write(args) + except (ReleaseVerificationError, OSError) as exc: + if attempt == args.attempts: + print(f"REFUSED: {exc}") + return 1 + print(f"Attempt {attempt}: {exc}; waiting {args.retry_seconds}s") + time.sleep(args.retry_seconds) + continue + print(f"Verified exact registry parity; unadmitted candidate digest: {digest}") + return 0 + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_release_registries.py b/tests/test_release_registries.py new file mode 100644 index 0000000..55448aa --- /dev/null +++ b/tests/test_release_registries.py @@ -0,0 +1,232 @@ +"""Release ordering and registry-parity contract tests.""" + +from __future__ import annotations + +import hashlib +import importlib.util +import json +import sys +from datetime import datetime, timezone +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +RELEASE_WORKFLOW = ROOT / ".github" / "workflows" / "release.yml" +CANDIDATE_SCHEMA = ROOT / "schemas" / "production-lifecycle-admission-candidate.schema.json" +SPEC = importlib.util.spec_from_file_location( + "verify_release_registries", ROOT / "scripts" / "verify_release_registries.py" +) +assert SPEC is not None and SPEC.loader is not None +VERIFY = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = VERIFY +SPEC.loader.exec_module(VERIFY) +MCP_REGISTRY = VERIFY.MCP_REGISTRY +MCP_SERVER_NAME = VERIFY.MCP_SERVER_NAME +PYPI_PROJECT = VERIFY.PYPI_PROJECT +ReleaseVerificationError = VERIFY.ReleaseVerificationError +_policy_binding = VERIFY._policy_binding +build_candidate = VERIFY.build_candidate +verify_mcp_registry = VERIFY.verify_mcp_registry +verify_pypi = VERIFY.verify_pypi +VERSION = "9.8.7" +SOURCE_COMMIT = "a" * 40 +POLICY_COMMIT = "b" * 40 + + +def _json_bytes(value: object) -> bytes: + return json.dumps(value, separators=(",", ":")).encode() + + +def _dist(tmp_path: Path) -> tuple[Path, dict[str, bytes]]: + dist = tmp_path / "dist" + dist.mkdir() + files = { + f"openadapt_agent-{VERSION}-py3-none-any.whl": b"wheel bytes", + f"openadapt_agent-{VERSION}.tar.gz": b"sdist bytes", + } + for name, body in files.items(): + (dist / name).write_bytes(body) + return dist, files + + +def _pypi_fetch(files: dict[str, bytes], *, latest: str = VERSION): + urls = [] + bodies: dict[str, bytes] = {} + for name, body in files.items(): + url = f"https://files.pythonhosted.org/packages/test/{name}" + bodies[url] = body + urls.append( + { + "filename": name, + "url": url, + "size": len(body), + "digests": {"sha256": hashlib.sha256(body).hexdigest()}, + "yanked": False, + } + ) + version_url = f"https://pypi.org/pypi/{PYPI_PROJECT}/{VERSION}/json" + latest_url = f"https://pypi.org/pypi/{PYPI_PROJECT}/json" + bodies[version_url] = _json_bytes({"info": {"version": VERSION}, "urls": urls}) + bodies[latest_url] = _json_bytes({"info": {"version": latest}}) + + def fetch(url: str) -> bytes: + return bodies[url] + + return fetch + + +def _server_json(tmp_path: Path) -> tuple[Path, dict]: + value = { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": MCP_SERVER_NAME, + "version": VERSION, + "packages": [ + { + "registryType": "pypi", + "identifier": PYPI_PROJECT, + "version": VERSION, + "transport": {"type": "stdio"}, + "environmentVariables": [{"name": "TOKEN", "isRequired": False, "isSecret": True}], + } + ], + } + path = tmp_path / "server.json" + path.write_text(json.dumps(value), encoding="utf-8") + return path, value + + +def _mcp_fetch(server: dict, *, latest_version: str = VERSION): + normalized = json.loads(json.dumps(server)) + normalized["packages"][0]["environmentVariables"][0].pop("isRequired") + version_url = ( + f"{MCP_REGISTRY}/v0.1/servers/io.github.OpenAdaptAI%2Fopenadapt-agent/versions/9.8.7" + ) + latest_url = ( + f"{MCP_REGISTRY}/v0.1/servers/io.github.OpenAdaptAI%2Fopenadapt-agent/versions/latest" + ) + latest_server = json.loads(json.dumps(normalized)) + latest_server["version"] = latest_version + latest_server["packages"][0]["version"] = latest_version + official = { + "io.modelcontextprotocol.registry/official": { + "status": "active", + "isLatest": True, + } + } + bodies = { + version_url: _json_bytes({"server": normalized, "_meta": official}), + latest_url: _json_bytes({"server": latest_server, "_meta": official}), + } + + def fetch(url: str) -> bytes: + return bodies[url] + + return fetch + + +def _policy(tmp_path: Path) -> Path: + path = tmp_path / "production-lifecycle-policy.json" + path.write_text( + json.dumps( + { + "revision": 4, + "targets": [ + { + "id": "agent", + "source_repository": "OpenAdaptAI/openadapt-agent", + "release_kind": "public_package", + "required_claim_scope": "qualified_agent_bridge_release", + "required_artifact_kinds": ["sdist", "wheel"], + "package_index_project": "openadapt-agent", + "artifact_authority_by_kind": { + "sdist": "pypi", + "wheel": "pypi", + }, + } + ], + } + ), + encoding="utf-8", + ) + return path + + +def test_exact_registry_parity_builds_only_an_unadmitted_candidate(tmp_path: Path) -> None: + dist, files = _dist(tmp_path) + artifacts, pypi = verify_pypi(dist, VERSION, fetch=_pypi_fetch(files)) + server_path, server = _server_json(tmp_path) + mcp = verify_mcp_registry(server_path, VERSION, fetch=_mcp_fetch(server)) + authority = _policy_binding(_policy(tmp_path), POLICY_COMMIT) + + candidate = build_candidate( + version=VERSION, + tag=f"v{VERSION}", + source_commit=SOURCE_COMMIT, + artifacts=artifacts, + pypi=pypi, + mcp_registry=mcp, + lifecycle_authority=authority, + verified_at=datetime(2026, 8, 20, 12, 0, tzinfo=timezone.utc), + ) + + assert candidate["admission_status"] == "not_admitted" + assert candidate["candidate_role"] == "production_admission_input" + assert candidate["production_authority"]["source_commit"] == POLICY_COMMIT + assert candidate["release"]["source_commit"] == SOURCE_COMMIT + assert [item["kind"] for item in candidate["release"]["artifacts"]] == [ + "sdist", + "wheel", + ] + # PyPI latest is distribution parity evidence. It is never a Production selector. + all_keys: set[str] = set() + + def collect_keys(value: object) -> None: + if isinstance(value, dict): + all_keys.update(value) + for item in value.values(): + collect_keys(item) + elif isinstance(value, list): + for item in value: + collect_keys(item) + + collect_keys(candidate) + assert {"admission_id", "release_identity", "channel"}.isdisjoint(all_keys) + + +def test_pypi_newer_default_refuses_the_candidate(tmp_path: Path) -> None: + dist, files = _dist(tmp_path) + with pytest.raises(ReleaseVerificationError, match="PyPI latest is not the exact release"): + verify_pypi(dist, VERSION, fetch=_pypi_fetch(files, latest="9.8.8")) + + +def test_mcp_latest_metadata_must_equal_the_exact_server(tmp_path: Path) -> None: + server_path, server = _server_json(tmp_path) + with pytest.raises(ReleaseVerificationError, match="MCP registry latest metadata differs"): + verify_mcp_registry( + server_path, + VERSION, + fetch=_mcp_fetch(server, latest_version="9.8.8"), + ) + + +def test_candidate_schema_is_closed_and_marks_the_record_not_admitted() -> None: + schema = json.loads(CANDIDATE_SCHEMA.read_text(encoding="utf-8")) + assert schema["additionalProperties"] is False + assert schema["properties"]["admission_status"] == {"const": "not_admitted"} + assert "admission_id" not in schema["properties"] + assert "release_identity" not in schema["properties"] + + +def test_release_orders_publish_parity_then_candidate_and_pins_publisher() -> None: + workflow = RELEASE_WORKFLOW.read_text(encoding="utf-8") + publish = workflow.index("./mcp-publisher publish") + parity = workflow.index("python scripts/verify_release_registries.py") + retain = workflow.index("Retain the content-bound admission candidate") + assert publish < parity < retain + assert "registry-parity:" in workflow + assert "needs: [validate, mcp-registry-publish]" in workflow + assert "releases/latest/download" not in workflow + assert 'publisher_version="1.8.1"' in workflow + assert "a06c9096dcb9727c13555b6be26c7effa707b01f06a4c561ba7a3635443cf2cc" in workflow + assert "production-lifecycle-admissions.json" not in workflow From 8691f136576eb2d087794cb9bb179fdd42810aa3 Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 20 Aug 2026 12:02:50 -0400 Subject: [PATCH 2/5] fix: exclude admission schema from MCPB --- .mcpbignore | 1 + tests/test_release_registries.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/.mcpbignore b/.mcpbignore index 016fb27..0461a74 100644 --- a/.mcpbignore +++ b/.mcpbignore @@ -6,6 +6,7 @@ build/ dist/ docs/ scripts/ +schemas/ tests/ *.egg-info/ **/__pycache__/ diff --git a/tests/test_release_registries.py b/tests/test_release_registries.py index 55448aa..6f75b61 100644 --- a/tests/test_release_registries.py +++ b/tests/test_release_registries.py @@ -14,6 +14,7 @@ ROOT = Path(__file__).resolve().parents[1] RELEASE_WORKFLOW = ROOT / ".github" / "workflows" / "release.yml" CANDIDATE_SCHEMA = ROOT / "schemas" / "production-lifecycle-admission-candidate.schema.json" +MCPB_IGNORE = ROOT / ".mcpbignore" SPEC = importlib.util.spec_from_file_location( "verify_release_registries", ROOT / "scripts" / "verify_release_registries.py" ) @@ -216,6 +217,7 @@ def test_candidate_schema_is_closed_and_marks_the_record_not_admitted() -> None: assert schema["properties"]["admission_status"] == {"const": "not_admitted"} assert "admission_id" not in schema["properties"] assert "release_identity" not in schema["properties"] + assert "schemas/" in MCPB_IGNORE.read_text(encoding="utf-8").splitlines() def test_release_orders_publish_parity_then_candidate_and_pins_publisher() -> None: From ff6e876329987feef4561efd87fe9082268aa9b1 Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 20 Aug 2026 12:32:29 -0400 Subject: [PATCH 3/5] docs: remove static agent maturity labels --- README.md | 1 - docs/DESIGN.md | 2 -- docs/DISTRIBUTION.md | 6 +++--- llms.txt | 2 +- pyproject.toml | 1 - server.json | 2 +- 6 files changed, 5 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 24d6e37..b0ef837 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,5 @@ # OpenAdapt Agent -[![Lifecycle: Beta](https://img.shields.io/badge/lifecycle-Beta-2563eb)](https://github.com/OpenAdaptAI/openadapt-agent) [![License: MIT](https://img.shields.io/badge/license-MIT-yellow.svg)](LICENSE) [![Python 3.10–3.12](https://img.shields.io/badge/python-3.10%E2%80%933.12-blue)](https://www.python.org/downloads/) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index edb352a..0fa1dec 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1,7 +1,5 @@ # OpenAdapt Agent design -**Lifecycle: Beta.** - `openadapt-agent` is the local agent-facing bridge for compiled `openadapt-flow` workflows. It exposes two complementary interfaces: diff --git a/docs/DISTRIBUTION.md b/docs/DISTRIBUTION.md index 839a191..3f46a23 100644 --- a/docs/DISTRIBUTION.md +++ b/docs/DISTRIBUTION.md @@ -1,6 +1,6 @@ # Distribution & discoverability — openadapt-agent -**Status: Beta (v2).** This document describes how the package is +This document describes how the package is made installable and discoverable as an MCP server, the security-relevant distinction between the *public capability* and a *user's private bundle*, and the exact founder-owned steps to publish and list it. The @@ -55,8 +55,8 @@ and [`../manifest.json`](../manifest.json). - **Name (reverse-DNS, official registry):** `io.github.OpenAdaptAI/openadapt-agent` - **Display name:** OpenAdapt Agent (openadapt-flow bridge) - **PyPI package:** `openadapt-agent` -- **Version:** `2.0.2` (Beta) -- **Description:** Local Beta bridge for governed openadapt-flow workflows and attended actions. +- **Version:** `2.0.2` +- **Description:** Local bridge for governed openadapt-flow workflows and attended actions. - **Homepage / docs:** https://docs.openadapt.ai - **Repository:** https://github.com/OpenAdaptAI/openadapt-agent - **License:** MIT diff --git a/llms.txt b/llms.txt index ddbaf07..d15210e 100644 --- a/llms.txt +++ b/llms.txt @@ -1,6 +1,6 @@ # openadapt-agent -> The local agent bridge for [openadapt-flow](https://github.com/OpenAdaptAI/openadapt-flow). It exposes compiled workflows and PHI-safe Needs Attention items to MCP clients and emits Agent Skills without becoming a second automation runtime. Healthy execution uses Flow's governed `run` command; a halted or refused run is returned as that exact non-success outcome. Flow remains the authority for policy, identity, verification, durable pauses, attended decisions, repair, and audit. Status: Beta (v2). +> The local agent bridge for [openadapt-flow](https://github.com/OpenAdaptAI/openadapt-flow). It exposes compiled workflows and PHI-safe Needs Attention items to MCP clients and emits Agent Skills without becoming a second automation runtime. Healthy execution uses Flow's governed `run` command; a halted or refused run is returned as that exact non-success outcome. Flow remains the authority for policy, identity, verification, durable pauses, attended decisions, repair, and audit. Production status comes only from the active signed OpenAdapt release admission. ## What it provides diff --git a/pyproject.toml b/pyproject.toml index 39d2bf3..d9fe913 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,6 @@ maintainers = [ {name = "OpenAdaptAI", email = "contact@openadapt.ai"} ] classifiers = [ - "Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", diff --git a/server.json b/server.json index e76f1f5..b236cdc 100644 --- a/server.json +++ b/server.json @@ -2,7 +2,7 @@ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", "name": "io.github.OpenAdaptAI/openadapt-agent", "title": "OpenAdapt Agent (openadapt-flow bridge)", - "description": "Local Beta bridge for governed openadapt-flow workflows and attended actions.", + "description": "Local bridge for governed openadapt-flow workflows and attended actions.", "websiteUrl": "https://docs.openadapt.ai", "repository": { "url": "https://github.com/OpenAdaptAI/openadapt-agent", From 52611317399c315269e6893b9bb58d2fb0ab2707 Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 20 Aug 2026 12:51:50 -0400 Subject: [PATCH 4/5] fix: make tag push the sole release trigger --- .github/workflows/release.yml | 34 +++++++++---------------------- README.md | 4 ++-- docs/DISTRIBUTION.md | 35 ++++++++++++++++++-------------- tests/test_release_registries.py | 16 ++++++++++++++- 4 files changed, 47 insertions(+), 42 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8f73a0e..0300d78 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,8 @@ name: Release # Builds the package, gates it on the license/boundary check, and — ONLY on -# a version tag or a published GitHub Release — publishes to PyPI and to the -# official MCP registry. A normal push or PR runs the validate job only +# a version tag push — publishes to PyPI and to the +# official MCP registry. A pull request or manual run starts validation only # (dry run, no publish), so the workflow itself is testable without shipping. # # Auth is OIDC-first and secret-free for the io.github.OpenAdaptAI namespace: @@ -15,8 +15,6 @@ name: Release on: push: tags: ["v*"] - release: - types: [published] workflow_dispatch: pull_request: paths: @@ -112,7 +110,7 @@ jobs: pypi-publish: name: Publish to PyPI needs: validate - if: ${{ (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || github.event_name == 'release' }} + if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }} runs-on: ubuntu-latest environment: pypi permissions: @@ -125,11 +123,7 @@ jobs: shell: bash run: | set -euo pipefail - if [ "${{ github.event_name }}" = "release" ]; then - tag="${{ github.event.release.tag_name }}" - else - tag="${GITHUB_REF#refs/tags/}" - fi + tag="${GITHUB_REF#refs/tags/}" tag_version="${tag#v}" pkg_version="$(grep -m1 -E '^version = ' pyproject.toml | sed -E 's/version = "(.*)"/\1/')" echo "version=${pkg_version}" >> "$GITHUB_OUTPUT" @@ -171,7 +165,7 @@ jobs: mcp-registry-publish: name: Publish server.json to the MCP registry needs: [validate, pypi-publish] - if: ${{ (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || github.event_name == 'release' }} + if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }} runs-on: ubuntu-latest permissions: id-token: write # OIDC for `mcp-publisher login github-oidc` @@ -183,11 +177,7 @@ jobs: shell: bash run: | set -euo pipefail - if [ "${{ github.event_name }}" = "release" ]; then - tag="${{ github.event.release.tag_name }}" - else - tag="${GITHUB_REF#refs/tags/}" - fi + tag="${GITHUB_REF#refs/tags/}" pkg_version="$(grep -m1 -E '^version = ' pyproject.toml | sed -E 's/version = "(.*)"/\1/')" if [ "${tag#v}" != "${pkg_version}" ]; then echo "::error::Release tag '${tag}' does not match package version '${pkg_version}'." >&2 @@ -249,9 +239,9 @@ jobs: run: ./mcp-publisher publish registry-parity: - name: Verify registries + retain admission input + name: Verify registries + upload admission handoff needs: [validate, mcp-registry-publish] - if: ${{ (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || github.event_name == 'release' }} + if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }} runs-on: ubuntu-latest permissions: contents: read @@ -262,11 +252,7 @@ jobs: shell: bash run: | set -euo pipefail - if [ "${{ github.event_name }}" = "release" ]; then - tag="${{ github.event.release.tag_name }}" - else - tag="${GITHUB_REF#refs/tags/}" - fi + tag="${GITHUB_REF#refs/tags/}" pkg_version="$(grep -m1 -E '^version = ' pyproject.toml | sed -E 's/version = "(.*)"/\1/')" if [ "${tag#v}" != "${pkg_version}" ]; then echo "::error::Release tag '${tag}' does not match package version '${pkg_version}'." >&2 @@ -304,7 +290,7 @@ jobs: --attempts 20 \ --retry-seconds 15 sha256sum release-metadata/production-admission-candidate.json - - name: Retain the content-bound admission candidate + - name: Upload the bounded admission-candidate handoff uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: production-admission-candidate-${{ steps.ver.outputs.version }} diff --git a/README.md b/README.md index b0ef837..38ceff9 100644 --- a/README.md +++ b/README.md @@ -275,8 +275,8 @@ Registry-launched installs start **read-only by default**; execution tools are registered only when the operator adds `--allow-run`. Publishing is automated: [`.github/workflows/release.yml`](.github/workflows/release.yml) -builds, runs the license/boundary gate, and — only on a `vX.Y.Z` tag or a -published Release — ships to PyPI (Trusted Publishing, OIDC) and the MCP +builds, runs the license/boundary gate, and — only on a pushed `vX.Y.Z` tag — +ships to PyPI (Trusted Publishing, OIDC) and the MCP registry (`mcp-publisher login github-oidc`), secret-free. It runs a dry run (no publish) on PRs and manual dispatch. See [`docs/DISTRIBUTION.md`](docs/DISTRIBUTION.md) for the one-time founder diff --git a/docs/DISTRIBUTION.md b/docs/DISTRIBUTION.md index 3f46a23..cb46192 100644 --- a/docs/DISTRIBUTION.md +++ b/docs/DISTRIBUTION.md @@ -79,14 +79,14 @@ remains for the founder is a small set of **one-time identity/config actions** (create accounts, enable Trusted Publishing, claim directory listings) that mint public first-party identities and therefore stay founder-authorized. Nothing publishes to a public index automatically -except on a deliberate version tag / GitHub Release. +except on a deliberate version-tag push. ### 3.0 What the workflow does (AUTOMATED) | Trigger | Jobs that run | Publishes? | | --- | --- | --- | -| Pull request touching release files, or `workflow_dispatch`, or any tag/release | `validate` (Python build, MCPB build, archive boundary checks, `twine check`, registry schema validation) | **No** — dry run | -| Push a `vX.Y.Z` tag, or publish a GitHub Release | `validate` -> `pypi-publish` -> `mcp-registry-publish` | **Yes** | +| Pull request touching release files, or `workflow_dispatch` | `validate` (Python build, MCPB build, archive boundary checks, `twine check`, registry schema validation) | **No** — dry run | +| Push a `vX.Y.Z` tag | `validate` -> `pypi-publish` -> `mcp-registry-publish` -> `registry-parity` | **Yes** | - **`validate`** builds the sdist+wheel and local MCPB, runs [`scripts/check_release_artifacts.py`](../scripts/check_release_artifacts.py) @@ -96,11 +96,11 @@ except on a deliberate version tag / GitHub Release. against its live schema, and runs the version-consistency guard (`tests/test_distribution.py`). It runs on PRs and manual dispatch so the pipeline is testable **without** publishing. -- **`pypi-publish`** (tag/release only) asserts the tag matches the +- **`pypi-publish`** (tag push only) asserts the tag matches the package version, then uploads via **PyPI Trusted Publishing (OIDC)** — no long-lived token. Runs in the `pypi` GitHub environment (add required reviewers there if you want a human approval gate on every publish). -- **`mcp-registry-publish`** (tag/release only) waits for the new version +- **`mcp-registry-publish`** (tag push only) waits for the new version to be live on PyPI (the registry validates package existence), then `mcp-publisher login github-oidc` + `mcp-publisher publish` — no token, because the repo lives under the `OpenAdaptAI` org that owns the @@ -110,21 +110,26 @@ except on a deliberate version tag / GitHub Release. workflow downloads each PyPI artifact and proves byte-for-byte equality with the archives produced by the protected build. It also proves that the exact and `latest` MCP registry records equal the reviewed `server.json`. - Only then does it retain a 30-day - `production-admission-candidate-` artifact. That record binds the - source commit, artifact hashes, both public registry observations, and one - exact commit of the canonical lifecycle policy. - -The retained record is explicitly `not_admitted`. It has no admission ID, + Only then does it upload a 30-day + `production-admission-candidate-` handoff artifact. That record + binds the source commit, artifact hashes, both public registry observations, + and one exact commit of the canonical lifecycle policy. The Actions artifact + is a bounded transport copy. It is not the long-term evidence record. Before + activation, the central admission process must verify its exact digest and + copy it with the supporting release evidence into the immutable evidence + store. If that handoff expires before the copy, registry parity must run + again. + +The candidate record is explicitly `not_admitted`. It has no admission ID, release sequence, or Production channel selector. PyPI `latest` and MCP `latest` are distribution checks only. They never grant Production status. The active, signed ledger in `OpenAdaptAI/.github` is the sole Production authority and requires its separate acceptance evidence and activation. To cut a release: bump the synchronized version fields (see §3.1), merge, then -`git tag vX.Y.Z && git push origin vX.Y.Z` (or publish a Release with that -tag). The first tag you push IS the first real publish — do it -deliberately. +use the release App to create and push `vX.Y.Z`. The tag push is the one +authoritative publish event. Creating or publishing the GitHub Release does not +start another publisher run. ### 3.a Required repo configuration and secrets (FOUNDER, one-time) @@ -165,7 +170,7 @@ both `version` fields in `server.json`, and `manifest.json` `version`. The ### 3.2 Publish to PyPI — AUTOMATED (`pypi-publish` job) -Fires automatically on a `vX.Y.Z` tag / Release once §3.a step 1-2 are +Fires automatically on a pushed `vX.Y.Z` tag once §3.a step 1-2 are done. To reproduce locally (dry run or a manual emergency publish): ```bash diff --git a/tests/test_release_registries.py b/tests/test_release_registries.py index 6f75b61..c143a1a 100644 --- a/tests/test_release_registries.py +++ b/tests/test_release_registries.py @@ -224,11 +224,25 @@ def test_release_orders_publish_parity_then_candidate_and_pins_publisher() -> No workflow = RELEASE_WORKFLOW.read_text(encoding="utf-8") publish = workflow.index("./mcp-publisher publish") parity = workflow.index("python scripts/verify_release_registries.py") - retain = workflow.index("Retain the content-bound admission candidate") + retain = workflow.index("Upload the bounded admission-candidate handoff") assert publish < parity < retain assert "registry-parity:" in workflow assert "needs: [validate, mcp-registry-publish]" in workflow + assert "name: Verify registries + upload admission handoff" in workflow + assert "retention-days: 30" in workflow assert "releases/latest/download" not in workflow assert 'publisher_version="1.8.1"' in workflow assert "a06c9096dcb9727c13555b6be26c7effa707b01f06a4c561ba7a3635443cf2cc" in workflow assert "production-lifecycle-admissions.json" not in workflow + + +def test_tag_push_is_the_only_authoritative_publish_trigger() -> None: + workflow = RELEASE_WORKFLOW.read_text(encoding="utf-8") + publish_guard = ( + "if: ${{ github.event_name == 'push' " + "&& startsWith(github.ref, 'refs/tags/v') }}" + ) + + assert workflow.count(publish_guard) == 3 + assert "\n release:\n" not in workflow + assert "github.event.release" not in workflow From 3e90c54c93fea9b34ee09fd52bc9a75bb8c98784 Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 20 Aug 2026 13:02:06 -0400 Subject: [PATCH 5/5] fix: fail closed on MCP schema validation --- .github/workflows/release.yml | 18 +---- docs/DISTRIBUTION.md | 6 +- scripts/validate_server_schema.py | 115 ++++++++++++++++++++++++++++++ tests/test_release_registries.py | 35 +++++++++ 4 files changed, 157 insertions(+), 17 deletions(-) create mode 100644 scripts/validate_server_schema.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0300d78..e35c05e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,6 +25,7 @@ on: - "src/openadapt_agent/mcpb_entry.py" - "scripts/check_mcpb.py" - "scripts/check_release_artifacts.py" + - "scripts/validate_server_schema.py" - "scripts/verify_release_registries.py" - "schemas/production-lifecycle-admission-candidate.schema.json" - "scripts/check_dist.py" @@ -78,22 +79,7 @@ jobs: npx -y @anthropic-ai/mcpb@2.1.2 pack . "mcpb-dist/openadapt-agent-${version}.mcpb" python scripts/check_mcpb.py mcpb-dist/*.mcpb - name: Validate server.json against the MCP registry schema - run: | - python - <<'PY' - import json, sys, urllib.request - from jsonschema import Draft202012Validator - doc = json.load(open("server.json")) - url = doc["$schema"] - try: - schema = json.load(urllib.request.urlopen(url, timeout=30)) - except Exception as exc: # network hiccup: don't fail the dry run - print(f"WARNING: could not fetch {url}: {exc}; skipping live schema check") - sys.exit(0) - errors = sorted(Draft202012Validator(schema).iter_errors(doc), key=lambda e: list(e.path)) - for e in errors: - print("SCHEMA ERROR:", list(e.path), e.message) - sys.exit(1 if errors else 0) - PY + run: python scripts/validate_server_schema.py --server-json server.json - name: Upload built distributions uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: diff --git a/docs/DISTRIBUTION.md b/docs/DISTRIBUTION.md index cb46192..f6df422 100644 --- a/docs/DISTRIBUTION.md +++ b/docs/DISTRIBUTION.md @@ -93,9 +93,13 @@ except on a deliberate version-tag push. (fails if a wheel/sdist carries a bundle, `.enc`, run outputs, keys, or any non-code payload — the license/boundary gate), checks the MCPB for workflow/evidence payloads, runs `twine check`, validates `server.json` - against its live schema, and runs the + against the exact MCP schema at the pinned `2025-12-11` URL and SHA-256 + `3fba09590c99f61735d234822279f4223fab9e300c0a81e81c91ab62a4114de0`, + and runs the version-consistency guard (`tests/test_distribution.py`). It runs on PRs and manual dispatch so the pipeline is testable **without** publishing. + An unavailable schema or changed schema bytes fail validation before any + publisher can run. - **`pypi-publish`** (tag push only) asserts the tag matches the package version, then uploads via **PyPI Trusted Publishing (OIDC)** — no long-lived token. Runs in the `pypi` GitHub environment (add required diff --git a/scripts/validate_server_schema.py b/scripts/validate_server_schema.py new file mode 100644 index 0000000..6c63975 --- /dev/null +++ b/scripts/validate_server_schema.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Validate ``server.json`` against one exact official MCP registry schema.""" + +from __future__ import annotations + +import argparse +import hashlib +import hmac +import json +import sys +import urllib.error +import urllib.request +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from jsonschema import FormatChecker +from jsonschema.exceptions import SchemaError +from jsonschema.validators import validator_for + +SCHEMA_URL = "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json" +SCHEMA_SHA256 = "3fba09590c99f61735d234822279f4223fab9e300c0a81e81c91ab62a4114de0" +MAX_SCHEMA_BYTES = 1024 * 1024 + + +class RegistrySchemaError(RuntimeError): + """The exact registry schema or the descriptor failed validation.""" + + +def _fetch_schema(url: str) -> bytes: + request = urllib.request.Request( + url, + headers={"User-Agent": "openadapt-agent-release-schema-validator/1"}, + ) + try: + with urllib.request.urlopen(request, timeout=30) as response: + body = response.read(MAX_SCHEMA_BYTES + 1) + except (OSError, TimeoutError, urllib.error.URLError) as exc: + raise RegistrySchemaError(f"could not download the pinned MCP schema: {exc}") from exc + if len(body) > MAX_SCHEMA_BYTES: + raise RegistrySchemaError("the pinned MCP schema exceeds the size limit") + return body + + +def _load_object(value: bytes, label: str) -> dict[str, Any]: + try: + document = json.loads(value) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise RegistrySchemaError(f"{label} is not valid JSON: {exc}") from exc + if not isinstance(document, dict): + raise RegistrySchemaError(f"{label} must be a JSON object") + return document + + +def validate_server_schema( + server_json: Path, + *, + fetch: Callable[[str], bytes] = _fetch_schema, + expected_sha256: str = SCHEMA_SHA256, +) -> None: + """Fetch, authenticate, and apply the exact registry schema.""" + + try: + descriptor_bytes = server_json.read_bytes() + except OSError as exc: + raise RegistrySchemaError(f"could not read {server_json}: {exc}") from exc + descriptor = _load_object(descriptor_bytes, "server.json") + if descriptor.get("$schema") != SCHEMA_URL: + raise RegistrySchemaError(f"server.json must declare the pinned schema URL: {SCHEMA_URL}") + + try: + schema_bytes = fetch(SCHEMA_URL) + except RegistrySchemaError: + raise + except Exception as exc: + raise RegistrySchemaError(f"could not download the pinned MCP schema: {exc}") from exc + actual_sha256 = hashlib.sha256(schema_bytes).hexdigest() + if not hmac.compare_digest(actual_sha256, expected_sha256): + raise RegistrySchemaError( + "pinned MCP schema digest mismatch: " + f"expected {expected_sha256}, received {actual_sha256}" + ) + + schema = _load_object(schema_bytes, "pinned MCP schema") + validator_class = validator_for(schema) + try: + validator_class.check_schema(schema) + except SchemaError as exc: + raise RegistrySchemaError(f"pinned MCP schema is invalid: {exc.message}") from exc + validator = validator_class(schema, format_checker=FormatChecker()) + errors = sorted(validator.iter_errors(descriptor), key=lambda item: list(item.absolute_path)) + if errors: + details = "; ".join( + f"{'.'.join(str(value) for value in error.absolute_path) or ''}: " + f"{error.message}" + for error in errors + ) + raise RegistrySchemaError(f"server.json does not match the pinned MCP schema: {details}") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--server-json", type=Path, default=Path("server.json")) + args = parser.parse_args() + try: + validate_server_schema(args.server_json) + except RegistrySchemaError as exc: + print(f"MCP SCHEMA CHECK FAILED: {exc}", file=sys.stderr) + return 1 + print(f"server.json matches the pinned MCP schema ({SCHEMA_SHA256})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_release_registries.py b/tests/test_release_registries.py index c143a1a..2a17e90 100644 --- a/tests/test_release_registries.py +++ b/tests/test_release_registries.py @@ -22,6 +22,13 @@ VERIFY = importlib.util.module_from_spec(SPEC) sys.modules[SPEC.name] = VERIFY SPEC.loader.exec_module(VERIFY) +SCHEMA_SPEC = importlib.util.spec_from_file_location( + "validate_server_schema", ROOT / "scripts" / "validate_server_schema.py" +) +assert SCHEMA_SPEC is not None and SCHEMA_SPEC.loader is not None +SCHEMA_VALIDATOR = importlib.util.module_from_spec(SCHEMA_SPEC) +sys.modules[SCHEMA_SPEC.name] = SCHEMA_VALIDATOR +SCHEMA_SPEC.loader.exec_module(SCHEMA_VALIDATOR) MCP_REGISTRY = VERIFY.MCP_REGISTRY MCP_SERVER_NAME = VERIFY.MCP_SERVER_NAME PYPI_PROJECT = VERIFY.PYPI_PROJECT @@ -30,6 +37,9 @@ build_candidate = VERIFY.build_candidate verify_mcp_registry = VERIFY.verify_mcp_registry verify_pypi = VERIFY.verify_pypi +RegistrySchemaError = SCHEMA_VALIDATOR.RegistrySchemaError +SCHEMA_URL = SCHEMA_VALIDATOR.SCHEMA_URL +validate_server_schema = SCHEMA_VALIDATOR.validate_server_schema VERSION = "9.8.7" SOURCE_COMMIT = "a" * 40 POLICY_COMMIT = "b" * 40 @@ -246,3 +256,28 @@ def test_tag_push_is_the_only_authoritative_publish_trigger() -> None: assert workflow.count(publish_guard) == 3 assert "\n release:\n" not in workflow assert "github.event.release" not in workflow + + +def test_unavailable_registry_schema_refuses_validation(tmp_path: Path) -> None: + server_path, _ = _server_json(tmp_path) + + def unavailable(_url: str) -> bytes: + raise OSError("offline") + + with pytest.raises(RegistrySchemaError, match="could not download the pinned MCP schema"): + validate_server_schema(server_path, fetch=unavailable) + + +def test_changed_registry_schema_bytes_refuse_validation(tmp_path: Path) -> None: + server_path, _ = _server_json(tmp_path) + + with pytest.raises(RegistrySchemaError, match="pinned MCP schema digest mismatch"): + validate_server_schema(server_path, fetch=lambda _url: b"{}") + + +def test_release_validation_uses_the_fail_closed_schema_guard() -> None: + workflow = RELEASE_WORKFLOW.read_text(encoding="utf-8") + + assert "python scripts/validate_server_schema.py --server-json server.json" in workflow + assert "skipping live schema check" not in workflow + assert 'release:\n types: [published]' not in workflow