Compare commits

..
2 Commits
Author SHA1 Message Date
Victor KuznetsovandClaude Opus 5.5 66d793d1a6 State why no aggregate oracle pass rate is published
Tier D of the verification plan now says the project publishes no
pass rate per model or resolution: a sample that large needs bulk
automated checks, which OpenAI's API terms and the SynthID Detector's
additional terms (synthid.com/terms, read 2026-10-17) both forbid.
Removal claims rest on the method and on individual hash-bound rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 20:49:16 -07:00
Victor KuznetsovandClaude Opus 5.5 f0b2c2406e Publish to the MCP registry from CI with GitHub OIDC
publish-mcp-registry.yml lists server.json in the official MCP registry
on every published release, and on dispatch for a version already on
PyPI. GitHub OIDC proves the io.github.wiltodelta namespace, so no login
or stored secret is involved. It skips a version the registry already
lists, checks out only server.json at the release tag, requires both of
its versions to equal the release, waits for PyPI to serve it, runs a
pinned, checksum-verified mcp-publisher, and reads the entry back.

Tests run the lookup and manifest steps against stubbed replies; every
targeted mutation fails them. docs/development.md records a stale
worktree venv that mixes OpenCV 4.x and 5.x into one cv2 package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 20:14:55 -07:00
7 changed files with 217 additions and 11 deletions

No files matched your search

+142
View File
@@ -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
+1 -1
View File
@@ -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).
+1 -1
View File
@@ -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.
+3 -2
View File
@@ -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).
+6 -7
View File
@@ -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).
+6
View File
@@ -280,6 +280,12 @@ protocol is in [`synthid-detector-removal-plan.md`](synthid-detector-removal-pla
Scope honestly: this tier certifies strength floors on a handful of images per vendor, and
that is all it can do. See `docs/synthid.md`.
The project therefore publishes no aggregate pass rate per model or resolution: a large
enough sample needs bulk automated checks, which the OpenAI restriction above and the
SynthID Detector's [additional terms](https://synthid.com/terms) both forbid. Public
removal claims rest on this method and on individual hash-bound rows such as
`data/evaluations/video-synthid-oracle.csv`, never on an extrapolated rate.
### D1. Sampling frame
Select a representative local sample without committing images, identifiers, dataset sizes, or oracle results. Stratify by signal family and preserve the sampling method outside the public repository.
+58
View File
@@ -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"}