From 78b8f700de35f3b22cc4a48cc172d4ee71e28e72 Mon Sep 17 00:00:00 2001 From: cdeust Date: Mon, 10 Aug 2026 18:59:56 +0200 Subject: [PATCH] fix(release): bound the registry-publish verification retry window The v3.1.1 release published cleanly (PyPI, GitHub Release, and MCP Registry with isLatest=true all agree) but the Release workflow still reported failure. The "Verify the registry now serves the published version" step read the registry one second after publish (16:48:46 -> 16:48:47) and compared with zero tolerance; the registry's own CDN cache layer (modelcontextprotocol/registry docs/design/tech-architecture.md, "CDN Layer") had not yet converged. A gate that fails on a successful release is the kind that gets disarmed, which would remove the only external proof that `mcp-publisher publish` actually worked. Fix: bounded retry (12 attempts x 5s = 60s) before concluding the registry never converged. The bound is a stated engineering assumption (no published cache-TTL SLA exists), sized an order of magnitude above the 1s staleness actually observed on the v3.1.1 release. It still fails, and fails with the same message, when the registry genuinely never serves the expected version -- this is a timeout, not an unbounded retry-until-pass. Also distinguishes the workflow_dispatch recovery path's duplicate case from a real failure: "cannot publish duplicate version" from mcp-publisher means "already published" -- the desired end state when re-running publish-registry to repair a missed publish -- but only for workflow_dispatch, and only as a candidate the verify step above still has to independently confirm. On the normal push-tag path the same error is unexpected (this job runs once per tag, gated on needs: [test, release]) and still fails the job immediately. Any other publish error, on either path, still fails immediately without touching the retry window. Co-Authored-By: Claude --- .github/workflows/Release.yaml | 74 ++++++++++++++++++++++++++++------ 1 file changed, 62 insertions(+), 12 deletions(-) diff --git a/.github/workflows/Release.yaml b/.github/workflows/Release.yaml index 32e32eb..5579f72 100644 --- a/.github/workflows/Release.yaml +++ b/.github/workflows/Release.yaml @@ -271,20 +271,70 @@ jobs: - name: Authenticate to the MCP Registry via GitHub OIDC run: ./mcp-publisher login github-oidc + # A "cannot publish duplicate version" 400 means the registry already + # has this exact version — the desired end state for the + # workflow_dispatch recovery path (RELEASING.md step 5: re-run this + # job for a tag whose publish was missed or failed). On the normal + # push-tag path the same error means something is wrong: this job + # only runs once per tag (needs: [test, release]), so an existing + # entry there is unexpected and must still fail loudly. The verify + # step below still runs after a swallowed duplicate and independently + # confirms the registry serves this version before the job can pass — + # this branch narrows *which* mcp-publisher exit codes are treated as + # non-fatal, it never widens what "passing" means. - name: Publish server.json to the MCP Registry - run: ./mcp-publisher publish + run: | + set +e + OUTPUT=$(./mcp-publisher publish 2>&1) + STATUS=$? + set -e + echo "$OUTPUT" + if [ "$STATUS" -ne 0 ]; then + if [ "${{ github.event_name }}" = "workflow_dispatch" ] && echo "$OUTPUT" | grep -qi "duplicate version"; then + echo "::notice::mcp-publisher reports ${{ steps.resolve.outputs.tag }} already published (workflow_dispatch recovery path) — treating as a candidate success, pending independent registry verification below." + else + echo "::error::mcp-publisher publish failed (exit ${STATUS})" + exit "$STATUS" + fi + fi - # A green `mcp-publisher publish` is not proof (Move 2: verify - # externally, not by exit code alone). Query the registry's own API - # and fail the job if it disagrees with server.json. + # A green `mcp-publisher publish` (or a swallowed duplicate-version + # error above) is not proof (Move 2: verify externally, not by exit + # code alone). Query the registry's own API and fail the job if it + # disagrees with server.json. + # + # Bounded retry, not zero-tolerance: the registry API sits behind a + # CDN cache in front of all public read endpoints (modelcontextprotocol/ + # registry docs/design/tech-architecture.md, "CDN Layer"), so a read + # immediately after publish can observe a stale, pre-publish entry. + # Measured on the v3.1.1 release (2026-08-09): publish completed at + # 16:48:46 UTC and a read one second later, 16:48:47, still returned + # the prior version — an empirical floor of >1s of staleness. No SLA + # for the cache TTL is published, so the bound below is a stated + # engineering assumption, not a measured constant: 12 attempts x 5s + # = 60s, an order of magnitude above the observed 1s staleness, meant + # to absorb ordinary CDN propagation without masking a genuine + # publish failure. It still fails — this is a timeout, not an + # unbounded retry-until-pass — when the registry never converges, + # which is exactly the case this check exists to catch. - name: Verify the registry now serves the published version run: | - set -euxo pipefail + set -euo pipefail VERSION=$(python3 -c "import json; print(json.load(open('server.json'))['packages'][0]['version'])") - ACTUAL=$(curl -fsSL "https://registry.modelcontextprotocol.io/v0/servers?search=hypermnesia-mcp-viz" \ - | python3 -c "import json, sys; d = json.load(sys.stdin); latest = next(s for s in d['servers'] if s['_meta']['io.modelcontextprotocol.registry/official']['isLatest']); print(latest['server']['version'])") - echo "Registry reports latest version: ${ACTUAL} (expected ${VERSION})" - if [ "$ACTUAL" != "$VERSION" ]; then - echo "::error::MCP Registry serves ${ACTUAL}, expected ${VERSION} after publish" - exit 1 - fi + MAX_ATTEMPTS=12 + SLEEP_SECONDS=5 + ACTUAL="" + for attempt in $(seq 1 "$MAX_ATTEMPTS"); do + ACTUAL=$(curl -fsSL "https://registry.modelcontextprotocol.io/v0/servers?search=hypermnesia-mcp-viz" \ + | python3 -c "import json, sys; d = json.load(sys.stdin); latest = next(s for s in d['servers'] if s['_meta']['io.modelcontextprotocol.registry/official']['isLatest']); print(latest['server']['version'])") + echo "Attempt ${attempt}/${MAX_ATTEMPTS}: registry reports latest version ${ACTUAL} (expected ${VERSION})" + if [ "$ACTUAL" = "$VERSION" ]; then + echo "Registry converged to ${VERSION} on attempt ${attempt}." + exit 0 + fi + if [ "$attempt" -lt "$MAX_ATTEMPTS" ]; then + sleep "$SLEEP_SECONDS" + fi + done + echo "::error::MCP Registry still serves ${ACTUAL}, expected ${VERSION} after ${MAX_ATTEMPTS} attempts (~$((MAX_ATTEMPTS * SLEEP_SECONDS))s). This is a non-convergence failure, not a propagation delay." + exit 1