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
20 changes: 20 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: Test validation scripts

on:
push:
paths:
- 'scripts/**'
- 'tests/**'
- '.github/workflows/test.yml'
pull_request:

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install pytest
- run: pytest tests/ -v
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
68 changes: 2 additions & 66 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,73 +15,9 @@ jobs:
- uses: actions/checkout@v4

- name: Validate marketplace.json schema
run: |
python3 -c "
import json, sys
with open('.claude-plugin/marketplace.json') as f:
m = json.load(f)
assert 'name' in m, 'missing name'
assert 'plugins' in m, 'missing plugins'
for p in m['plugins']:
assert 'name' in p, f'plugin missing name: {p}'
assert 'source' in p, f'plugin missing source: {p[\"name\"]}'
src = p['source']
if isinstance(src, dict) and src.get('source') == 'git-subdir':
assert 'url' in src, f'git-subdir missing url: {p[\"name\"]}'
assert 'path' in src, f'git-subdir missing path: {p[\"name\"]}'
print(f'OK: {len(m[\"plugins\"])} plugin(s) validated')
"
run: python3 scripts/validate_schema.py .claude-plugin/marketplace.json

- name: Check plugin sources are reachable
env:
GH_TOKEN: ${{ github.token }}
run: |
python3 -c "
import json, os, re, sys, urllib.request
with open('.claude-plugin/marketplace.json') as f:
m = json.load(f)
token = os.environ.get('GH_TOKEN', '')
api_headers = {'Accept': 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28'}
if token:
api_headers['Authorization'] = f'Bearer {token}'
errors = []
for p in m['plugins']:
src = p['source']
if not isinstance(src, dict) or src.get('source') != 'git-subdir':
continue
url = src['url']
ref = src.get('ref', 'main')
name = p['name']
match = re.match(r'https://github\.com/([^/]+)/([^/.]+)', url)
if not match:
errors.append(f'{name}: non-GitHub URL not supported yet: {url}')
continue
owner, repo = match.group(1), match.group(2)
# Try branch first, then tag
checked = False
for kind, api_path in [('branch', f'heads/{ref}'), ('tag', f'tags/{ref}')]:
api_url = f'https://api.github.com/repos/{owner}/{repo}/git/ref/{api_path}'
req = urllib.request.Request(api_url, headers=api_headers)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
if resp.status == 200:
print(f'OK: {name} -> {owner}/{repo} ({kind}: {ref})')
checked = True
break
except urllib.error.HTTPError as e:
if e.code != 404:
errors.append(f'{name}: API error {e.code} for {kind} {ref}')
checked = True
break
except Exception as e:
errors.append(f'{name}: request failed: {e}')
checked = True
break
if not checked:
errors.append(f'{name}: ref {ref!r} not found as branch or tag in {owner}/{repo}')
if errors:
print('ERRORS:', file=sys.stderr)
for e in errors:
print(f' {e}', file=sys.stderr)
sys.exit(1)
"
run: python3 scripts/validate_reachability.py .claude-plugin/marketplace.json
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
__pycache__/
118 changes: 111 additions & 7 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ In your project repo, create `dist/<plugin-name>/` with:
```
dist/<plugin-name>/
├── .claude-plugin/
│ └── plugin.json # name, version, userConfig, mcpServers, skills, agents, commands
│ └── plugin.json # name, userConfig, mcpServers, skills, agents, commands (no version field — see below)
├── .mcp.json # MCP server config using ${user_config.*} refs
├── skills/ # SKILL.md files
├── agents/ # agent .md files (optional)
Expand All @@ -23,6 +23,8 @@ Follow the [Claude Code plugin spec](https://code.claude.com/docs/en/plugins-ref

For HTTP MCP servers requiring authentication, use `userConfig` with `"sensitive": true` — **never hardcode API keys**.

**Do not set `version` in `plugin.json`.** Claude Code uses the pinned SHA as the version identifier. Every time the SHA in `marketplace.json` is updated, users automatically receive the new content. If you set an explicit version string, you would need to bump it manually on every change — the auto-pin workflow would not be enough.

### 2. Open a PR here

Add an entry to `.claude-plugin/marketplace.json`:
Expand All @@ -34,24 +36,126 @@ Add an entry to `.claude-plugin/marketplace.json`:
"source": "git-subdir",
"url": "https://github.com/Viindoo/<your-repo>.git",
"path": "dist/<plugin-name>",
"ref": "main",
"ref": "master",
"sha": "<exact-commit-sha-after-your-pr-merges>"
},
"description": "One-line description (max 120 chars)"
}
```

Pin `sha` to the exact commit where your plugin landed on `main`. This is the anti-drift anchor — the nightly CI checks this SHA is still reachable.
Pin `sha` to the exact commit where your plugin landed. This is the anti-drift anchor — the nightly CI checks this SHA is still reachable.

### 3. Set up auto-pinning in your plugin repo

After registering the plugin, wire up the auto-pin workflow so that every merge to `master` that touches `dist/<plugin-name>/` automatically opens a SHA-update PR here.

**Required setup (one-time per plugin repo):**

1. **Create a fine-grained PAT** targeting `Viindoo/claude-plugins` with permissions:
- `Contents: Read and write`
- `Pull requests: Read and write`

2. **Add the PAT as a secret** in your plugin repo:
`Settings → Secrets → Actions → New repository secret`
Name: `CLAUDE_PLUGINS_PAT`

3. **Create the `auto-pin` label** in `Viindoo/claude-plugins`:
```bash
gh label create auto-pin --repo Viindoo/claude-plugins --color 0075ca --description "Automated SHA pin update"
```

4. **Add the workflow** `.github/workflows/pin-sha.yml` to your plugin repo (see template below).

**Template `pin-sha.yml`** (replace `<plugin-name>` and `<plugin-dir>`):

```yaml
name: Pin SHA in claude-plugins

on:
push:
branches: [master]
paths:
- 'dist/<plugin-dir>/**'

jobs:
pin-sha:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Get SHA
id: info
run: echo "sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT

- uses: actions/checkout@v4
with:
repository: Viindoo/claude-plugins
token: ${{ secrets.CLAUDE_PLUGINS_PAT }}
path: claude-plugins

- name: Check if already pinned
id: check
run: |
CURRENT=$(python3 -c "
import json
m = json.load(open('claude-plugins/.claude-plugin/marketplace.json'))
for p in m['plugins']:
if p['name'] == '<plugin-name>':
print(p['source'].get('sha', ''))
")
[ "$CURRENT" = "${{ steps.info.outputs.sha }}" ] && echo "skip=true" >> $GITHUB_OUTPUT || echo "skip=false" >> $GITHUB_OUTPUT

- name: Update marketplace.json
if: steps.check.outputs.skip == 'false'
run: |
python3 -c "
import json
path = 'claude-plugins/.claude-plugin/marketplace.json'
with open(path) as f: m = json.load(f)
for p in m['plugins']:
if p['name'] == '<plugin-name>':
p['source']['sha'] = '${{ steps.info.outputs.sha }}'
with open(path, 'w') as f: json.dump(m, f, indent=2); f.write('\n')
"

- name: Create PR with auto-merge
if: steps.check.outputs.skip == 'false'
working-directory: claude-plugins
env:
GH_TOKEN: ${{ secrets.CLAUDE_PLUGINS_PAT }}
run: |
SHA="${{ steps.info.outputs.sha }}"
SHA7="${SHA:0:7}"
BRANCH="auto-pin/<plugin-name>-${SHA7}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git checkout -b "$BRANCH"
git add .claude-plugin/marketplace.json
git commit -m "pin <plugin-name> to ${SHA7}"
git push origin "$BRANCH"
PR_URL=$(gh pr create \
--repo Viindoo/claude-plugins \
--title "pin <plugin-name> to ${SHA7}" \
--body "Auto-pin: plugin content changed in [${SHA7}](https://github.com/Viindoo/<your-repo>/commit/${SHA})." \
--label "auto-pin")
gh pr merge --auto --squash "$PR_URL"
```

**Enable auto-merge in `Viindoo/claude-plugins`** (one-time repo setting):
`Settings → General → Pull Requests → Allow auto-merge` ✓

**Enable branch protection on `master`** (required for auto-merge to work):
`Settings → Branches → Add rule → master → Require status checks → validate`

### 3. After your PR merges
### 4. After the first PR merges

Bump the `sha` here whenever you ship a new plugin version. Open a follow-up PR with the new SHA + updated `ref` if needed.
Subsequent plugin updates are fully automatic — the `pin-sha.yml` workflow handles everything. No manual SHA updates needed.

## Anti-drift CI

`.github/workflows/validate.yml` runs nightly and on every change to `marketplace.json`:
- Validates JSON schema
- Checks each `git-subdir` source URL + ref is still reachable via `git ls-remote`
- Validates JSON schema (`scripts/validate_schema.py`)
- Checks each `git-subdir` source URL + ref is still reachable via GitHub API (`scripts/validate_reachability.py`)

If CI fails: the plugin source has moved or been deleted. Update `marketplace.json` to the new location.

Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,11 @@ Or in Claude Code interactive session:

Each Viindoo project hosts its own plugin source under `dist/<plugin-name>/`. To add a new plugin to this marketplace:

1. Create `dist/<your-plugin>/` in your project repo with `.claude-plugin/plugin.json`
1. Create `dist/<your-plugin>/` in your project repo with `.claude-plugin/plugin.json` (no `version` field — SHA is used as the version identifier)
2. Open a PR here adding an entry to `.claude-plugin/marketplace.json`
3. Pin `sha` to the exact commit of your plugin source after it merges
4. Set up `.github/workflows/pin-sha.yml` in your plugin repo — subsequent updates are then fully automatic

See [CONTRIBUTING.md](CONTRIBUTING.md) for the full setup guide including the auto-pin workflow template.

See [Anti-drift CI](.github/workflows/validate.yml) — nightly validation checks each plugin source is still reachable and schema-valid.
58 changes: 58 additions & 0 deletions scripts/validate_reachability.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
#!/usr/bin/env python3
import json, os, re, sys, urllib.error, urllib.request


def check(path):
with open(path) as f:
m = json.load(f)
token = os.environ.get('GH_TOKEN', '')
headers = {'Accept': 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28'}
if token:
headers['Authorization'] = f'Bearer {token}'
errors = []
for p in m['plugins']:
src = p['source']
if not isinstance(src, dict) or src.get('source') != 'git-subdir':
continue
url = src['url']
ref = src.get('ref', 'main')
name = p['name']
match = re.match(r'https://github\.com/([^/]+)/([^/.]+)', url)
if not match:
errors.append(f'{name}: non-GitHub URL not supported yet: {url}')
continue
owner, repo = match.group(1), match.group(2)
checked = False
for kind, api_path in [('branch', f'heads/{ref}'), ('tag', f'tags/{ref}')]:
api_url = f'https://api.github.com/repos/{owner}/{repo}/git/ref/{api_path}'
req = urllib.request.Request(api_url, headers=headers)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
if resp.status == 200:
print(f'OK: {name} -> {owner}/{repo} ({kind}: {ref})')
checked = True
break
except urllib.error.HTTPError as e:
if e.code != 404:
errors.append(f'{name}: API error {e.code} for {kind} {ref}')
checked = True
break
except Exception as e:
errors.append(f'{name}: request failed: {e}')
checked = True
break
if not checked:
errors.append(f'{name}: ref {ref!r} not found as branch or tag in {owner}/{repo}')
return errors


if __name__ == '__main__':
if len(sys.argv) != 2:
print(f'usage: {sys.argv[0]} <marketplace.json>', file=sys.stderr)
sys.exit(1)
errors = check(sys.argv[1])
if errors:
print('ERRORS:', file=sys.stderr)
for e in errors:
print(f' {e}', file=sys.stderr)
sys.exit(1)
42 changes: 42 additions & 0 deletions scripts/validate_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#!/usr/bin/env python3
import json, sys


def validate(path):
with open(path) as f:
m = json.load(f)
errors = []
if 'name' not in m:
errors.append('missing top-level name')
if 'plugins' not in m:
errors.append('missing top-level plugins')
return errors
for p in m['plugins']:
if 'name' not in p:
errors.append(f'plugin missing name: {p}')
continue
if 'source' not in p:
errors.append(f'plugin missing source: {p["name"]}')
continue
src = p['source']
if isinstance(src, dict) and src.get('source') == 'git-subdir':
if 'url' not in src:
errors.append(f'git-subdir missing url: {p["name"]}')
if 'path' not in src:
errors.append(f'git-subdir missing path: {p["name"]}')
return errors


if __name__ == '__main__':
if len(sys.argv) != 2:
print(f'usage: {sys.argv[0]} <marketplace.json>', file=sys.stderr)
sys.exit(1)
errors = validate(sys.argv[1])
if errors:
print('ERRORS:', file=sys.stderr)
for e in errors:
print(f' {e}', file=sys.stderr)
sys.exit(1)
with open(sys.argv[1]) as f:
m = json.load(f)
print(f'OK: {len(m["plugins"])} plugin(s) validated')
Empty file added tests/__init__.py
Empty file.
Loading
Loading