From dd2bd2484140cbbaa65d9a053e366c400c409275 Mon Sep 17 00:00:00 2001 From: diego-sigma <116198719+diego-sigma@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:49:11 -0400 Subject: [PATCH] Release and validate the Codex, Cursor and Cortex Code plugins The repository will carry plugins for four assistants under plugins/claude, plugins/codex, plugins/cursor and plugins/coco. Both workflows now handle all of them. Release: every plugin manifest on every host must agree on one X.Y.Z version, and a plugin folder without its host's manifest, or an unknown host folder, stops the release. Each plugin is archived per host: -.claude.plugin for Claude (the folder's contents, as before), and -.codex.zip, .cursor.zip or .coco.zip for the others (each holding the / folder). The changed-without-a-bump check now covers every host's plugins and every marketplace file. The existing safeguards stay: main only, no manual trigger, the version format check, the released-from-a-commit-not-on-main check, a per-step token, zip -y, and one release at a time. Validate: a new step checks the Codex, Cursor and Cortex Code plugins. Every JSON file parses, every plugin folder has its manifest, each marketplace lists exactly the plugin folders on disk, and every manifest and marketplace entry, Claude's included, carries the same version. The Claude CLI validation, the symlink refusal and the pinned actions are unchanged. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 58 ++++++++++++++++++++------ .github/workflows/validate.yml | 76 ++++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 24d0366..24e3a01 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,10 @@ name: Release # Merging a version bump is the approval to publish. When main carries a -# version with no release yet, tag it and attach one .plugin archive per -# plugin. A merge that doesn't change the version finds its release already -# there and does nothing, and so does a main with no plugins in it yet. +# version with no release yet, tag it and attach one archive per plugin per +# host: .claude.plugin for Claude, and .codex.zip, .cursor.zip and .coco.zip +# for the others. A merge that doesn't change the version finds its release +# already there and does nothing, and so does a main with no plugins in it yet. # # Only main publishes, and there is no manual trigger: a run from any other # branch would publish that branch's unreviewed content, and tags here can't @@ -29,25 +30,44 @@ jobs: with: persist-credentials: false - # Every plugin carries the same version; the tag is built from it, so - # anything other than X.Y.Z is refused before it reaches a tag name. + # Every plugin on every host carries the same version; the tag is built + # from it, so anything other than X.Y.Z is refused before it reaches a + # tag name. A plugin folder without its host's manifest, or a host this + # file doesn't know, is refused too, so nothing is archived unchecked. - name: Work out the version id: version run: | python3 - <<'PY' - import glob, json, os, re - manifests = sorted(glob.glob("plugins/claude/*/.claude-plugin/plugin.json")) + import json, os, re + from pathlib import Path + HOSTS = { + "claude": ".claude-plugin/plugin.json", + "codex": ".codex-plugin/plugin.json", + "cursor": ".cursor-plugin/plugin.json", + "coco": ".cortex-plugin/plugin.json", + } + manifests = [] + if Path("plugins").is_dir(): + for host_dir in sorted(Path("plugins").iterdir()): + if host_dir.name not in HOSTS: + raise SystemExit(f"plugins/{host_dir.name} isn't a known host: {sorted(HOSTS)}") + for plugin in sorted(p for p in host_dir.iterdir() if p.is_dir()): + manifest = plugin / HOSTS[host_dir.name] + if not manifest.is_file(): + raise SystemExit(f"{plugin} has no {HOSTS[host_dir.name]}") + manifests.append(manifest) with open(os.environ["GITHUB_OUTPUT"], "a") as out: if not manifests: print("No plugins yet; nothing to release.") out.write("none=true\n") raise SystemExit - versions = {json.load(open(m))["version"] for m in manifests} + versions = {json.loads(m.read_text())["version"] for m in manifests} if len(versions) != 1: raise SystemExit(f"Plugins disagree on the version: {sorted(versions)}") version = versions.pop() if not re.fullmatch(r"\d+\.\d+\.\d+", version): raise SystemExit(f"Version {version!r} isn't X.Y.Z") + print(f"{len(manifests)} plugins at {version}") out.write(f"tag=v{version}-beta\n") out.write(f"slug=v{version.replace('.', '-')}-beta\n") PY @@ -72,28 +92,42 @@ jobs: echo "::error::$TAG was released from a commit that isn't on main." exit 1 fi - changed="$(gh api "repos/$GITHUB_REPOSITORY/compare/$TAG...$GITHUB_SHA" -q '[.files[]?.filename | select(startswith("plugins/") or startswith(".claude-plugin/"))] | length')" + # Every host's plugins and every marketplace file. + changed="$(gh api "repos/$GITHUB_REPOSITORY/compare/$TAG...$GITHUB_SHA" -q '[.files[]?.filename | select(startswith("plugins/") or startswith(".claude-plugin/") or startswith(".agents/") or startswith(".cursor-plugin/"))] | length')" if [ "$changed" != "0" ]; then echo "::error::The plugins changed since $TAG without a version bump." exit 1 fi echo "exists=true" >> "$GITHUB_OUTPUT" + # A Claude archive holds the plugin folder's contents, which is what an + # upload install expects. The other hosts' archives hold the folder + # itself, so unzipping one gives a folder to install from. - name: Archive each plugin if: steps.released.outputs.exists == 'false' env: SLUG: ${{ steps.version.outputs.slug }} run: | mkdir dist - for dir in plugins/claude/*/; do + for dir in plugins/*/*/; do + host="$(basename "$(dirname "$dir")")" name="$(basename "$dir")" # -y stores a symlink as a link instead of following it. - (cd "$dir" && zip -qry "../../../dist/${name}-${SLUG}.plugin" . -x '*.DS_Store') + case "$host" in + claude) + (cd "$dir" && zip -qry "../../../dist/${name}-${SLUG}.claude.plugin" . -x '*.DS_Store') ;; + codex|cursor|coco) + (cd "plugins/$host" && zip -qry "../../dist/${name}-${SLUG}.${host}.zip" "$name" -x '*.DS_Store') ;; + *) + echo "::error::plugins/$host isn't a known host." + exit 1 ;; + esac done + ls dist - name: Publish the release if: steps.released.outputs.exists == 'false' env: GH_TOKEN: ${{ github.token }} TAG: ${{ steps.version.outputs.tag }} - run: gh release create "$TAG" dist/*.plugin --target "$GITHUB_SHA" --title "$TAG" --prerelease --generate-notes + run: gh release create "$TAG" dist/* --target "$GITHUB_SHA" --title "$TAG" --prerelease --generate-notes diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 0a5f737..e5dd1bb 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -51,3 +51,79 @@ jobs: - name: Validate every plugin's skills run: find plugins/claude -mindepth 2 -maxdepth 2 -type d -name skills -print0 | xargs -0 -n1 claude plugin validate --strict + + # The Claude CLI checks only Claude plugins. For Codex, Cursor and + # Cortex Code: every JSON file parses, every plugin folder has its + # manifest, each marketplace lists exactly the plugin folders there, + # and every manifest on every host, Claude's included, carries one + # version, the one Release tags. + - name: Check the other hosts' plugins + run: | + python3 - <<'PY' + import json + from pathlib import Path + MANIFESTS = { + "claude": ".claude-plugin/plugin.json", + "codex": ".codex-plugin/plugin.json", + "cursor": ".cursor-plugin/plugin.json", + "coco": ".cortex-plugin/plugin.json", + } + MARKETPLACES = { + "codex": ".agents/plugins/marketplace.json", + "cursor": ".cursor-plugin/marketplace.json", + } + errors, versions = [], {} + + def load(path): + try: + return json.loads(Path(path).read_text()) + except (OSError, ValueError) as e: + errors.append(f"{path}: {e}") + return None + + for host, manifest in MANIFESTS.items(): + host_dir = Path("plugins", host) + plugins = sorted(p for p in host_dir.iterdir() if p.is_dir()) if host_dir.is_dir() else [] + if host != "claude": + for f in sorted(host_dir.rglob("*.json")): + load(f) + for plugin in plugins: + if not (plugin / manifest).is_file(): + errors.append(f"{plugin} has no {manifest}") + continue + data = load(plugin / manifest) + if data is not None: + versions[str(plugin / manifest)] = data.get("version") + market = MARKETPLACES.get(host) + if market is None or not (plugins or Path(market).exists()): + continue + data = load(market) + if data is None: + continue + listed = set() + for entry in data.get("plugins", []): + source = entry.get("source") + if isinstance(source, dict): + source = source.get("path") + if not isinstance(source, str) or not (Path(source) / manifest).is_file(): + errors.append(f"{market}: {entry.get('name')} points at {source!r}, which has no {manifest}") + continue + listed.add(Path(source).resolve()) + if "version" in entry: + versions[f"{market} ({entry.get('name')})"] = entry["version"] + unlisted = [str(p) for p in plugins if p.resolve() not in listed] + if unlisted: + errors.append(f"{market} doesn't list {unlisted}") + + if len(set(versions.values())) > 1: + by_version = {} + for where, v in versions.items(): + by_version.setdefault(v, []).append(where) + errors.append("plugins disagree on the version: " + "; ".join( + f"{v!r} ({len(w)}, e.g. {w[0]})" for v, w in sorted(by_version.items(), key=str))) + for e in errors: + print(f"::error::{e}") + if errors: + raise SystemExit(1) + print(f"{len(versions)} manifests and marketplace entries at {sorted(set(versions.values()))}") + PY