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>
This commit is contained in:
Victor KuznetsovandClaude Opus 5.5 committed 2026-10-17 20:14:55 -07:00
1 parent 5677c22a6b
commit f0b2c2406e
6 files changed
+211 -11

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