From 6a7323b3b6ef1291adfd1b650ba2b6fbf0e673bc Mon Sep 17 00:00:00 2001 From: quantamixsol Date: Wed, 23 Sep 2026 16:32:02 +0200 Subject: [PATCH] fix(release): restore the MCP Registry marker and make gate failures diagnosable Two independent release-path defects found while auditing the v0.84.1 merge. Both are ports of work already on private master, or fixes to public-only gaps. 1. MCP REGISTRY OWNERSHIP MARKER (blocks every registry publish) Publishing to the MCP Registry has failed since 0.84.0 with HTTP 400: "PyPI package 'graqle' ownership validation failed. The server name must appear as 'mcp-name: io.github.quantamixsol/graqle' in the package README". Root cause: the marker lives in README.md, but pyproject.toml sets readme = "README_PYPI.md". The registry reads the PyPI long_description, not the GitHub README, so the published artifact never carried it. The marker was in the wrong file. Fixed by adding it to README_PYPI.md (CRLF preserved; the diff is 2 lines). VERIFIED IN THE BUILT ARTIFACT: graqle-0.84.1-py3-none-any.whl METADATA now contains the marker, which is what the registry actually reads. Ports tests/test_packaging/test_mcp_name_marker_in_pypi_readme.py from private. It asserts against the pyproject readme POINTER rather than a hard-coded filename, because the defect was a pointer move silently invalidating an assumption about which file ships. Proven by removing the marker: the guard fails, then passes once restored. 2. RELEASE GATE FAILURES WERE UNDIAGNOSABLE Release Gate (PyPI) has been red on every PR. Both provider-exception handlers logged only type(exc).__name__ -- no message, no traceback -- so every failure read as a bare class name and the cause stayed unknown across multiple releases. Now logger.exception with %r, so the operator log carries type, message and traceback. The verdict object is deliberately NOT changed: it is user-facing and the module's IP-redaction contract requires it stay free of internal detail. The two surfaces have opposite requirements and the new test asserts both -- exception text present in the log, absent from the verdict JSON. Proven: reverting to the old logging makes the new test fail. NOT INCLUDED, deliberately: making the gate fail CLOSED (WARN -> BLOCK) on internal error. Research asked for it, and it is the right end state, but it flips a governance contract pinned by four existing tests and documented as the module's "never-crash" behaviour. Changing what a gate does to a release needs an explicit decision, not a drive-by edit. Raised separately. Verified: 92 passed / 1 skipped across test_release_gate + test_packaging. TS scan on the diff: clean. Co-Authored-By: Claude Opus 5 (1M context) --- README_PYPI.md | 2 + graqle/release_gate/engine.py | 9 +- .../test_mcp_name_marker_in_pypi_readme.py | 94 +++++++++++++++++++ tests/test_release_gate/test_engine.py | 34 +++++++ 4 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 tests/test_packaging/test_mcp_name_marker_in_pypi_readme.py diff --git a/README_PYPI.md b/README_PYPI.md index f1171826..aa48185a 100644 --- a/README_PYPI.md +++ b/README_PYPI.md @@ -135,3 +135,5 @@ Optional compliance surfaces for regulated deployments cover the EU AI Act, SOX/ --- *Built by Quantamix Solutions B.V. · Patent pending EP26167849.4 · Local by default · Your code never leaves your machine* + + diff --git a/graqle/release_gate/engine.py b/graqle/release_gate/engine.py index b03c9df4..1f4cf845 100644 --- a/graqle/release_gate/engine.py +++ b/graqle/release_gate/engine.py @@ -149,7 +149,11 @@ async def gate( except asyncio.TimeoutError: return self._fallback_verdict(effective_target, reason="review_timeout") except Exception as exc: # pylint: disable=broad-except - logger.warning("release_gate review provider failed: %s", type(exc).__name__) + # Log the full exception (type, message, traceback) to the operator + # log. Diagnosing a gate that fails needs the actual error: logging + # only the class name is why the 0.84.0 failures were undiagnosable. + # The verdict object stays redacted — see _fallback_verdict. + logger.exception("release_gate review provider failed: %r", exc) return self._fallback_verdict(effective_target, reason="review_error") review = self._normalize_review(review_raw) @@ -166,7 +170,8 @@ async def gate( except asyncio.TimeoutError: return self._fallback_verdict(effective_target, reason="prediction_timeout") except Exception as exc: # pylint: disable=broad-except - logger.warning("release_gate prediction provider failed: %s", type(exc).__name__) + # Full exception to the operator log; the verdict stays redacted. + logger.exception("release_gate prediction provider failed: %r", exc) return self._fallback_verdict(effective_target, reason="prediction_error") prediction = self._normalize_prediction(prediction_raw) diff --git a/tests/test_packaging/test_mcp_name_marker_in_pypi_readme.py b/tests/test_packaging/test_mcp_name_marker_in_pypi_readme.py new file mode 100644 index 00000000..84f18224 --- /dev/null +++ b/tests/test_packaging/test_mcp_name_marker_in_pypi_readme.py @@ -0,0 +1,94 @@ +"""The MCP Registry ownership marker must live in the PyPI long_description. + +The defect this pins (0.84.0, 2026-09-15): publishing to the MCP Registry +failed with HTTP 400 — + + registry validation failed for package 0 (graqle): PyPI package 'graqle' + ownership validation failed. The server name 'io.github.quantamixsol/graqle' + must appear as 'mcp-name: io.github.quantamixsol/graqle' in the package + README + +Root cause: ```` lived in ``README.md``. CR-README-01 +repointed ``pyproject.readme`` to ``README_PYPI.md`` for conversion reasons and +did not carry the marker across, so the published ``long_description`` no +longer contained it. The registry reads the **PyPI** README, not the GitHub +one, so ownership validation failed at the first tag push after the change. + +0.83.0 published to the registry successfully; 0.84.0 was the first failure — +a clean break introduced by the pointer move. + +Why the test is written against ``pyproject.readme`` rather than against a +hard-coded filename: the whole defect was a *pointer change* silently +invalidating an assumption about which file ships. Asserting on the pointer +means any future repoint is caught here rather than at a release. +""" + +from __future__ import annotations + +import pathlib +import re + +import pytest + +ROOT = pathlib.Path(__file__).resolve().parents[2] +PYPROJECT = ROOT / "pyproject.toml" + +#: The server name registered with the MCP Registry. Must match ``server.json``. +SERVER_NAME = "io.github.quantamixsol/graqle" + +#: The exact marker string the registry greps for. +MARKER = f"mcp-name: {SERVER_NAME}" + + +def _readme_pointer() -> str: + """Return the filename ``pyproject.toml`` declares as the package readme.""" + text = PYPROJECT.read_text(encoding="utf-8") + m = re.search(r'^readme\s*=\s*"([^"]+)"', text, re.M) + assert m, "pyproject.toml has no top-level `readme = \"...\"` declaration" + return m.group(1) + + +def test_pypi_readme_contains_mcp_name_marker() -> None: + """Whatever file ships as long_description must carry the marker. + + Without it, ``mcp-publisher publish`` fails ownership validation and the + MCP Registry entry silently stops tracking releases. + """ + readme = ROOT / _readme_pointer() + assert readme.is_file(), f"pyproject readme points at missing file: {readme}" + + body = readme.read_text(encoding="utf-8") + assert MARKER in body, ( + f"{readme.name} does not contain {MARKER!r}. The MCP Registry reads the " + f"PyPI long_description (not README.md) and will reject the publish " + f"with HTTP 400 ownership-validation failure. Add " + f"`` to {readme.name}." + ) + + +def test_marker_server_name_matches_server_json() -> None: + """The marker must name the same server as ``server.json``. + + A mismatch fails validation just as surely as an absent marker. + """ + import json + + server_json = ROOT / "server.json" + if not server_json.exists(): + pytest.skip("server.json not present in this checkout") + + name = json.loads(server_json.read_text(encoding="utf-8-sig")).get("name") + assert name == SERVER_NAME, ( + f"server.json declares {name!r} but this test (and the README marker) " + f"expect {SERVER_NAME!r}. Update both together." + ) + + +def test_github_readme_keeps_its_marker_too() -> None: + """README.md keeps the marker for anyone reading the repo directly. + + Not required by the registry once the pointer moved, but removing it would + be a silent regression for GitHub-sourced tooling. + """ + body = (ROOT / "README.md").read_text(encoding="utf-8") + assert MARKER in body, "README.md lost its mcp-name marker" diff --git a/tests/test_release_gate/test_engine.py b/tests/test_release_gate/test_engine.py index 10fafc96..943f04ea 100644 --- a/tests/test_release_gate/test_engine.py +++ b/tests/test_release_gate/test_engine.py @@ -228,6 +228,40 @@ def test_gate_handles_review_provider_exception(): assert "review_error" in result.prediction_reasons +def test_provider_exception_text_reaches_the_operator_log(caplog): + """The real error must be diagnosable from the log, never from the verdict. + + Regression for the 0.84.0/0.84.1 release-gate failures: the handler logged + only ``type(exc).__name__``, so every CI failure read as a bare class name + with no message and no traceback, and the cause stayed unknown across + multiple releases. + + The two surfaces have opposite requirements and both are asserted here: + the operator log carries the full exception, while the verdict object stays + redacted because it is user-facing (see the module's IP-redaction contract). + """ + import logging + + eng = _engine( + review=FakeReviewProvider(raise_exc=RuntimeError("upstream 503 from review api")), + ) + with caplog.at_level(logging.ERROR, logger="graqle.release_gate.engine"): + result = asyncio.run(eng.gate(SAMPLE_DIFF, "pypi")) + + logged = caplog.text + assert "upstream 503 from review api" in logged, ( + "the exception message must reach the operator log — logging only the " + "exception class is what made the 0.84.0 failures undiagnosable" + ) + assert "RuntimeError" in logged + assert "Traceback" in logged, "logger.exception must attach the traceback" + + # ...and must NOT appear anywhere in the caller-visible verdict. + blob = json.dumps(result.to_dict()) + assert "upstream 503" not in blob + assert "RuntimeError" not in blob + + # ── 16. Prediction provider exception → WARN ───────────────────────────── def test_gate_handles_prediction_provider_exception():