diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1d8cd4027..91b42b0a7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -409,11 +409,23 @@ jobs: # product, the registry entry is metadata. A registry-preview outage # must never block shipping. Re-run this job alone to retry — it does # not touch npm/PyPI, so retries are safe. + # + # It DOES depend on publish-final, which is the job that un-drafts the + # release. Both used to need only publish-registries, so they raced: the + # registry validates every package URL it is given by fetching it, and a + # DRAFT release's assets are not publicly readable. In v0.10.3 the registry + # ran 5s before the un-draft and was told its own .mcpb URL was a 404: + # MCPB package '...codebase-memory-mcp-darwin-amd64.mcpb' is not publicly + # accessible (status: 404) + # The same URL served 200 once the release went public, and the job passed on + # a plain re-run. Depending on publish-final keeps the documented intent + # exactly — the registry still cannot block the release, it just runs after + # the assets it validates actually exist to the outside world. publish-mcp-registry: - needs: [publish-registries] + needs: [publish-registries, publish-final] # Skipped for prereleases: the MCP Registry has no channel concept, so an # RC would present itself as the current version to every registry consumer. - if: ${{ !cancelled() && !failure() && needs.publish-registries.result == 'success' && !contains(inputs.version, '-') }} + if: ${{ !cancelled() && !failure() && needs.publish-registries.result == 'success' && needs.publish-final.result == 'success' && !contains(inputs.version, '-') }} runs-on: ubuntu-latest permissions: id-token: write # GitHub OIDC auth to the MCP Registry diff --git a/tests/test_release_gate_chain_contract.sh b/tests/test_release_gate_chain_contract.sh index 3d7ceb7ac..f59440c46 100755 --- a/tests/test_release_gate_chain_contract.sh +++ b/tests/test_release_gate_chain_contract.sh @@ -84,6 +84,31 @@ if "needs.soak.result" not in draft: " legitimately skipped soak (soak_level=none) is distinguishable\n" " from a soak that never ran.") +# The MCP registry validates every package URL it is handed by FETCHING it, and +# a DRAFT release's assets are not publicly readable. publish-final is the job +# that un-drafts. Both jobs used to need only publish-registries, so they raced +# and the registry was told its own .mcpb URL was a 404 (v0.10.3). The registry +# must therefore run AFTER the release is public — while still never gating it. +def needs(job): + """The job's `needs:` list as a single line.""" + m = re.search(r"^ needs:\s*(.*)$", blocks.get(job, ""), re.M) + return m.group(1).strip() if m else "" + +if "publish-mcp-registry" not in blocks: + failures.append("publish-mcp-registry: job missing from release.yml — update this contract") +elif "publish-final" not in needs("publish-mcp-registry"): + failures.append( + "publish-mcp-registry: must `needs:` publish-final. The registry fetches\n" + " the .mcpb URLs it validates, and a draft release's assets 404 —\n" + " running it in parallel with the un-draft is a race it loses.") + +# ...and the reverse must NEVER hold: a registry outage must not block shipping. +if "publish-final" in blocks and "publish-mcp-registry" in needs("publish-final"): + failures.append( + "publish-final: must NOT depend on publish-mcp-registry. The binary\n" + " release is the product and the registry entry is metadata; a\n" + " registry-preview outage must never hold up a release.") + if failures: for f in failures: print("FAIL: " + f, file=sys.stderr)