diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index ba75dba..31633db 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -13,7 +13,7 @@ "name": "graqle", "source": "./plugins/codex/graqle", "description": "Graph-powered codebase reasoning, impact analysis, and governed edits via the GraQle MCP server, plus governed-workflow skills.", - "version": "0.80.0", + "version": "0.83.0", "author": { "name": "Quantamix Solutions", "url": "https://graqle.com" diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 7f57b24..029ce20 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -13,7 +13,7 @@ "name": "graqle", "source": "./plugins/claude-code/graqle", "description": "Graph-powered codebase reasoning, impact analysis, and governed edits via the GraQle MCP server, plus governed-workflow skills and an optional governance gate hook.", - "version": "0.80.0", + "version": "0.83.0", "author": { "name": "Quantamix Solutions", "url": "https://graqle.com" diff --git a/.github/workflows/publish-mcp-registry.yml b/.github/workflows/publish-mcp-registry.yml index 5d17006..a44ab2a 100644 --- a/.github/workflows/publish-mcp-registry.yml +++ b/.github/workflows/publish-mcp-registry.yml @@ -18,27 +18,32 @@ jobs: runs-on: ubuntu-latest permissions: id-token: write - contents: read + # CR-DIST-06: 'write' (was 'read') so the manifest sync below can commit the + # version back to master. Without the commit the repo copy stays stale forever + # — which is exactly how the plugin manifests reached 0.80.0 while PyPI served + # 0.83.0. The published artifact was always right; the file people READ was not. + contents: write steps: - uses: actions/checkout@v4 with: ref: ${{ github.event.workflow_run.head_sha }} + # CR-DIST-06: full history, not the default shallow single-branch fetch. + # Without this the checkout holds ONLY the tag SHA in detached HEAD and no + # local `master` ref exists, so the commit-back step below dies with + # "pathspec 'master' did not match any file(s) known to git" — AFTER the + # registry publish has already succeeded. Verified by reproducing the exact + # shallow clone locally. + fetch-depth: 0 - - name: Update server.json version from tag + # CR-DIST-06: sync EVERY distribution manifest, not just server.json. + # server.json was already rewritten here before publishing, so the registry + # entry was correct — but plugins/claude-code and plugins/codex were never + # touched by anything, and users read those straight from the repo. + - name: Sync all distribution manifests to the released version run: | TAG="${{ github.event.workflow_run.head_branch }}" VERSION="${TAG#v}" - echo "Updating server.json to version ${VERSION}" - # Use Python with env var to avoid shell/Python quoting conflicts - VERSION="${VERSION}" python3 - <<'PYEOF' - import json, os, pathlib - version = os.environ["VERSION"] - s = json.loads(pathlib.Path('server.json').read_text()) - s['version'] = version - s['packages'][0]['version'] = version - pathlib.Path('server.json').write_text(json.dumps(s, indent=2) + '\n') - print(f'Updated server.json to version {version}') - PYEOF + python3 scripts/ci/sync_manifest_versions.py "${VERSION}" - name: Wait for PyPI to index the new version run: sleep 60 @@ -58,3 +63,64 @@ jobs: run: | sleep 5 curl -sf "https://registry.modelcontextprotocol.io/v0.1/servers?search=io.github.quantamixsol/graqle" | python3 -m json.tool + + # CR-DIST-06: commit the synced manifests back to master. + # + # Deliberately LAST — after the registry publish has succeeded. Committing a + # version we then failed to publish would leave the repo advertising a release + # that does not exist, which is worse than the drift this fixes. + # + # Runs on the default branch, not the tag: a tag is a fixed point and a commit + # on it would be unreachable. `git checkout master` picks up any commits that + # landed since the tag, so this never force-moves anything. + - name: Commit synced manifests back to master + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + TAG="${{ github.event.workflow_run.head_branch }}" + VERSION="${TAG#v}" + + git checkout master + + # Retry, do NOT --ff-only. Any commit landing on master between the start + # of this job and now (a hotfix, a concurrent release, a docs push) makes a + # fast-forward impossible, and --ff-only would kill the job AFTER a + # successful publish — leaving the manifests stale, the exact defect this + # fixes. The sync is idempotent, so replaying it on newer master is safe. + for attempt in 1 2 3; do + git fetch origin master + git reset --hard origin/master + + python3 scripts/ci/sync_manifest_versions.py "${VERSION}" + + if git diff --quiet; then + echo "Manifests already at ${VERSION} on master — nothing to commit." + exit 0 + fi + + # -A over an explicit list: the script owns which files are targets, and + # an explicit list here silently drops any target added later — which is + # exactly how the two marketplace manifests were missed the first time. + # Nothing else can be dirty; the loop resets to origin/master first. + git add -A + # [skip ci] guards the push-triggered workflows. This workflow itself is + # workflow_run-triggered on CI completion, so it cannot self-retrigger. + git commit -m "chore(dist): sync distribution manifests to ${VERSION} [skip ci]" + + if git push origin master; then + echo "Synced manifests to ${VERSION} on attempt ${attempt}." + exit 0 + fi + + echo "push rejected (master moved) — retrying (${attempt}/3)" + sleep 10 + done + + # Never fail the release over a listing sync: the package is already + # published and the registry entry is already correct. Warn loudly so the + # drift guard in tests/test_packaging/test_manifest_version_sync.py catches + # it on the next PR rather than letting it rot silently for three releases. + echo "::warning::Could not commit synced manifests after 3 attempts. \ + Run: python scripts/ci/sync_manifest_versions.py ${VERSION}" diff --git a/plugins/claude-code/graqle/.claude-plugin/plugin.json b/plugins/claude-code/graqle/.claude-plugin/plugin.json index 52dff59..de62b55 100644 --- a/plugins/claude-code/graqle/.claude-plugin/plugin.json +++ b/plugins/claude-code/graqle/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "graqle", - "version": "0.80.0", + "version": "0.83.0", "description": "GraQle dev intelligence layer: graph-powered codebase reasoning, impact analysis, and governed edits via MCP, plus governed-workflow skills and an optional governance gate hook.", "author": { "name": "Quantamix Solutions", diff --git a/plugins/codex/graqle/.codex-plugin/plugin.json b/plugins/codex/graqle/.codex-plugin/plugin.json index 27f3f1a..8d3e326 100644 --- a/plugins/codex/graqle/.codex-plugin/plugin.json +++ b/plugins/codex/graqle/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "graqle", - "version": "0.80.0", + "version": "0.83.0", "description": "GraQle dev intelligence layer: graph-powered codebase reasoning, impact analysis, and governed edits via MCP, plus governed-workflow skills.", "interface": { "displayName": "GraQle", diff --git a/scripts/ci/sync_manifest_versions.py b/scripts/ci/sync_manifest_versions.py new file mode 100644 index 0000000..a695c9d --- /dev/null +++ b/scripts/ci/sync_manifest_versions.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +"""Sync every distribution manifest to the released version (CR-DIST-06). + +Why this exists +--------------- +Three manifests carry a hand-maintained version string. Nothing updated them, so +they drifted to ``0.80.0`` while PyPI served ``0.83.0`` — three releases stale. + +That drift is *not* a paywall leak: the Claude Code / Codex plugins invoke ``graq`` +from the user's own environment and pin no version, so a stale manifest never +installs old code. It is a **listing** problem — a directory entry that advertises +a version we no longer ship, which reads as an abandoned project. + +``server.json`` is a special case. The MCP Registry workflow already rewrites it +from the tag *inside the CI checkout* before publishing, so the published registry +entry has always been correct. But that rewrite is never committed, so the file in +git stays stale forever. This script fixes the repo copy too, which keeps the two +in agreement and stops the next person "fixing" a bug that isn't there. + +Usage +----- + python scripts/ci/sync_manifest_versions.py 0.83.0 # write + python scripts/ci/sync_manifest_versions.py 0.83.0 --check # verify only + +``--check`` exits 1 when anything is out of sync, so CI can fail a release that +would ship a stale listing. +""" + +from __future__ import annotations + +import argparse +import json +import pathlib +import re +import sys + +# (path, list-of-dotted-key-paths-to-set). A dotted path may index a list: "packages.0.version". +TARGETS: list[tuple[str, list[str]]] = [ + ("server.json", ["version", "packages.0.version"]), + ("plugins/claude-code/graqle/.claude-plugin/plugin.json", ["version"]), + ("plugins/codex/graqle/.codex-plugin/plugin.json", ["version"]), + # The marketplace manifests are what a directory reviewer fetches. Their version + # is NESTED under plugins[0] — a top-level grep for '"version"' finds only + # metadata.version and misses it, which is exactly how these two were left out + # of the first pass of this CR. + # + # metadata.version is deliberately NOT synced: it is the marketplace *schema* + # version (1.0.0), not the SDK release. Syncing it would corrupt the manifest. + (".claude-plugin/marketplace.json", ["plugins.0.version"]), + (".agents/plugins/marketplace.json", ["plugins.0.version"]), +] + +# PEP 440 core release + optional pre/post/dev suffix. Deliberately strict: a +# malformed version must not be written into a public listing. +_VERSION_RE = re.compile(r"^\d+\.\d+\.\d+(?:[abc]|rc)?\d*(?:\.(?:post|dev)\d+)?$") + + +def _set_path(obj, dotted: str, value: str) -> bool: + """Set ``dotted`` on ``obj``. Returns True if the value actually changed. + + Missing keys are an error, not a silent no-op: a manifest that lost its + version field would otherwise sync "successfully" while staying stale. + """ + parts = dotted.split(".") + cur = obj + for p in parts[:-1]: + if isinstance(cur, list): + cur = cur[int(p)] + else: + if p not in cur: + raise KeyError(dotted) + cur = cur[p] + last = parts[-1] + if isinstance(cur, list): + idx = int(last) + old, cur[idx] = cur[idx], value + return old != value + if last not in cur: + raise KeyError(dotted) + old, cur[last] = cur[last], value + return old != value + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("version", help="Release version, e.g. 0.83.0 (a leading 'v' is stripped)") + ap.add_argument("--check", action="store_true", + help="Report drift and exit 1 without writing anything.") + ap.add_argument("--root", default=".", help="Repo root (default: cwd)") + args = ap.parse_args() + + version = args.version.lstrip("v").strip() + if not _VERSION_RE.match(version): + print(f"ERROR: {version!r} is not a valid release version", file=sys.stderr) + return 2 + + root = pathlib.Path(args.root) + drifted: list[str] = [] + missing: list[str] = [] + + for rel, keys in TARGETS: + path = root / rel + if not path.exists(): + # A manifest that has been removed is not a failure — but say so, or a + # silently-skipped file looks identical to a synced one. + missing.append(rel) + continue + + data = json.loads(path.read_text(encoding="utf-8")) + changed = False + for key in keys: + try: + changed |= _set_path(data, key, version) + except (KeyError, IndexError, ValueError) as exc: + print(f"ERROR: {rel}: cannot resolve '{key}' ({exc})", file=sys.stderr) + return 2 + + if not changed: + print(f" ok {rel}") + continue + + drifted.append(rel) + if args.check: + print(f" DRIFT {rel} (expected {version})") + else: + # Trailing newline + 2-space indent matches how these files are stored, + # so a sync produces a one-line diff rather than reformatting the file. + # ensure_ascii=False: these manifests contain real UTF-8 (an em-dash in + # the marketplace description). Default json.dumps would rewrite it as + # —, turning a one-line version bump into a mojibake diff on a file + # that directory reviewers read. + path.write_text( + json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" + ) + print(f" synced {rel} -> {version}") + + for rel in missing: + print(f" absent {rel} (skipped)") + + if args.check and drifted: + print( + f"\n{len(drifted)} manifest(s) stale. Run:\n" + f" python scripts/ci/sync_manifest_versions.py {version}", + file=sys.stderr, + ) + return 1 + + if not drifted: + print(f"\nAll manifests already at {version}.") + else: + print(f"\n{len(drifted)} manifest(s) synced to {version}.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/server.json b/server.json index 07f37e5..1f79f60 100644 --- a/server.json +++ b/server.json @@ -8,12 +8,12 @@ "url": "https://github.com/quantamixsol/graqle", "source": "github" }, - "version": "0.80.0", + "version": "0.83.0", "packages": [ { "registryType": "pypi", "identifier": "graqle", - "version": "0.80.0", + "version": "0.83.0", "transport": { "type": "stdio" } diff --git a/tests/test_packaging/test_manifest_version_sync.py b/tests/test_packaging/test_manifest_version_sync.py new file mode 100644 index 0000000..9c86224 --- /dev/null +++ b/tests/test_packaging/test_manifest_version_sync.py @@ -0,0 +1,137 @@ +"""CR-DIST-06: distribution manifests must not drift from the shipped version. + +The defect this pins: three manifests carried a hand-maintained version string that +nothing updated. They reached ``0.80.0`` while PyPI served ``0.83.0`` — three releases +stale. Not a paywall leak (the plugins invoke ``graq`` from the user's own environment +and pin no version) but a listing that advertises a version we no longer ship. + +``server.json`` was a subtler case: the registry workflow rewrote it *inside the CI +checkout* before publishing, so the published entry was always right while the file in +git stayed stale forever. Correct artifact, wrong repo — the two must now agree. +""" + +from __future__ import annotations + +import json +import pathlib +import subprocess +import sys + +import pytest + +ROOT = pathlib.Path(__file__).resolve().parents[2] +SCRIPT = ROOT / "scripts" / "ci" / "sync_manifest_versions.py" + +MANIFESTS = [ + ("server.json", ["version", "packages.0.version"]), + ("plugins/claude-code/graqle/.claude-plugin/plugin.json", ["version"]), + ("plugins/codex/graqle/.codex-plugin/plugin.json", ["version"]), + (".claude-plugin/marketplace.json", ["plugins.0.version"]), + (".agents/plugins/marketplace.json", ["plugins.0.version"]), +] + + +def _sdk_version() -> str: + ns: dict = {} + exec((ROOT / "graqle" / "__version__.py").read_text(encoding="utf-8"), ns) + return ns["__version__"] + + +def _dig(obj, dotted: str): + cur = obj + for p in dotted.split("."): + cur = cur[int(p)] if isinstance(cur, list) else cur[p] + return cur + + +@pytest.mark.parametrize("rel,keys", MANIFESTS, ids=[m[0] for m in MANIFESTS]) +def test_manifest_matches_sdk_version(rel, keys): + """Every manifest version must equal graqle.__version__. + + This is the guard that would have caught the 0.80.0 drift three releases earlier. + """ + path = ROOT / rel + if not path.exists(): + pytest.skip(f"{rel} not present in this checkout") + + data = json.loads(path.read_text(encoding="utf-8")) + expected = _sdk_version() + for key in keys: + assert _dig(data, key) == expected, ( + f"{rel}:{key} is {_dig(data, key)!r} but the SDK ships {expected!r}. " + f"Run: python scripts/ci/sync_manifest_versions.py {expected}" + ) + + +def test_sync_script_check_mode_detects_drift(tmp_path): + """--check must EXIT NON-ZERO on drift. + + A checker that cannot fail is decoration. Build a deliberately stale manifest and + assert the script rejects it. + """ + (tmp_path / "server.json").write_text( + json.dumps({"version": "0.1.0", "packages": [{"version": "0.1.0"}]}, indent=2), + encoding="utf-8", + ) + r = subprocess.run( + [sys.executable, str(SCRIPT), "9.9.9", "--check", "--root", str(tmp_path)], + capture_output=True, text=True, + ) + assert r.returncode == 1, f"stale manifest passed --check (rc={r.returncode})\n{r.stdout}" + assert "DRIFT" in r.stdout + + +def test_sync_script_writes_and_then_passes(tmp_path): + """A sync makes --check pass, and touches only the version fields.""" + original = { + "name": "io.github.quantamixsol/graqle", + "version": "0.1.0", + "packages": [{"registryType": "pypi", "identifier": "graqle", "version": "0.1.0"}], + } + p = tmp_path / "server.json" + p.write_text(json.dumps(original, indent=2), encoding="utf-8") + + w = subprocess.run( + [sys.executable, str(SCRIPT), "9.9.9", "--root", str(tmp_path)], + capture_output=True, text=True, + ) + assert w.returncode == 0, w.stderr + + after = json.loads(p.read_text(encoding="utf-8")) + assert after["version"] == "9.9.9" + assert after["packages"][0]["version"] == "9.9.9" + # Non-version fields must survive untouched — a sync is not a rewrite. + assert after["name"] == original["name"] + assert after["packages"][0]["identifier"] == "graqle" + + c = subprocess.run( + [sys.executable, str(SCRIPT), "9.9.9", "--check", "--root", str(tmp_path)], + capture_output=True, text=True, + ) + assert c.returncode == 0, c.stdout + + +def test_sync_script_rejects_malformed_version(tmp_path): + """A bad version must never reach a public listing.""" + (tmp_path / "server.json").write_text( + json.dumps({"version": "0.1.0", "packages": [{"version": "0.1.0"}]}), encoding="utf-8" + ) + for bad in ("not-a-version", "1.2", "", "v1.2.3.4.5"): + r = subprocess.run( + [sys.executable, str(SCRIPT), bad, "--root", str(tmp_path)], + capture_output=True, text=True, + ) + assert r.returncode == 2, f"accepted malformed version {bad!r}" + + +def test_leading_v_is_stripped(tmp_path): + """The workflow passes a git tag; 'v0.83.0' and '0.83.0' must behave identically.""" + p = tmp_path / "server.json" + p.write_text(json.dumps({"version": "0.1.0", "packages": [{"version": "0.1.0"}]}), + encoding="utf-8") + r = subprocess.run( + [sys.executable, str(SCRIPT), "v9.9.9", "--root", str(tmp_path)], + capture_output=True, text=True, + ) + assert r.returncode == 0, r.stderr + assert json.loads(p.read_text(encoding="utf-8"))["version"] == "9.9.9"