Просмотр исходного кода

feat(ci): allow live external contributors to open scoped PRs

Add an opt-in allowlist so a vetted external developer who already has a
plugin live in this marketplace — but cannot use the submission form
(e.g. an enterprise partner without a Claude account) — can open a
reviewable PR instead of having it auto-closed.

- .github/external-contributors.json: username -> allowed_sources map
  (doubles as the allowlist and the per-author source scope).
- close-external-prs.yml: skip the auto-close for allowlisted authors
  (reads the list from the trusted base checkout). Grants ONLY the right
  to open a PR; CI + maintainer approval are unchanged.
- external-pr-scope-guard.yml: required check for allowlisted external
  authors. Fails unless the PR touches ONLY marketplace.json and the
  delta is additions-only, with every added entry's source.url under
  that author's allowed_sources. Anthropic members are unrestricted.
  Reads head marketplace.json as data via the API (no untrusted checkout).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bryan Thompson 3 месяцев назад
Родитель
Сommit
fb608cb095

+ 34 - 0
.github/external-contributors.json

@@ -0,0 +1,34 @@
+{
+  "$comment": [
+    "Allowlist of external (non-Anthropic) developers permitted to open pull requests",
+    "against this repository. Used by .github/workflows/close-external-prs.yml (to skip",
+    "the auto-close) and .github/workflows/external-pr-scope-guard.yml (to constrain what",
+    "an allowlisted external author may change).",
+    "",
+    "Being on this list grants ONLY the right to OPEN a reviewable PR. It does NOT grant",
+    "merge rights: all CI still runs and an Anthropic maintainer approval is still required",
+    "by branch protection before merge.",
+    "",
+    "Criterion for inclusion: a developer who already has at least one plugin live in this",
+    "marketplace and cannot use the submission form (https://clau.de/plugin-directory-submission),",
+    "e.g. an enterprise partner without a Claude account. Add an entry via a normal reviewed PR.",
+    "",
+    "allowed_sources: the scope guard requires every plugin entry this author ADDS to point",
+    "at a source.url under one of these prefixes (match is a case-insensitive prefix on the",
+    "normalized https URL, with any leading https:// and trailing .git removed). Keep it as",
+    "tight as the author's actual repos."
+  ],
+  "contributors": [
+    {
+      "github_username": "flovogt",
+      "name": "Florian Vogt",
+      "affiliation": "SAP SE",
+      "allowed_sources": [
+        "github.com/UI5/",
+        "github.com/SAP/",
+        "github.com/SAP-samples/"
+      ],
+      "note": "SAP UI5 org. Live plugins from github.com/UI5/plugins-coding-agents: ui5, ui5-typescript-conversion, ui5-modernization. SAP cannot currently use the submission form (no Claude account)."
+    }
+  ]
+}

+ 21 - 0
.github/workflows/close-external-prs.yml

@@ -13,10 +13,14 @@ jobs:
     if: vars.DISABLE_EXTERNAL_PR_CHECK != 'true'
     runs-on: ubuntu-latest
     steps:
+      # pull_request_target: this checks out the BASE repo (trusted), so the allowlist
+      # we read below is this repo's version, never the fork's.
+      - uses: actions/checkout@v4
       - name: Check if author has write access
         uses: actions/github-script@v7
         with:
           script: |
+            const fs = require('fs');
             const author = context.payload.pull_request.user.login;
 
             const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
@@ -30,6 +34,23 @@ jobs:
               return;
             }
 
