Răsfoiți Sursa

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>
Bryan Thompson 4 luni în urmă
părinte
comite
167f01f2e0
2 a modificat fișierele cu 362 adăugiri și 0 ștergeri
  1. 229 0
      .github/scripts/discover_bumps.py
  2. 133 0
      .github/workflows/bump-plugin-shas.yml

+ 229 - 0
.github/scripts/discover_bumps.py

@@ -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 - 0
.github/workflows/bump-plugin-shas.yml

@@ -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