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