+            // Allowlisted external contributors (e.g. enterprise partners who cannot use the
+            // submission form) may OPEN a PR. This does NOT grant merge: all CI still runs,
+            // the external-pr-scope-guard check constrains what they can change, and an
+            // Anthropic maintainer approval is still required by branch protection.
+            try {
+              const list = JSON.parse(fs.readFileSync('.github/external-contributors.json', 'utf8'));
+              const allowed = (list.contributors || []).some(
+                c => (c.github_username || '').toLowerCase() === author.toLowerCase()
+              );
+              if (allowed) {
+                console.log(`${author} is an allowlisted external contributor, allowing PR (scope guard + maintainer review still apply)`);
+                return;
+              }
+            } catch (e) {
+              console.log(`Could not read external-contributors allowlist: ${e.message}`);
+            }
+
             console.log(`${author} has ${data.permission} access, closing PR`);
 
             await github.rest.issues.createComment({

+ 148 - 0
.github/workflows/external-pr-scope-guard.yml

@@ -0,0 +1,148 @@
+name: External PR Scope Guard
+
+# Constrains what an allowlisted EXTERNAL contributor (see .github/external-contributors.json)
+# may change in a pull request. Anthropic members (write/admin) are unrestricted and skip this
+# check. For an allowlisted external author this is a REQUIRED status check (configure in branch
+# protection) that fails unless:
+#   1. the PR changes ONLY .claude-plugin/marketplace.json (no workflow edits, no other plugins'
+#      files, and crucially not the allowlist itself), and
+#   2. the marketplace.json delta is additions-only — no existing entry is modified or removed, and
+#      every ADDED entry's source.url points at a repo under that author's allowed_sources.
+#
+# Security: runs on pull_request_target but checks out only the BASE repo (trusted) for the
+# allowlist; the head's marketplace.json is fetched as DATA via the API and parsed, never executed.
+
+on:
+  pull_request_target:
+    types: [opened, synchronize, reopened]
+
+permissions:
+  contents: read
+  pull-requests: read
+
+jobs:
+  scope-guard:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4   # base repo (trusted) — for the allowlist
+      - uses: actions/github-script@v7
+        with:
+          script: |
+            const fs = require('fs');
+            const pr = context.payload.pull_request;
+            const author = pr.user.login;
+            const MARKETPLACE = '.claude-plugin/marketplace.json';
+
+            // Anthropic members are unrestricted.
+            const { data: perm } = await github.rest.repos.getCollaboratorPermissionLevel({
+              owner: context.repo.owner, repo: context.repo.repo, username: author,
+            });
+            if (['admin', 'write'].includes(perm.permission)) {
+              console.log(`${author} is ${perm.permission} (Anthropic member) — scope guard not applicable.`);
+              return;
+            }
+
+            // Look up the author in the allowlist (read from the trusted base checkout).
+            let entry;
+            try {
+              const list = JSON.parse(fs.readFileSync('.github/external-contributors.json', 'utf8'));
+              entry = (list.contributors || []).find(
+                c => (c.github_username || '').toLowerCase() === author.toLowerCase()
+              );
+            } catch (e) {
+              core.setFailed(`Could not read .github/external-contributors.json: ${e.message}`);
+              return;
+            }
+            if (!entry) {
+              // Not allowlisted: the Close External PRs workflow governs this author. Nothing to guard.
+              console.log(`${author} is not on the external-contributor allowlist — scope guard is a no-op (the close workflow handles this PR).`);
+              return;
+            }
+            const allowed = (entry.allowed_sources || []).map(normalizeUrl);
+            if (allowed.length === 0) {
+              core.setFailed(`Allowlist entry for ${author} has no allowed_sources — cannot validate scope.`);
+              return;
+            }
+
+            // (1) Changed-files gate: marketplace.json only.
+            const files = await github.paginate(github.rest.pulls.listFiles, {
+              owner: context.repo.owner, repo: context.repo.repo, pull_number: pr.number, per_page: 100,
+            });
+            const offlimits = files.map(f => f.filename).filter(n => n !== MARKETPLACE);
+            if (offlimits.length > 0) {
+              core.setFailed(
+                `As an allowlisted external contributor, ${author} may only modify ${MARKETPLACE}. ` +
+                `This PR also changes: ${offlimits.join(', ')}.`
+              );
+              return;
+            }
+            if (!files.some(f => f.filename === MARKETPLACE)) {
+              console.log('No change to marketplace.json — nothing to validate.');
+              return;
+            }
+
+            // (2) Semantic base-vs-head diff of marketplace.json (head fetched as data, not executed).
+            const base = await readPlugins(context.repo.owner, context.repo.repo, pr.base.sha);
+            const head = await readPlugins(pr.head.repo.owner.login, pr.head.repo.name, pr.head.sha);
+            if (base === null || head === null) {
+              core.setFailed('Could not read marketplace.json at base and/or head.');
+              return;
+            }
+
+            const baseNames = new Set(Object.keys(base));
+            const headNames = new Set(Object.keys(head));
+            const removed = [...baseNames].filter(n => !headNames.has(n));
+            const added = [...headNames].filter(n => !baseNames.has(n));
+            const modified = [...headNames].filter(
+              n => baseNames.has(n) && JSON.stringify(base[n]) !== JSON.stringify(head[n])
+            );
+
+            const problems = [];
+            if (removed.length)  problems.push(`removes existing entr${removed.length > 1 ? 'ies' : 'y'}: ${removed.join(', ')}`);
+            if (modified.length) problems.push(`modifies existing entr${modified.length > 1 ? 'ies' : 'y'}: ${modified.join(', ')}`);
+
+            for (const name of added) {
+              const url = head[name] && head[name].source && head[name].source.url;
+              if (!url) { problems.push(`added "${name}" has no source.url to validate`); continue; }
+              const n = normalizeUrl(url);
+              // Require a real host/org/repo path (rejects a bare org URL).
+              if (n.split('/').length < 3) {
+                problems.push(`added "${name}" source.url ${url} is not a valid repo URL`);
+                continue;
+              }
+              // Boundary-safe: an exact repo match, or a path strictly under the allowed prefix.
+              // (Prevents "github.com/ui5" from matching "github.com/ui5-evil/x".)
+              if (!allowed.some(a => n === a || n.startsWith(a + '/'))) {
+                problems.push(`added "${name}" points at ${url}, outside ${author}'s allowed_sources (${entry.allowed_sources.join(', ')})`);
+              }
+            }
+
+            if (problems.length) {
+              core.setFailed(
+                `Scope guard: an allowlisted external contributor may only ADD entries for their own repos.\n - ` +
+                problems.join('\n - ')
+              );
+              return;
+            }
+            console.log(`Scope guard passed: ${author} adds ${added.length} entr${added.length === 1 ? 'y' : 'ies'} (${added.join(', ') || 'none'}), all within allowed_sources.`);
+
+            // --- helpers ---
+            function normalizeUrl(u) {
+              return String(u).trim().toLowerCase()
+                .replace(/^git\+/, '')
+                .replace(/^https?:\/\//, '')
+                .replace(/\.git$/, '')
+                .replace(/\/+$/, '');   // no trailing slash; matching adds the boundary
+            }
+            async function readPlugins(owner, repo, ref) {
+              try {
+                const { data } = await github.rest.repos.getContent({ owner, repo, ref, path: MARKETPLACE });
+                const json = JSON.parse(Buffer.from(data.content, 'base64').toString('utf8'));
+                const map = {};
+                for (const p of (json.plugins || [])) { if (p && p.name) map[p.name] = p; }
+                return map;
+              } catch (e) {
+                console.log(`readPlugins(${owner}/${repo}@${ref}): ${e.message}`);
+                return null;
+              }
+            }