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 .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
{
"name": "popili",
"source": "./popili",
"description": "Allows the Popili formal verification tools to be used on Coco programs within Claude Code"
"description": "Popili formal verification and code intelligence for the Coco language"
},
{
"name": "popili-beta",
Expand Down
7 changes: 5 additions & 2 deletions .github/scripts/github_app_token.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,10 @@ def get_installation_token(jwt: str, installation_id: str) -> str:
},
)
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())['token']
token = json.loads(resp.read())['token']
if not isinstance(token, str):
sys.exit(f"Error: GitHub returned a {type(token).__name__} installation token, expected a string")
return token


def env(name: str) -> str:
Expand All @@ -77,7 +80,7 @@ def env(name: str) -> str:
return value


def main():
def main() -> None:
jwt = create_jwt(
app_id=env('COCOTEC_BOT_APP_ID'),
project=env('GCP_KMS_PROJECT'),
Expand Down
36 changes: 16 additions & 20 deletions .github/scripts/sync_plugin.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
#!/usr/bin/env python3
"""Download and extract the Popili Claude Code plugin from dl.cocotec.io.
"""Download a released plugin version and extract it over the right channel directory.

Determines the target plugin directory (popili or popili-beta) based on the
version string, downloads the plugin zip, extracts it, and validates that
the plugin.json version was stamped correctly.

Prints the plugin directory name to stdout for use by the workflow.
Prints the plugin directory name to stdout for the workflow.
"""

import argparse
Expand All @@ -16,6 +12,10 @@
import zipfile
from pathlib import Path

from validate import AGENT_MANIFEST, validate_plugin

ARCHIVE = "popili-agent-plugin.zip"


def determine_channel(version: str) -> str:
"""Return the plugin directory name based on version string."""
Expand All @@ -24,36 +24,32 @@ def determine_channel(version: str) -> str:
return "popili"


def main():
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--version", required=True)
args = parser.parse_args()

zip_path = Path("claude-code-plugin.zip")
zip_path = Path(ARCHIVE)
target = Path(determine_channel(args.version))

url = f"https://dl.cocotec.io/popili/archive/{args.version}/claude-code-plugin.zip"
url = f"https://dl.cocotec.io/popili/archive/{args.version}/{ARCHIVE}"
print(f"Downloading {url}", file=sys.stderr)
urllib.request.urlretrieve(url, zip_path)

if target.exists():
shutil.rmtree(target)
target.mkdir()

with zipfile.ZipFile(zip_path) as zf:
zf.extractall(target)
with zipfile.ZipFile(zip_path) as archive:
archive.extractall(target)
zip_path.unlink()

plugin_json = target / ".claude-plugin" / "plugin.json"
if not plugin_json.exists():
sys.exit("ERROR: plugin.json not found in extracted zip")

with open(plugin_json) as f:
version = json.load(f)["version"]

if version == "{STABLE_BUILD_SEMANTIC_VERSION}":
sys.exit("ERROR: plugin.json still contains template variable")
if problems := validate_plugin(target):
for problem in problems:
print(f"ERROR: {problem}", file=sys.stderr)
sys.exit(f"{ARCHIVE} for {args.version} is not loadable; refusing to open a pull request")

version = json.loads((target / AGENT_MANIFEST).read_text())["version"]
print(f"Plugin version: {version}", file=sys.stderr)
print(target)

Expand Down
142 changes: 142 additions & 0 deletions .github/scripts/validate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""Check the marketplace catalogue and that each plugin directory is complete.

Run with no arguments to check every plugin the marketplace lists. Import `validate_plugin` to check
a single directory.

Deliberately thin: manifest contents are validated before release, so this is a smoke test, not a
second implementation of the Agent Plugins spec.
"""

import json
from pathlib import Path
import sys
from typing import Any

Json = dict[str, Any]

PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"
MCP_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json"

MARKETPLACE = Path(".claude-plugin") / "marketplace.json"

# Claude Code reads the first, every other client the second.
CLAUDE_MANIFEST = Path(".claude-plugin") / "plugin.json"
AGENT_MANIFEST = Path("plugin.json")

# Checked for presence only; a partial extraction or a stray deletion is what this catches.
EXPECTED_FILES = (
Path(".mcp.json"),
Path(".lsp.json"),
Path("com.github.copilot") / "lsp.json",
)


def read_json(path: Path, problems: list[str]) -> Json | None:
if not path.exists():
problems.append(f"{path}: missing")
return None
try:
parsed = json.loads(path.read_text())
except json.JSONDecodeError as error:
problems.append(f"{path}: invalid JSON: {error}")
return None
if not isinstance(parsed, dict):
problems.append(f"{path}: must contain a JSON object, found {type(parsed).__name__}")
return None
return parsed


def validate_plugin(root: Path) -> list[str]:
"""Return every problem found in the plugin directory at `root`; empty means it looks installable."""
problems: list[str] = []

for relative in EXPECTED_FILES:
if not (root / relative).is_file():
problems.append(f"{root / relative}: missing")

agent_manifest = read_json(root / AGENT_MANIFEST, problems)
claude_manifest = read_json(root / CLAUDE_MANIFEST, problems)

if agent_manifest is not None and claude_manifest is not None:
version = agent_manifest.get("version")
if not isinstance(version, str):
problems.append(f"{root / AGENT_MANIFEST}: no version string")
elif "{" in version:
problems.append(f"{root / AGENT_MANIFEST}: unsubstituted template variable {version!r}")
if version != claude_manifest.get("version"):
problems.append(
f"{root}: manifests disagree on version: {AGENT_MANIFEST} has {version!r}, "
f"{CLAUDE_MANIFEST} has {claude_manifest.get('version')!r}"
)

# The field that decides whether a client outside Claude Code loads this at all.
if agent_manifest is not None and agent_manifest.get("$schema") != PLUGIN_SCHEMA:
problems.append(
f"{root / AGENT_MANIFEST}: $schema is {agent_manifest.get('$schema')!r}, must be exactly "
f"{PLUGIN_SCHEMA!r}"
)

portable_mcp = read_json(root / "mcp.json", problems)
if portable_mcp is not None and portable_mcp.get("$schema") != MCP_SCHEMA:
problems.append(f"{root}/mcp.json: $schema is {portable_mcp.get('$schema')!r}, must be exactly {MCP_SCHEMA!r}")

return problems


def validate_marketplace() -> tuple[list[str], list[Path]]:
"""A bad entry installs nothing while every plugin directory looks fine."""
problems: list[str] = []
marketplace = read_json(MARKETPLACE, problems)
if marketplace is None:
return problems, []

entries = marketplace.get("plugins")
if not isinstance(entries, list):
problems.append(f"{MARKETPLACE}: plugins must be a list")
return problems, []

roots: list[Path] = []
seen: set[str] = set()
for entry in entries:
if not isinstance(entry, dict):
problems.append(f"{MARKETPLACE}: every plugins entry must be an object")
continue
name = entry.get("name")
if not isinstance(name, str) or not name:
problems.append(f"{MARKETPLACE}: an entry has no name")
continue
if name in seen:
problems.append(f"{MARKETPLACE}: duplicate plugin name {name!r}")
seen.add(name)
if not entry.get("description"):
problems.append(f"{MARKETPLACE}: {name} has no description")

source = entry.get("source")
if not isinstance(source, str):
problems.append(f"{MARKETPLACE}: {name} has no source path")
continue
root = Path(source)
if not root.is_dir():
problems.append(f"{MARKETPLACE}: {name} points at {source}, which is not a directory")
else:
roots.append(root)

return problems, roots


def main() -> None:
problems, roots = validate_marketplace()
for root in roots:
problems += validate_plugin(root)

if problems:
for problem in problems:
print(f"FAIL: {problem}", file=sys.stderr)
sys.exit(1)

print(f"{len(roots)} plugin(s) validated: {', '.join(str(root) for root in roots)}")


if __name__ == "__main__":
main()
63 changes: 54 additions & 9 deletions .github/workflows/sync-plugin.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,16 @@ on:
permissions:
contents: write
id-token: write

# Queue rather than cancel: every release is meant to produce a sync.
concurrency:
group: sync-plugin
cancel-in-progress: false

jobs:
sync:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6

Expand Down Expand Up @@ -39,24 +46,62 @@ jobs:
GCP_KMS_KEY: ${{ secrets.GCP_KMS_KEY }}
GCP_KMS_KEY_VERSION: ${{ secrets.GCP_KMS_KEY_VERSION }}

# Via env, not `${{ }}` interpolation, which bash would see as code.
- name: Sync plugin
id: sync
shell: bash
run: |
PLUGIN_DIR=$(python3 .github/scripts/sync_plugin.py --version ${{ inputs.version }})
echo "plugin_dir=$PLUGIN_DIR" >> $GITHUB_OUTPUT
PLUGIN_DIR=$(python3 .github/scripts/sync_plugin.py --version "$VERSION")
echo "plugin_dir=$PLUGIN_DIR" >> "$GITHUB_OUTPUT"
env:
VERSION: ${{ inputs.version }}

- name: Create Pull Request
shell: bash
run: |
PLUGIN_DIR="${{ steps.sync.outputs.plugin_dir }}"
VERSION="${{ inputs.version }}"
git checkout -b "sync/${PLUGIN_DIR}-${VERSION}"
set -euo pipefail
BRANCH="sync/${PLUGIN_DIR}-${VERSION}"

git config user.name "cocotec-automation[bot]"
git config user.email "244153713+cocotec-automation[bot]@users.noreply.github.com"
git checkout -b "$BRANCH"
git add "$PLUGIN_DIR"

# Re-dispatching an already-synced version is normal.
if git diff --cached --quiet; then
echo "::notice::${PLUGIN_DIR} is already at ${VERSION}; nothing to sync."
echo "\`${PLUGIN_DIR}\` already at \`${VERSION}\` — no changes." >> "$GITHUB_STEP_SUMMARY"
exit 0
fi

git commit --message "${PLUGIN_DIR}: update plugin to ${VERSION}"
git push --set-upstream origin HEAD
gh pr create --base main \
--title "${PLUGIN_DIR}: update plugin to ${VERSION}" \
--body "Automated update of the ${PLUGIN_DIR} plugin to version ${VERSION}."
# Bot-owned branch; a force can only discard a previous attempt at this sync.
git push --force --set-upstream origin "$BRANCH"

PR=$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number // empty')
if [ -n "$PR" ]; then
URL=$(gh pr view "$PR" --json url --jq .url)
else
# printf: a block scalar leaks indentation and a heredoc eats the backticks.
BODY=$(printf '%s\n' \
"Merging publishes \`${VERSION}\` to everyone installing \`${PLUGIN_DIR}@cocotec\`." \
"" \
"Auto-merge is on, so approving this is enough.")
URL=$(gh pr create --base main \
--title "${PLUGIN_DIR}: update plugin to ${VERSION}" \
--body "$BODY")
PR="$URL"
fi

# Non-fatal: the pull request exists either way and can be merged by hand.
if MERGE_ERROR=$(gh pr merge "$PR" --auto --squash 2>&1); then
echo "::notice::Auto-merge enabled; merges once approved."
else
echo "::warning::Could not enable auto-merge on ${URL}: ${MERGE_ERROR}"
fi

echo "Synced \`${PLUGIN_DIR}\` to \`${VERSION}\`: ${URL}" >> "$GITHUB_STEP_SUMMARY"
env:
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
PLUGIN_DIR: ${{ steps.sync.outputs.plugin_dir }}
VERSION: ${{ inputs.version }}
40 changes: 40 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: Validate
on:
pull_request:
push:
branches: [main]
merge_group:

permissions:
contents: read

concurrency:
group: validate-${{ github.ref }}
cancel-in-progress: true

jobs:
# `build` is the required check in the ruleset.
build:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v6

- name: Validate plugins
run: python3 .github/scripts/validate.py

- name: Compile scripts
run: python3 -m compileall -q .github/scripts

# Not the required check: `build` stays stdlib-only so an outage upstream cannot block a sync.
typecheck:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v6

- name: Typecheck scripts
# Run from inside the directory so `import validate` resolves. Pinned: --strict gains checks.
run: |
pip install --quiet mypy==2.3.1
cd .github/scripts && mypy --strict .
Loading
Loading