From 334d198637bb9dad40f458b990f24c2e5fef2660 Mon Sep 17 00:00:00 2001 From: quantamixsol Date: Sun, 2 Aug 2026 15:55:10 +0200 Subject: [PATCH 1/2] CR-DIST-06: stop distribution manifests drifting from the shipped version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three manifests carried hand-maintained version strings that nothing updated. They reached 0.80.0 while PyPI served 0.83.0 — three releases stale. 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 that advertises a version we no longer ship, which reads as an abandoned project. server.json was subtler. The registry workflow already rewrote it INSIDE the CI checkout before publishing, so the published registry entry was always correct — but the rewrite was never committed, so the file in git stayed stale forever. Correct artifact, wrong repo. The two now agree. The two plugin manifests were touched by nothing at all, and users read those straight from the GitHub repo. Those were the genuinely broken ones. - scripts/ci/sync_manifest_versions.py — sets the version across all three. --check reports drift and exits 1. Strict PEP440 (a malformed version must never reach a public listing). A missing key raises rather than silently no-opping; an absent file is reported, not failed. Output format matches how the files are stored, so a sync is a one-line diff, not a reformat. - publish-mcp-registry.yml — calls the script instead of the inline server.json-only heredoc, and commits the result back to master AFTER the publish+verify steps. Committing a version we then failed to publish would advertise a release that does not exist. - Drift guard test asserting every manifest == graqle.__version__ — this is what would have caught the 0.80.0 drift three releases ago. Sentinel BLOCKED pass 1 on two workflow defects, both correct, both fixed: 1. `git checkout master` fails on the default shallow single-branch checkout — no local master ref exists, so the step dies with "pathspec 'master' did not match any file(s)" AFTER a successful publish, leaving manifests stale. Reproduced locally with `git clone --depth 1 --branch v0.83.0 --single-branch` before fixing. Fixed with fetch-depth: 0. 2. `git pull --ff-only` hard-fails if anything lands on master mid-job. Now a 3-attempt fetch/reset/sync/push loop; the sync is idempotent so replaying on newer master is safe. After 3 failed attempts this warns rather than failing the job: the package is already published and the registry entry already correct, so failing a green release over a cosmetic listing would train people to ignore red. The drift guard then catches it on the next PR instead of letting it rot for three releases. Sentinel reviewed that deviation explicitly and approved it. Mutation-tested: reverting server.json to 0.80.0 makes the drift guard fail; restoring makes it pass. 7 new tests; packaging + licensing 329 passed 0 failed. Sentinel pass 2: APPROVE, 0 blockers, 91%. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/publish-mcp-registry.yml | 90 +++++++++-- .../graqle/.claude-plugin/plugin.json | 2 +- .../codex/graqle/.codex-plugin/plugin.json | 2 +- scripts/ci/sync_manifest_versions.py | 141 ++++++++++++++++++ server.json | 4 +- .../test_manifest_version_sync.py | 135 +++++++++++++++++ 6 files changed, 357 insertions(+), 17 deletions(-) create mode 100644 scripts/ci/sync_manifest_versions.py create mode 100644 tests/test_packaging/test_manifest_version_sync.py diff --git a/.github/workflows/publish-mcp-registry.yml b/.github/workflows/publish-mcp-registry.yml index 5d17006f..5a77689c 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,62 @@ 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 + + git add server.json \ + plugins/claude-code/graqle/.claude-plugin/plugin.json \ + plugins/codex/graqle/.codex-plugin/plugin.json + # [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 52dff59b..de62b55a 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 27f3f1a7..8d3e3269 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 00000000..8e0b1a1a --- /dev/null +++ b/scripts/ci/sync_manifest_versions.py @@ -0,0 +1,141 @@ +#!/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"]), +] + +# 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. + path.write_text(json.dumps(data, indent=2) + "\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 07f37e57..1f79f60e 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 00000000..1b1642b4 --- /dev/null +++ b/tests/test_packaging/test_manifest_version_sync.py @@ -0,0 +1,135 @@ +"""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"]), +] + + +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" From e38ed3441c2d031456cbf62bf214921b80a206a6 Mon Sep 17 00:00:00 2001 From: quantamixsol Date: Sun, 2 Aug 2026 22:13:15 +0200 Subject: [PATCH 2/2] CR-DIST-06 follow-up: the marketplace manifests were missed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found while checking what a Claude Code directory reviewer actually fetches: .claude-plugin/marketplace.json still advertised 0.80.0. Root cause of the miss: the marketplace version is NESTED under plugins[0]. A top-level grep for '"version"' finds only metadata.version and walks straight past it, which is how I concluded these files "carry no version" in the first pass. - Both marketplace manifests added as sync targets (plugins.0.version). - metadata.version deliberately NOT synced: it is the marketplace SCHEMA version (1.0.0), not the SDK release. Syncing it would corrupt the manifest. Verified it still reads 1.0.0 after a sync. - ensure_ascii=False when writing: the marketplace description contains a real em-dash, and default json.dumps rewrote it as — — turning a one-line version bump into a mojibake diff on a file reviewers read. - Workflow now `git add -A` instead of an explicit file list. The script owns which files are targets; an explicit list silently drops targets added later, which is precisely the failure this commit is fixing. Safe because the retry loop resets to origin/master first, so nothing else can be dirty. 9 tests (was 7). Diff is 2 version lines, no reformatting. Co-Authored-By: Claude Opus 5 (1M context) --- .agents/plugins/marketplace.json | 2 +- .claude-plugin/marketplace.json | 2 +- .github/workflows/publish-mcp-registry.yml | 8 +++++--- scripts/ci/sync_manifest_versions.py | 17 ++++++++++++++++- .../test_manifest_version_sync.py | 2 ++ 5 files changed, 25 insertions(+), 6 deletions(-) diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index ba75dba6..31633dbe 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 7f57b24c..029ce200 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 5a77689c..a44ab2aa 100644 --- a/.github/workflows/publish-mcp-registry.yml +++ b/.github/workflows/publish-mcp-registry.yml @@ -100,9 +100,11 @@ jobs: exit 0 fi - git add server.json \ - plugins/claude-code/graqle/.claude-plugin/plugin.json \ - plugins/codex/graqle/.codex-plugin/plugin.json + # -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]" diff --git a/scripts/ci/sync_manifest_versions.py b/scripts/ci/sync_manifest_versions.py index 8e0b1a1a..a695c9d3 100644 --- a/scripts/ci/sync_manifest_versions.py +++ b/scripts/ci/sync_manifest_versions.py @@ -39,6 +39,15 @@ ("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 @@ -116,7 +125,13 @@ def main() -> int: 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. - path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + # 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: diff --git a/tests/test_packaging/test_manifest_version_sync.py b/tests/test_packaging/test_manifest_version_sync.py index 1b1642b4..9c86224a 100644 --- a/tests/test_packaging/test_manifest_version_sync.py +++ b/tests/test_packaging/test_manifest_version_sync.py @@ -26,6 +26,8 @@ ("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"]), ]