mirror of
https://github.com/lucas-labs/claude-plugins.git
synced 2026-05-11 09:05:43 -03:00
Add auto-SHA-bump workflow for marketplace plugins (#1392)
* Add auto-SHA-bump workflow for marketplace plugins
Weekly CI action that discovers stale SHA pins in marketplace.json
and opens a batched PR with updated SHAs. Adapted from the
claude-plugins-community-internal bump-plugin-shas workflow for
the single-file marketplace.json format.
- discover_bumps.py: checks 56 SHA-pinned plugins against upstream
repos, oldest-stale-first rotation, capped at 20 bumps/run
- bump-plugin-shas.yml: weekly Monday schedule + manual dispatch
with dry_run and per-plugin targeting options
Entries without SHA pins (intentionally tracking HEAD) are never
touched. Existing validate-marketplace CI runs on the resulting PR.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix input interpolation and add BASE_BRANCH overlay
- Pass workflow_dispatch inputs through env vars instead of direct
${{ inputs.* }} interpolation in run blocks (avoids shell injection)
- Add marketplace.json overlay from main so the workflow can be tested
via dispatch from a feature branch against main's real plugin data
Both patterns match claude-plugins-community-internal's implementation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Use GitHub App token for PR creation
The anthropics org disables "Allow GitHub Actions to create and approve
pull requests", so GITHUB_TOKEN cannot call gh pr create. Split the
workflow: GITHUB_TOKEN pushes the branch, then the same GitHub App
used by -internal's bump workflow (app-id 2812036) creates the PR.
Prerequisite: app must be installed on this repo and the PEM secret
(CLAUDE_DIRECTORY_BOT_PRIVATE_KEY) must exist in repo settings.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Use --force-with-lease for bump branch push
Prevents push failure if the branch exists from a previous same-day
run whose PR was merged but whose branch wasn't auto-deleted.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
637c6b3b6a
commit
167f01f2e0
229
.github/scripts/discover_bumps.py
vendored
Normal file
229
.github/scripts/discover_bumps.py
vendored
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Discover plugins in marketplace.json whose upstream repo has moved past
|
||||||
|
their pinned SHA, update the file in place, and emit a summary.
|
||||||
|
|
||||||
|
Adapted from claude-plugins-community-internal's discover_bumps.py for the
|
||||||
|
single-file marketplace.json format used by claude-plugins-official.
|
||||||
|
|
||||||
|
Usage: discover_bumps.py [--plugin NAME] [--max N] [--dry-run]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
MARKETPLACE_PATH = ".claude-plugin/marketplace.json"
|
||||||
|
|
||||||
|
|
||||||
|
def gh_api(path: str) -> Any:
|
||||||
|
"""GET from the GitHub API. None on not-found; raises on other errors.
|
||||||
|
|
||||||
|
"Not found" covers both 404 (resource gone) and 422 "No commit found
|
||||||
|
for SHA" (force-pushed away). Both mean the thing we asked for isn't
|
||||||
|
there — treating them the same lets callers handle dead refs uniformly.
|
||||||
|
"""
|
||||||
|
r = subprocess.run(
|
||||||
|
["gh", "api", path], capture_output=True, text=True
|
||||||
|
)
|
||||||
|
if r.returncode != 0:
|
||||||
|
combined = r.stdout + r.stderr
|
||||||
|
if any(s in combined for s in ("404", "Not Found", "No commit found")):
|
||||||
|
return None
|
||||||
|
raise RuntimeError(f"gh api {path}: {r.stderr.strip() or r.stdout.strip()}")
|
||||||
|
return json.loads(r.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_github_repo(url: str) -> tuple[str, str] | None:
|
||||||
|
"""Extract (owner, repo) from a URL or owner/repo shorthand."""
|
||||||
|
# Full URL: https://github.com/owner/repo(.git)(/...)
|
||||||
|
m = re.match(r"https?://github\.com/([^/]+)/([^/]+?)(?:\.git)?(?:/|$)", url)
|
||||||
|
if m:
|
||||||
|
return m.group(1), m.group(2)
|
||||||
|
# Shorthand: owner/repo
|
||||||
|
m = re.match(r"^([\w.-]+)/([\w.-]+)$", url)
|
||||||
|
if m:
|
||||||
|
return m.group(1), m.group(2)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def latest_sha(owner: str, repo: str, *, ref: str | None, path: str | None) -> str | None:
|
||||||
|
"""Latest commit SHA for the repo, optionally scoped to a ref and/or path."""
|
||||||
|
if path:
|
||||||
|
# Scoped to a subdirectory — use the commits list endpoint with path filter.
|
||||||
|
q = f"repos/{owner}/{repo}/commits?per_page=1&path={path}"
|
||||||
|
if ref:
|
||||||
|
q += f"&sha={ref}"
|
||||||
|
commits = gh_api(q)
|
||||||
|
if not commits:
|
||||||
|
return None
|
||||||
|
return commits[0]["sha"]
|
||||||
|
# Whole repo — the single-ref endpoint is cheaper.
|
||||||
|
if not ref:
|
||||||
|
meta = gh_api(f"repos/{owner}/{repo}")
|
||||||
|
if not meta:
|
||||||
|
return None
|
||||||
|
ref = meta["default_branch"]
|
||||||
|
c = gh_api(f"repos/{owner}/{repo}/commits/{ref}")
|
||||||
|
return c["sha"] if c else None
|
||||||
|
|
||||||
|
|
||||||
|
def pinned_age_days(owner: str, repo: str, sha: str) -> int | None:
|
||||||
|
"""Days since the pinned commit was authored. Used for oldest-first rotation."""
|
||||||
|
c = gh_api(f"repos/{owner}/{repo}/commits/{sha}")
|
||||||
|
if not c:
|
||||||
|
return None
|
||||||
|
dt = datetime.fromisoformat(
|
||||||
|
c["commit"]["committer"]["date"].replace("Z", "+00:00")
|
||||||
|
)
|
||||||
|
return (datetime.now(timezone.utc) - dt).days
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--plugin", help="only check this plugin")
|
||||||
|
ap.add_argument("--max", type=int, default=20, help="cap bumps emitted")
|
||||||
|
ap.add_argument("--dry-run", action="store_true", help="don't write marketplace.json")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
with open(MARKETPLACE_PATH) as f:
|
||||||
|
marketplace = json.load(f)
|
||||||
|
|
||||||
|
plugins = marketplace.get("plugins", [])
|
||||||
|
bumps: list[dict] = []
|
||||||
|
dead: list[str] = []
|
||||||
|
skipped_non_github = 0
|
||||||
|
checked = 0
|
||||||
|
|
||||||
|
for plugin in plugins:
|
||||||
|
name = plugin.get("name", "?")
|
||||||
|
src = plugin.get("source")
|
||||||
|
|
||||||
|
# Only process object sources with a sha field
|
||||||
|
if not isinstance(src, dict) or "sha" not in src:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Filter to specific plugin if requested
|
||||||
|
if args.plugin and name != args.plugin:
|
||||||
|
continue
|
||||||
|
|
||||||
|
checked += 1
|
||||||
|
kind = src.get("source")
|
||||||
|
url = src.get("url", "")
|
||||||
|
path = src.get("path")
|
||||||
|
ref = src.get("ref")
|
||||||
|
pinned = src.get("sha")
|
||||||
|
|
||||||
|
slug = parse_github_repo(url)
|
||||||
|
if not slug:
|
||||||
|
skipped_non_github += 1
|
||||||
|
continue
|
||||||
|
owner, repo = slug
|
||||||
|
|
||||||
|
try:
|
||||||
|
latest = latest_sha(owner, repo, ref=ref, path=path)
|
||||||
|
except RuntimeError as e:
|
||||||
|
print(f"::warning::{name}: {e}", file=sys.stderr)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if latest is None:
|
||||||
|
dead.append(f"{name} ({owner}/{repo})")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if latest == pinned:
|
||||||
|
continue # up to date
|
||||||
|
|
||||||
|
# Age lookup for rotation — oldest-pinned first prevents starvation.
|
||||||
|
try:
|
||||||
|
age = pinned_age_days(owner, repo, pinned) if pinned else None
|
||||||
|
except RuntimeError as e:
|
||||||
|
print(f"::warning::{name}: age lookup failed: {e}", file=sys.stderr)
|
||||||
|
age = None
|
||||||
|
|
||||||
|
bumps.append({
|
||||||
|
"name": name,
|
||||||
|
"kind": kind,
|
||||||
|
"url": url,
|
||||||
|
"path": path or "",
|
||||||
|
"ref": ref or "",
|
||||||
|
"old_sha": pinned or "",
|
||||||
|
"new_sha": latest,
|
||||||
|
"age_days": age if age is not None else 10**6,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Oldest-pinned first so nothing starves under the cap.
|
||||||
|
bumps.sort(key=lambda b: -b["age_days"])
|
||||||
|
emitted = bumps[: args.max]
|
||||||
|
|
||||||
|
# Apply bumps to marketplace data
|
||||||
|
if emitted and not args.dry_run:
|
||||||
|
bump_map = {b["name"]: b["new_sha"] for b in emitted}
|
||||||
|
for plugin in plugins:
|
||||||
|
name = plugin.get("name")
|
||||||
|
src = plugin.get("source")
|
||||||
|
if isinstance(src, dict) and name in bump_map:
|
||||||
|
src["sha"] = bump_map[name]
|
||||||
|
|
||||||
|
with open(MARKETPLACE_PATH, "w") as f:
|
||||||
|
json.dump(marketplace, f, indent=2, ensure_ascii=False)
|
||||||
|
f.write("\n")
|
||||||
|
|
||||||
|
# Write GitHub outputs
|
||||||
|
out = os.environ.get("GITHUB_OUTPUT")
|
||||||
|
if out:
|
||||||
|
bumped_names = ",".join(b["name"] for b in emitted)
|
||||||
|
with open(out, "a") as fh:
|
||||||
|
fh.write(f"count={len(emitted)}\n")
|
||||||
|
fh.write(f"bumped_names={bumped_names}\n")
|
||||||
|
|
||||||
|
# Write GitHub step summary
|
||||||
|
summary = os.environ.get("GITHUB_STEP_SUMMARY")
|
||||||
|
if summary:
|
||||||
|
with open(summary, "a") as fh:
|
||||||
|
fh.write("## SHA Bump Discovery\n\n")
|
||||||
|
fh.write(f"- Checked: {checked} SHA-pinned entries\n")
|
||||||
|
fh.write(f"- Stale: {len(bumps)} (applying {len(emitted)}, cap {args.max})\n")
|
||||||
|
if skipped_non_github:
|
||||||
|
fh.write(f"- Skipped non-GitHub: {skipped_non_github}\n")
|
||||||
|
if dead:
|
||||||
|
fh.write(f"- **Dead upstream** ({len(dead)}): {', '.join(dead)}\n")
|
||||||
|
if emitted:
|
||||||
|
fh.write("\n| Plugin | Old | New | Age |\n|---|---|---|---|\n")
|
||||||
|
for b in emitted:
|
||||||
|
old = b["old_sha"][:8] if b["old_sha"] else "(unpinned)"
|
||||||
|
fh.write(f"| {b['name']} | `{old}` | `{b['new_sha'][:8]}` | {b['age_days']}d |\n")
|
||||||
|
|
||||||
|
# Write PR body for the workflow to use
|
||||||
|
pr_body_path = os.environ.get("PR_BODY_PATH", "/tmp/bump-pr-body.md")
|
||||||
|
if emitted:
|
||||||
|
with open(pr_body_path, "w") as fh:
|
||||||
|
fh.write("Upstream repos moved. Bumping pinned SHAs so plugins track latest.\n\n")
|
||||||
|
fh.write("| Plugin | Old | New | Upstream |\n")
|
||||||
|
fh.write("|--------|-----|-----|----------|\n")
|
||||||
|
for b in emitted:
|
||||||
|
old = b["old_sha"][:8] if b["old_sha"] else "(unpinned)"
|
||||||
|
slug_str = re.sub(r"https?://github\.com/", "", b["url"])
|
||||||
|
slug_str = re.sub(r"\.git$", "", slug_str)
|
||||||
|
compare = f"https://github.com/{slug_str}/compare/{b['old_sha'][:12]}...{b['new_sha'][:12]}"
|
||||||
|
fh.write(f"| `{b['name']}` | `{old}` | `{b['new_sha'][:8]}` | [diff]({compare}) |\n")
|
||||||
|
fh.write(f"\n---\n_Auto-generated by `bump-plugin-shas.yml` on {datetime.now(timezone.utc).strftime('%Y-%m-%d')}_\n")
|
||||||
|
|
||||||
|
# Console summary
|
||||||
|
print(f"Checked {checked} SHA-pinned plugins", file=sys.stderr)
|
||||||
|
print(f"Stale: {len(bumps)}, applying: {len(emitted)}", file=sys.stderr)
|
||||||
|
if dead:
|
||||||
|
print(f"Dead upstream: {', '.join(dead)}", file=sys.stderr)
|
||||||
|
for b in emitted:
|
||||||
|
old = b["old_sha"][:8] if b["old_sha"] else "unpinned"
|
||||||
|
print(f" {b['name']}: {old} -> {b['new_sha'][:8]} ({b['age_days']}d)", file=sys.stderr)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
133
.github/workflows/bump-plugin-shas.yml
vendored
Normal file
133
.github/workflows/bump-plugin-shas.yml
vendored
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
name: Bump plugin SHAs
|
||||||
|
|
||||||
|
# Weekly sweep of marketplace.json — for each entry whose upstream repo has
|
||||||
|
# moved past its pinned SHA, open a PR against main with updated SHAs. The
|
||||||
|
# validate-marketplace workflow then runs on the PR to confirm the file is
|
||||||
|
# still well-formed.
|
||||||
|
#
|
||||||
|
# Adapted from claude-plugins-community-internal's bump-plugin-shas.yml
|
||||||
|
# for the single-file marketplace.json format. Key difference: all bumps
|
||||||
|
# are batched into one PR (since they all modify the same file).
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '23 7 * * 1' # Monday 07:23 UTC
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
plugin:
|
||||||
|
description: Only bump this plugin (for testing)
|
||||||
|
required: false
|
||||||
|
max_bumps:
|
||||||
|
description: Cap on plugins bumped this run
|
||||||
|
required: false
|
||||||
|
default: '20'
|
||||||
|
dry_run:
|
||||||
|
description: Discover only, don't open PR
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: bump-plugin-shas
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
bump:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Check for existing bump PR
|
||||||
|
id: existing
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
existing=$(gh pr list --label sha-bump --state open --json number --jq 'length')
|
||||||
|
echo "count=$existing" >> "$GITHUB_OUTPUT"
|
||||||
|
if [ "$existing" -gt 0 ]; then
|
||||||
|
echo "::notice::Open sha-bump PR already exists — skipping"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Ensure sha-bump label exists
|
||||||
|
if: steps.existing.outputs.count == '0'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: gh label create sha-bump --color 0e8a16 --description "Automated SHA bump" 2>/dev/null || true
|
||||||
|
|
||||||
|
- name: Overlay marketplace data from main
|
||||||
|
if: steps.existing.outputs.count == '0'
|
||||||
|
run: |
|
||||||
|
git fetch origin main --depth=1 --quiet
|
||||||
|
git checkout origin/main -- .claude-plugin/marketplace.json
|
||||||
|
|
||||||
|
- name: Discover and apply SHA bumps
|
||||||
|
if: steps.existing.outputs.count == '0'
|
||||||
|
id: discover
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
PR_BODY_PATH: /tmp/bump-pr-body.md
|
||||||
|
PLUGIN: ${{ inputs.plugin }}
|
||||||
|
MAX_BUMPS: ${{ inputs.max_bumps }}
|
||||||
|
DRY_RUN: ${{ inputs.dry_run }}
|
||||||
|
run: |
|
||||||
|
args=(--max "${MAX_BUMPS:-20}")
|
||||||
|
[[ -n "$PLUGIN" ]] && args+=(--plugin "$PLUGIN")
|
||||||
|
[[ "$DRY_RUN" = "true" ]] && args+=(--dry-run)
|
||||||
|
python3 .github/scripts/discover_bumps.py "${args[@]}"
|
||||||
|
|
||||||
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
if: steps.existing.outputs.count == '0' && steps.discover.outputs.count != '0' && inputs.dry_run != true
|
||||||
|
|
||||||
|
- name: Validate marketplace.json
|
||||||
|
if: steps.existing.outputs.count == '0' && steps.discover.outputs.count != '0' && inputs.dry_run != true
|
||||||
|
run: |
|
||||||
|
bun .github/scripts/validate-marketplace.ts .claude-plugin/marketplace.json
|
||||||
|
bun .github/scripts/check-marketplace-sorted.ts
|
||||||
|
|
||||||
|
- name: Push bump branch
|
||||||
|
if: steps.existing.outputs.count == '0' && steps.discover.outputs.count != '0' && inputs.dry_run != true
|
||||||
|
id: push
|
||||||
|
run: |
|
||||||
|
branch="auto/bump-shas-$(date +%Y%m%d)"
|
||||||
|
echo "branch=$branch" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||||
|
git checkout -b "$branch"
|
||||||
|
git add .claude-plugin/marketplace.json
|
||||||
|
git commit -m "Bump SHA pins for ${{ steps.discover.outputs.count }} plugin(s)
|
||||||
|
|
||||||
|
Plugins: ${{ steps.discover.outputs.bumped_names }}"
|
||||||
|
git push -u origin "$branch" --force-with-lease
|
||||||
|
|
||||||
|
# GITHUB_TOKEN cannot create PRs (org policy: "Allow GitHub Actions to
|
||||||
|
# create and approve pull requests" is disabled). Use the same GitHub App
|
||||||
|
# that -internal's bump workflow uses.
|
||||||
|
#
|
||||||
|
# Prerequisite: app 2812036 must be installed on this repo. The PEM
|
||||||
|
# secret must exist in this repo's settings (shared with -internal).
|
||||||
|
- name: Generate bot token
|
||||||
|
if: steps.push.outcome == 'success'
|
||||||
|
id: app-token
|
||||||
|
uses: actions/create-github-app-token@v1
|
||||||
|
with:
|
||||||
|
app-id: 2812036
|
||||||
|
private-key: ${{ secrets.CLAUDE_DIRECTORY_BOT_PRIVATE_KEY }}
|
||||||
|
owner: ${{ github.repository_owner }}
|
||||||
|
repositories: ${{ github.event.repository.name }}
|
||||||
|
|
||||||
|
- name: Create pull request
|
||||||
|
if: steps.push.outcome == 'success'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||||
|
run: |
|
||||||
|
gh pr create \
|
||||||
|
--base main \
|
||||||
|
--head "${{ steps.push.outputs.branch }}" \
|
||||||
|
--title "Bump SHA pins (${{ steps.discover.outputs.count }} plugins)" \
|
||||||
|
--body-file /tmp/bump-pr-body.md \
|
||||||
|
--label sha-bump
|
||||||
Loading…
x
Reference in New Issue
Block a user