Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/plugins/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
92 changes: 79 additions & 13 deletions .github/workflows/publish-mcp-registry.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}"
2 changes: 1 addition & 1 deletion plugins/claude-code/graqle/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion plugins/codex/graqle/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
156 changes: 156 additions & 0 deletions scripts/ci/sync_manifest_versions.py
Original file line number Diff line number Diff line change
@@ -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())
4 changes: 2 additions & 2 deletions server.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
Loading
Loading