diff --git a/.github/workflows/publish-mcp-registry.yml b/.github/workflows/publish-mcp-registry.yml new file mode 100644 index 0000000..a34b504 --- /dev/null +++ b/.github/workflows/publish-mcp-registry.yml @@ -0,0 +1,142 @@ +name: Publish to MCP registry + +# Lists the released PyPI package in the official MCP registry +# (registry.modelcontextprotocol.io) from server.json at the release tag. +# GitHub OIDC proves ownership of the io.github.wiltodelta namespace, so no +# interactive login or stored secret is involved. The registry validates the +# PyPI release itself (the mcp-name marker in its description), and the Release +# event fires in parallel with publish.yml's upload, so this waits until PyPI +# serves the version first. A version the registry already lists is skipped. +# It is a workflow of its own, not a distribute.yml job, so a manual dispatch +# re-lists one version without re-running Homebrew, the Space, or ComfyUI, and +# a registry outage never fails release verification. + +on: + release: + types: [published] + workflow_dispatch: + inputs: + version: + description: "Released version to list (no leading v), e.g. 0.46.0" + required: true + +permissions: + id-token: write + contents: read + +jobs: + publish: + # Model-only prereleases are not Python package releases. + if: ${{ github.event_name != 'release' || (startsWith(github.event.release.tag_name, 'v') && !github.event.release.prerelease) }} + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + SERVER_NAME: io.github.wiltodelta/remove-ai-watermarks + # The same name percent-encoded, as the registry's path segment takes it. + SERVER_PATH: https://registry.modelcontextprotocol.io/v0/servers/io.github.wiltodelta%2Fremove-ai-watermarks + # Pinned publisher release; the digest is GitHub's sha256 for the asset. + PUBLISHER_URL: https://github.com/modelcontextprotocol/registry/releases/download/v1.8.1/mcp-publisher_linux_amd64.tar.gz + PUBLISHER_SHA256: a06c9096dcb9727c13555b6be26c7effa707b01f06a4c561ba7a3635443cf2cc + + steps: + - name: Resolve target version + id: v + env: + EVENT_NAME: ${{ github.event_name }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + INPUT_VERSION: ${{ github.event.inputs.version }} + run: | + set -euo pipefail + if [ "$EVENT_NAME" = "release" ]; then + v="${RELEASE_TAG#v}" + else + v="$INPUT_VERSION" + fi + # The version becomes a git ref and a URL component. Reject malformed input. + [[ "$v" =~ ^[0-9]+\.[0-9]+\.[0-9]+([a-zA-Z0-9.+-]*)$ ]] + echo "version=$v" >> "$GITHUB_OUTPUT" + + - name: Skip a version the registry already lists + id: listed + env: + VERSION: ${{ steps.v.outputs.version }} + run: | + set -euo pipefail + status=$(curl -sS --connect-timeout 10 --max-time 20 -o /dev/null -w '%{http_code}' "$SERVER_PATH/versions/$VERSION") + case "$status" in + 200) echo "listed=true" >> "$GITHUB_OUTPUT" + echo "- MCP registry: $SERVER_NAME $VERSION was already listed" >> "$GITHUB_STEP_SUMMARY" ;; + 404) echo "listed=false" >> "$GITHUB_OUTPUT" ;; + *) echo "::error::Registry lookup returned HTTP $status"; exit 1 ;; + esac + + # mcp-publisher reads only ./server.json, so fetch nothing else. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: ${{ steps.listed.outputs.listed == 'false' }} + with: + ref: v${{ steps.v.outputs.version }} + sparse-checkout: server.json + sparse-checkout-cone-mode: false + + - name: Check server.json matches the release + if: ${{ steps.listed.outputs.listed == 'false' }} + env: + VERSION: ${{ steps.v.outputs.version }} + run: | + set -euo pipefail + python3 - <<'PYTHON' + import json, os + manifest = json.load(open("server.json", encoding="utf-8")) + version = os.environ["VERSION"] + assert manifest["name"] == os.environ["SERVER_NAME"], manifest["name"] + found = [manifest["version"], *(p["version"] for p in manifest["packages"])] + assert all(v == version for v in found), f"server.json versions {found} != {version}" + PYTHON + + - name: Wait for PyPI to serve the release + if: ${{ steps.listed.outputs.listed == 'false' }} + env: + VERSION: ${{ steps.v.outputs.version }} + run: | + set -euo pipefail + for attempt in $(seq 1 40); do + status=$(curl -sS --connect-timeout 10 --max-time 20 -o /dev/null -w '%{http_code}' \ + "https://pypi.org/pypi/remove-ai-watermarks/$VERSION/json" || true) + if [ "$status" = "200" ]; then + echo "PyPI serves $VERSION" + exit 0 + fi + echo "Attempt $attempt: PyPI returned HTTP $status for $VERSION" + sleep 30 + done + echo "::error::PyPI never served $VERSION" + exit 1 + + - name: Publish with a verified mcp-publisher + if: ${{ steps.listed.outputs.listed == 'false' }} + run: | + set -euo pipefail + curl -fsSL -o mcp-publisher.tar.gz "$PUBLISHER_URL" + echo "$PUBLISHER_SHA256 mcp-publisher.tar.gz" | sha256sum -c - + tar xzf mcp-publisher.tar.gz mcp-publisher + ./mcp-publisher login github-oidc + ./mcp-publisher publish + + - name: Read the entry back + if: ${{ steps.listed.outputs.listed == 'false' }} + env: + VERSION: ${{ steps.v.outputs.version }} + run: | + set -euo pipefail + for attempt in $(seq 1 10); do + status=$(curl -sS --connect-timeout 10 --max-time 20 -o /dev/null -w '%{http_code}' \ + "$SERVER_PATH/versions/$VERSION" || true) + if [ "$status" = "200" ]; then + echo "- MCP registry: $SERVER_NAME $VERSION is listed" >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + echo "Attempt $attempt: registry returned HTTP $status" + sleep 15 + done + echo "::error::The registry does not list $SERVER_NAME $VERSION" + exit 1 diff --git a/CLAUDE.md b/CLAUDE.md index 83a2b88..4873325 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ The gate does not pass `--ignore-unfixed` to uv-secure, so a transitive CVE with Command, gate, typing, and model-test invariants auto-load from [`.claude/rules/development.md`](.claude/rules/development.md). Environment recovery, CI behavior, and fixture policy live in [`docs/development.md`](docs/development.md). -Before a release, read [`docs/release-and-distribution.md`](docs/release-and-distribution.md). Treat the release as complete only after PyPI, Homebrew, the Hugging Face Space, and the ComfyUI Registry are verified; conda is not published. Keep the source-distribution public allowlist. +Before a release, read [`docs/release-and-distribution.md`](docs/release-and-distribution.md). Treat the release as complete only after PyPI, Homebrew, the Hugging Face Space, and the ComfyUI Registry are verified; conda is not published. The MCP registry listing publishes itself from `publish-mcp-registry.yml` and does not block a release. Keep the source-distribution public allowlist. The published Agent Skill is [`skills/remove-ai-watermarks/`](skills/remove-ai-watermarks/). Update it in the same change as any CLI edit that alters commands, extras, exit codes, mark keys, or the intended-use boundary. Install and publish steps: [`docs/agent-skill.md`](docs/agent-skill.md). diff --git a/docs/development.md b/docs/development.md index 9501e2f..115515f 100644 --- a/docs/development.md +++ b/docs/development.md @@ -14,7 +14,7 @@ Read this reference for environment setup, dependency recovery, CI behavior, and - On an unreliable connection, sync `dev` plus only the required feature extras, such as `diffusion`, and run the checks directly instead of downloading every optional learned backend. - Run `uv` from the repository root or it may create a bare environment without the project dependencies. -`maintain.sh` starts with `uv sync --all-extras`, so a lean environment is replaced whenever the gate runs. Syncing back down can leave `opencv-python-headless` half-removed: `import cv2` then succeeds against an empty namespace while `cv2.INTER_LINEAR` is gone, which surfaces as a suite that stalls or reports hundreds of unrelated failures. Repair it with `uv sync --frozen --extra dev --reinstall-package opencv-python-headless` rather than re-reading the diff. +`maintain.sh` starts with `uv sync --all-extras`, so a lean environment is replaced whenever the gate runs. Syncing back down can leave `opencv-python-headless` half-removed: `import cv2` then succeeds against an empty namespace while `cv2.INTER_LINEAR` is gone, which surfaces as a suite that stalls or reports hundreds of unrelated failures. Repair it with `uv sync --frozen --extra dev --reinstall-package opencv-python-headless` rather than re-reading the diff. A stale worktree environment can fail differently: `import cv2` raises `partially initialized module 'cv2' has no attribute 'gapi_wip_gst_GStreamerPipeline'`, because the lock installs `opencv-contrib-python` 4.x beside `opencv-python` 5.x into one `cv2` directory. Reinstalling the three OpenCV packages did not fix that on 2026-10-17; deleting the worktree's `.venv` and running `uv sync --frozen --all-extras` did. Pyright is only meaningful with the extras its imports need. Scoped to `src/`, a lean environment reports `TrustMark`, `transformers` and `huggingface_hub` as unresolved, which reads as a type error and is not one; add `--extra trustmark --extra classify` before believing it. diff --git a/docs/mcp-server.md b/docs/mcp-server.md index ee2d984..9812432 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -87,5 +87,6 @@ default mode, receives method-not-found and falls back to the handshake. `io.github.wiltodelta/remove-ai-watermarks`. The registry verifies ownership by the `mcp-name:` marker in `README.md`, which becomes the PyPI description, so it can only verify a release whose README carries that marker. -[`glama.json`](../glama.json) names the maintainer for Glama. Publishing steps -are in [release and distribution](release-and-distribution.md#mcp-registry). +[`glama.json`](../glama.json) names the maintainer for Glama. Each release is +published automatically; the workflow is described in +[release and distribution](release-and-distribution.md#mcp-registry). diff --git a/docs/release-and-distribution.md b/docs/release-and-distribution.md index 22ec3be..95dabb5 100644 --- a/docs/release-and-distribution.md +++ b/docs/release-and-distribution.md @@ -227,14 +227,13 @@ submission state and links live in `uvx remove-ai-watermarks mcp`. The registry accepts it only after PyPI serves a release whose description, the README, carries the `mcp-name:` marker, and `tests/test_mcp_server.py` pins `server.json` to the package version. The listing -is not a release surface that blocks a release; after PyPI publishes a version: +is not a release surface that blocks a release. -1. `mcp-publisher login github` with an account that owns `wiltodelta`; -2. `mcp-publisher validate` and then `mcp-publisher publish` from the - repository root; -3. read the entry back from - `https://registry.modelcontextprotocol.io/v0/servers?search=io.github.wiltodelta/remove-ai-watermarks` - and check its version. +[`publish-mcp-registry.yml`](../.github/workflows/publish-mcp-registry.yml) +publishes it on every published GitHub Release, once PyPI serves that version. +GitHub OIDC proves ownership of the `io.github.wiltodelta` namespace, so no login +or secret is stored. To list a version already on PyPI, dispatch the workflow +with that version; a version the registry already lists is skipped. [`glama.json`](../glama.json) names the maintainer for Glama's claim flow. Client setup and the tool contract are in [the MCP server guide](mcp-server.md). diff --git a/tests/test_release_workflows.py b/tests/test_release_workflows.py index 6d5f681..4c64be0 100644 --- a/tests/test_release_workflows.py +++ b/tests/test_release_workflows.py @@ -214,3 +214,61 @@ def test_clawhub_uses_local_install(tmp_path: Path) -> None: "login --token synthetic", "inspect remove-ai-watermarks --file SKILL.md", ] + + +@pytest.mark.skipif(os.name != "posix" or shutil.which("bash") is None, reason="Executes an Ubuntu Bash workflow") +@pytest.mark.parametrize(("status", "listed"), [("200", "true"), ("404", "false"), ("503", None)]) +def test_mcp_registry_lookup_skips_listed_versions_and_fails_on_other_replies( + tmp_path: Path, status: str, listed: str | None +) -> None: + workflow = _workflow("publish-mcp-registry.yml") + script = _step("publish-mcp-registry.yml", "publish", "Skip a version the registry already lists")["run"] + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + curl = bin_dir / "curl" + curl.write_text(f'#!/bin/sh\nprintf "%s\\n" "$*" >> "$CALL_LOG"\nprintf {status}\n') + curl.chmod(0o755) + env = { + **os.environ, + **workflow["jobs"]["publish"]["env"], + "PATH": str(bin_dir) + os.pathsep + os.environ["PATH"], + "VERSION": "0.46.0", + "GITHUB_OUTPUT": str(tmp_path / "output"), + "GITHUB_STEP_SUMMARY": str(tmp_path / "summary"), + "CALL_LOG": str(tmp_path / "calls"), + } + result = subprocess.run(["bash", "-c", script], cwd=tmp_path, env=env, capture_output=True, text=True) + # The server name carries a slash, so it must reach the registry percent-encoded. + assert "/v0/servers/io.github.wiltodelta%2Fremove-ai-watermarks/versions/0.46.0" in (tmp_path / "calls").read_text() + if listed is None: + assert result.returncode != 0 + return + assert result.returncode == 0, result.stderr + assert f"listed={listed}" in (tmp_path / "output").read_text() + + +@pytest.mark.skipif(os.name != "posix" or shutil.which("bash") is None, reason="Executes an Ubuntu Bash workflow") +@pytest.mark.parametrize( + ("release", "package", "accepted"), + [("1.0.0", "1.0.0", True), ("1.0.1", "1.0.0", False), ("1.0.0", "1.0.1", False)], +) +def test_mcp_registry_publishes_only_a_server_json_that_matches_the_release( + tmp_path: Path, release: str, package: str, accepted: bool +) -> None: + workflow = _workflow("publish-mcp-registry.yml") + script = _step("publish-mcp-registry.yml", "publish", "Check server.json matches the release")["run"] + manifest = json.loads((ROOT / "server.json").read_text(encoding="utf-8")) + manifest["version"] = "1.0.0" + # A stale package entry under a bumped server version must not publish either. + manifest["packages"][0]["version"] = package + (tmp_path / "server.json").write_text(json.dumps(manifest), encoding="utf-8") + env = {**os.environ, **workflow["jobs"]["publish"]["env"], "VERSION": release} + result = subprocess.run(["bash", "-c", script], cwd=tmp_path, env=env, capture_output=True, text=True) + assert (result.returncode == 0) == accepted, result.stderr + + +def test_mcp_registry_publishes_on_release_with_an_oidc_token() -> None: + workflow = _workflow("publish-mcp-registry.yml") + assert workflow["permissions"]["id-token"] == "write" + # PyYAML reads the bare `on:` key as the boolean True. + assert set(workflow[True]) == {"release", "workflow_dispatch"}