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
58 changes: 46 additions & 12 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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
76 changes: 76 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading