chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:11:34 +08:00
commit f20bdd32f3
408 changed files with 77098 additions and 0 deletions
+101
View File
@@ -0,0 +1,101 @@
name: Bump Plugin SHAs
# Nightly sweep: for each external entry whose upstream HEAD has moved past
# its pinned SHA, validate at the new SHA with `claude plugin validate`
# inline, then open one PR per bumped plugin on branch `bump/<slug>`.
# Failing entries stay isolated in their own PR; passing bumps merge
# independently.
#
# Bot-free — uses the default GITHUB_TOKEN. PRs opened with GITHUB_TOKEN don't
# trigger on:pull_request workflows, so the required status checks on main
# (`scan` from Scan Plugins, `check` from Check MCP URLs, `validate` from
# Validate Plugins) would never run and the bump PR could never merge.
# workflow_dispatch is exempt from that recursion guard, so we dispatch all
# three ourselves against each per-entry bump branch after its PR is opened.
# Each check run lands on the branch HEAD — the same SHA as the PR head — and
# satisfies the corresponding required check. (Each of those workflows runs
# its job unconditionally on workflow_dispatch, so a dispatch always reports.)
#
# max-bumps caps the per-night work for cost control. Per-entry scans are
# more expensive than a single batched scan, so the cap is conservative.
# The composite action skips entries that already have an open bump PR, so
# re-dispatches don't pile up duplicate work.
on:
schedule:
- cron: '23 7 * * *' # Daily 07:23 UTC
workflow_dispatch:
inputs:
max_bumps:
description: Cap on plugins bumped this run
required: false
default: '30'
plugin:
description: >-
Bump ONLY this plugin name (exact entry name; empty = all stale). A
frozen/sha-exempt target is still skipped (same as a full run).
required: false
default: ''
permissions:
contents: write
pull-requests: write
actions: write # gh workflow run {scan-plugins,check-mcp-urls,validate-plugins}.yml per bump branch
concurrency:
group: bump-plugin-shas
jobs:
bump:
runs-on: ubuntu-latest
# Per-bump cost is ~2s (ls-remote + shallow clone + validate); 30 entries
# is ~1-2 min. The 60 min ceiling absorbs slow upstreams without letting a
# pathological run consume the default 360 min budget.
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
# createCommitOnBranch-based bump so commits are signed by GitHub and
# satisfy the org-level required_signatures ruleset on main.
- uses: anthropics/claude-plugins-community/.github/actions/bump-plugin-shas@426e469f322952061102b286b378c0c9733a0934
id: bump
with:
marketplace-path: .claude-plugin/marketplace.json
max-bumps: ${{ inputs.max_bumps || '30' }}
only: ${{ inputs.plugin }}
pr-mode: per-entry
claude-cli-version: latest
# Per-entry fan-out: dispatch the three required checks against each bump
# branch. `pr-urls` is a JSON array of {name, old_sha, new_sha, branch,
# pr_url} entries emitted by the composite action when pr-mode is
# per-entry. All three (scan / check / validate) are required on main and
# none fire on the GITHUB_TOKEN-opened PR, so each must be dispatched.
# A single failed dispatch (transient API error / rate limit) must not
# strand the remaining branches, so we attempt every dispatch, then fail
# the step if any failed: a missing required check would otherwise leave
# its bump PR silently blocked behind a green run, and the composite
# action skips slugs with an open PR so it would never be retried.
- name: Dispatch required checks per per-entry PR
if: steps.bump.outputs.pr-urls != '' && steps.bump.outputs.pr-urls != '[]'
env:
GH_TOKEN: ${{ github.token }}
PR_URLS: ${{ steps.bump.outputs.pr-urls }}
run: |
set -euo pipefail
dispatch_failures="$(mktemp)"
jq -c '.[]' <<<"$PR_URLS" | while read -r entry; do
branch=$(jq -r '.branch' <<<"$entry")
name=$(jq -r '.name' <<<"$entry")
for wf in scan-plugins check-mcp-urls validate-plugins; do
echo "Dispatching ${wf}.yml against $branch ($name)"
if ! gh workflow run "${wf}.yml" --ref "$branch"; then
echo "::error::Failed to dispatch ${wf}.yml against $branch ($name) — required check will be missing; re-dispatch with: gh workflow run ${wf}.yml --ref $branch"
echo "${wf} ${branch}" >> "$dispatch_failures"
fi
done
done
if [ -s "$dispatch_failures" ]; then
echo "::error::$(wc -l < "$dispatch_failures" | tr -d ' ') required-check dispatch(es) failed; the affected bump PR(s) are blocked until re-dispatched (see annotations above)."
exit 1
fi
+137
View File
@@ -0,0 +1,137 @@
name: Check MCP URLs
# Liveness check for http/sse MCP server URLs declared by plugins vendored
# in this repo. Catches typos in new submissions and upstream endpoints that
# disappear after merge.
#
# Scope: only plugins whose files live in this working tree (marketplace
# entries with a string `source`, e.g. "./plugins/foo"). External entries
# are pinned to an upstream repo at a SHA — reading their .mcp.json would
# mean cloning every upstream on each run, which is slow and flaky. Those
# are out of scope for now.
#
# What counts as "alive": anything that proves the hostname/path resolves to
# a server. 401/403/405/5xx all pass — auth and method errors are expected
# without credentials. Only 404/410 and connection/DNS/TLS failures fail.
on:
pull_request:
paths:
- '.claude-plugin/marketplace.json'
- 'plugins/**'
- 'external_plugins/**'
- '.github/workflows/check-mcp-urls.yml'
schedule:
- cron: '0 6 * * *'
workflow_dispatch:
permissions:
contents: read
jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Discover and probe MCP server URLs
run: |
set -euo pipefail
MARKETPLACE=".claude-plugin/marketplace.json"
# Each line: "<plugin>\t<server>\t<url>". Marketplace entries with a
# string `source` are local paths; objects describe an external repo
# pinned at a SHA, which we don't have checked out — skip those.
discover() {
jq -r '.plugins[] | select(.source | type == "string") | "\(.name)\t\(.source)"' "$MARKETPLACE" |
while IFS=$'\t' read -r plugin src; do
dir="${src#./}"
[[ -d "$dir" ]] || continue
for cfg in "$dir/.mcp.json" "$dir/mcp.json" "$dir/.claude-plugin/plugin.json"; do
[[ -f "$cfg" ]] || continue
# MCP config comes in two shapes: a bare map of server name ->
# config, or wrapped under a top-level "mcpServers" key (also
# the shape inside plugin.json). Normalize, then keep entries
# with an http/sse type and a string url.
# Skip entries with empty url — those are placeholders awaiting
# user config, not dead endpoints, and would false-fail.
jq -r --arg plugin "$plugin" '
(if (type == "object" and has("mcpServers")) then .mcpServers else . end)
| to_entries[]
| select((.value | type) == "object")
| select(.value.type == "http" or .value.type == "sse")
| select(.value.url | type == "string" and . != "")
| "\($plugin)\t\(.key)\t\(.value.url)"
' "$cfg" 2>/dev/null || true
done
done | sort -u
}
# Returns 0 on pass, 1 on fail; prints "PASS|FAIL <code> <note>".
probe() {
local url="$1"
local code
# HEAD first — cheap and covers plain web endpoints. -L follows
# redirects so a permanent redirect to a live page still passes.
#
# On a connection-level failure curl writes "000" to -w AND exits
# nonzero. The fallback assignment must happen OUTSIDE the command
# substitution — `... || echo "000"` inside $() would *append* a
# second "000", producing "000000" which falls through the case
# statement and silently passes a dead host.
code="$(curl -sS -o /dev/null -w '%{http_code}' \
--connect-timeout 10 --max-time 10 \
--retry 2 --retry-delay 2 \
-L -I "$url" 2>/dev/null)" || code="000"
# MCP endpoints typically reject HEAD (404/405) but answer POST
# with a JSON-RPC body. Retry as a real MCP client would.
if [[ "$code" == "000" || "$code" == "404" || "$code" == "405" ]]; then
code="$(curl -sS -o /dev/null -w '%{http_code}' \
--connect-timeout 10 --max-time 10 \
--retry 2 --retry-delay 2 \
-L -X POST \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
--data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"ci","version":"0"}}}' \
"$url" 2>/dev/null)" || code="000"
fi
case "$code" in
000) echo "FAIL $code unreachable"; return 1 ;;
404|410) echo "FAIL $code gone"; return 1 ;;
*) echo "PASS $code"; return 0 ;;
esac
}
entries="$(discover)"
if [[ -z "$entries" ]]; then
echo "::notice::No http/sse MCP server URLs found in vendored plugins."
exit 0
fi
failures=0
printf '%-24s %-18s %-52s %s\n' "PLUGIN" "SERVER" "URL" "RESULT"
while IFS=$'\t' read -r plugin server url; do
# Skip URLs with template placeholders — they need user config
# and can't be probed as-is.
if [[ "$url" == *'${'* || "$url" == *'{{'* ]]; then
printf '%-24s %-18s %-52s %s\n' "$plugin" "$server" "$url" "SKIP templated"
continue
fi
result="$(probe "$url")" || true
printf '%-24s %-18s %-52s %s\n' "$plugin" "$server" "$url" "$result"
if [[ "$result" == FAIL* ]]; then
failures=$((failures + 1))
echo "::error::MCP server URL for plugin '$plugin' (server '$server') is unreachable: $url ($result)"
fi
done <<< "$entries"
echo
if (( failures > 0 )); then
echo "::error::$failures MCP server URL(s) failed liveness check."
exit 1
fi
echo "All MCP server URLs reachable."
+63
View File
@@ -0,0 +1,63 @@
name: Close External PRs
on:
pull_request_target:
types: [opened]
permissions:
pull-requests: write
issues: write
contents: read
jobs:
check-membership:
if: vars.DISABLE_EXTERNAL_PR_CHECK != 'true'
runs-on: ubuntu-latest
steps:
# pull_request_target: checks out the BASE repo (trusted), so the allowlist + shared
# script below are this repo's versions, never the fork's.
- uses: actions/checkout@v4
- name: Close PR unless author is a member or the PR is an in-scope external contribution
uses: actions/github-script@v7
with:
script: |
const author = context.payload.pull_request.user.login;
const { evaluate, isExemptAuthor } = require(`${process.env.GITHUB_WORKSPACE}/.github/scripts/external-pr-scope.js`);
// Members (write/admin) and the repo's own automation bot (bump SHA PRs) are never
// auto-closed.
const ex = await isExemptAuthor({ github, context });
if (ex.exempt) {
console.log(`${ex.reason} — allowing PR`);
return;
}
// Non-member: allow the PR to stay open ONLY if it is an in-scope external
// contribution — it adds marketplace.json entries whose source repo ALREADY backs
// a live plugin here, and changes nothing else. (No maintained allowlist: the set
// of allowed repos is derived from the live marketplace.) This grants only the
// right to open a reviewable PR; the validate + scan checks and a maintainer
// approval still gate the merge (the External PR Scope Guard is advisory signal,
// not a required check).
const result = await evaluate({ github, context });
if (result.ok && result.added.length > 0) {
console.log(`In-scope external contribution (adds: ${result.added.join(', ')}) — allowing PR.`);
return;
}
console.log(`Closing PR from ${author}: ${result.problems.join('; ') || 'out of scope'}`);
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body: `Thanks for your interest! This repo only accepts contributions from Anthropic team members. If you'd like to submit a plugin to the marketplace, please submit your plugin [here](https://clau.de/plugin-directory-submission).`
});
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
state: 'closed'
});
@@ -0,0 +1,54 @@
name: External PR Scope Guard
# Advisory check that surfaces what a NON-MEMBER pull request may change.
# Members (write/admin) and the repo's own automation bot (bump SHA PRs) are unrestricted and
# skip this check. For a non-member PR this fails unless the PR is an in-scope external
# contribution per .github/scripts/external-pr-scope.js: it changes ONLY
# .claude-plugin/marketplace.json, the delta is additions-only (no existing entry modified or
# removed), and every ADDED entry's source.url is a repo that ALREADY backs a live plugin in
# this marketplace (the allowed set is derived from the live marketplace — there is no
# maintained allowlist).
#
# Do NOT add this job to branch protection as a required status check. The merge gate is the
# `validate` + `scan` checks plus a maintainer approval; this guard is advisory signal for the
# reviewer, not a hard gate. (Making it required would block the no-approval bump-merge path.)
#
# Security: runs on pull_request_target but checks out only the BASE repo (trusted) for the
# shared script; the head 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)
- uses: actions/github-script@v7
with:
script: |
const { evaluate, isExemptAuthor } = require(`${process.env.GITHUB_WORKSPACE}/.github/scripts/external-pr-scope.js`);
// Members (write/admin) and the repo's own automation bot (bump SHA PRs) are
// unrestricted; only genuinely external contributions are scope-checked.
const ex = await isExemptAuthor({ github, context });
if (ex.exempt) {
console.log(`${ex.reason} — scope guard not applicable.`);
return;
}
const result = await evaluate({ github, context });
if (!result.ok) {
core.setFailed(
`Scope guard: a non-member PR may only ADD marketplace.json entries whose source repo already backs a live plugin here.\n - ` +
result.problems.join('\n - ')
);
return;
}
console.log(`Scope guard passed: adds ${result.added.join(', ') || 'none'}, all from repos already live here.`);
+284
View File
@@ -0,0 +1,284 @@
name: Revert Failed Bumps
# Drops policy-failing entries from a bump PR so one bad upstream can't
# block the rest. Runs after a Scan Plugins workflow_run on bump/plugin-shas
# concludes with a failure: read the per-entry verdicts the scan uploaded,
# revert just the failing entries' source.sha back to main's pin, push a
# follow-up signed commit, and re-dispatch the scan. The re-dispatched scan
# finds only cached-pass entries in the new diff and goes green in seconds.
#
# Scope and guardrails — this job has contents:write so it must be tight:
# - Only acts on bump/plugin-shas (literal branch match).
# - Only acts when the scan was dispatched (workflow_dispatch event), i.e.
# by bump-plugin-shas.yml. A scan on a regular PR never triggers this.
# - Only reverts source.sha. If any other field in a failing entry differs
# from main, the run aborts — that means the bump branch was tampered
# with and a human needs to look.
# - Bounded at MAX_REVERT_PASSES per night via a PR comment marker; a
# persistent loop means the cache or scan is broken and a human needs
# to look.
# - The revert commit is created with createCommitOnBranch (GitHub-signed,
# compare-and-swap via expectedHeadOid) — no signing key on the runner.
on:
workflow_run:
workflows: ["Scan Plugins"]
types: [completed]
permissions:
contents: read
env:
MARKETPLACE: .claude-plugin/marketplace.json
BUMP_BRANCH: bump/plugin-shas
MAX_REVERT_PASSES: '3'
REVERT_MARKER: '<!-- revert-failed-bumps -->'
jobs:
revert:
# Tight gate: the triggering scan must be a workflow_dispatch run on the
# bump branch (i.e. the one bump-plugin-shas.yml dispatched) that failed.
# A scan on a regular PR, a passing scan, or a manual dispatch on another
# branch must never reach this job.
if: >
github.event.workflow_run.conclusion == 'failure' &&
github.event.workflow_run.event == 'workflow_dispatch' &&
github.event.workflow_run.head_branch == 'bump/plugin-shas'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write # createCommitOnBranch on bump/plugin-shas
pull-requests: write # comment on / close the bump PR
actions: write # gh workflow run scan-plugins.yml --ref bump/plugin-shas
concurrency:
group: revert-failed-bumps
cancel-in-progress: false
steps:
# The artifact carries run-failed.json (just plugin names) and
# run-verdicts.json (full per-entry verdicts for the PR comment). It is
# uploaded by scan-plugins.yml for every relevant run so we can tell
# "policy failures found" from "scan never ran" (infra error → no revert).
# The artifact won't exist when the scan died before the upload step
# (cache restore error, jq failure, timeout) — that is an infra error,
# not a policy failure, so the right move is to do nothing. The
# download must not fail the job; the next step handles the missing file.
- name: Download scan verdicts
continue-on-error: true
uses: actions/download-artifact@v4
with:
name: scan-verdicts
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}
path: scan-out
- name: Determine revert set
id: plan
run: |
set -euo pipefail
if [[ ! -f scan-out/run-failed.json ]]; then
echo "::warning::No run-failed.json in scan artifact — nothing to revert."
echo "act=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if ! jq -e 'type == "array"' scan-out/run-failed.json >/dev/null 2>&1; then
echo "::warning::run-failed.json is not a JSON array — refusing to act."
echo "act=false" >> "$GITHUB_OUTPUT"
exit 0
fi
fail_count="$(jq 'length' scan-out/run-failed.json)"
if [[ "$fail_count" -eq 0 ]]; then
# The scan job failed but reported zero policy failures: that is
# an infra error (API key missing, clone failure, schema break).
# Reverting nothing is correct; surfacing the infra error is the
# scan job's responsibility.
echo "::notice::Scan failed with zero parsed policy failures — infra error, not a policy failure. Not reverting."
echo "act=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "act=true" >> "$GITHUB_OUTPUT"
echo "fail_count=$fail_count" >> "$GITHUB_OUTPUT"
echo "Failing entries:"
jq -r '.[]' scan-out/run-failed.json
- name: Locate bump PR and check revert budget
if: steps.plan.outputs.act == 'true'
id: pr
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
# Resolve the bump PR by head ref. `gh pr list --head <ref>` matches
# by ref name across forks, so reject any PR whose head repo isn't
# ours — a fork PR named bump/plugin-shas must never reach the
# contents:write paths below.
pr_json="$(gh api "repos/$REPO/pulls?head=${REPO%%/*}:$BUMP_BRANCH&base=main&state=open&per_page=1" \
--jq '.[0] // empty')"
if [[ -z "$pr_json" ]]; then
echo "::warning::No open bump PR on $BUMP_BRANCH — nothing to revert."
echo "act=false" >> "$GITHUB_OUTPUT"
exit 0
fi
pr_number="$(jq -r '.number' <<<"$pr_json")"
head_repo="$(jq -r '.head.repo.full_name' <<<"$pr_json")"
head_sha="$(jq -r '.head.sha' <<<"$pr_json")"
# The list endpoint omits `commits`; the single-PR endpoint has it.
commit_count="$(gh api "repos/$REPO/pulls/$pr_number" --jq '.commits')"
if [[ "$head_repo" != "$REPO" ]]; then
echo "::error::Bump PR head is from $head_repo, not $REPO — refusing to act."
echo "act=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Loop bound: every nightly bump force-resets the branch to a single
# commit and every revert pass adds exactly one. Counting commits is
# therefore the per-night pass count + 1, with no date math, no
# pagination, and no exposure to comment spoofing.
if [[ "$commit_count" -gt $(( MAX_REVERT_PASSES + 1 )) ]]; then
echo "::error::Revert budget exhausted ($((commit_count - 1))/$MAX_REVERT_PASSES passes on this PR). The cache or scan is likely broken — needs a human."
gh pr comment "$pr_number" --repo "$REPO" --body \
"$REVERT_MARKER"$'\n\n'"⚠️ Revert budget exhausted ($((commit_count - 1)) passes). The scan keeps failing after reverting — likely a cache or scan bug. Pausing automatic reverts until the next nightly bump."
echo "act=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Bump PR #$pr_number @ $head_sha ($commit_count commit(s))"
{
echo "act=true"
echo "number=$pr_number"
echo "head_sha=$head_sha"
} >> "$GITHUB_OUTPUT"
- name: Revert failing SHAs
if: steps.plan.outputs.act == 'true' && steps.pr.outputs.act == 'true'
id: revert
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
run: |
set -euo pipefail
mkdir -p work
gh api "repos/$REPO/contents/${MARKETPLACE}?ref=$HEAD_SHA" --jq '.content' | base64 -d > work/head.json
gh api "repos/$REPO/contents/${MARKETPLACE}?ref=main" --jq '.content' | base64 -d > work/base.json
# Build the reverted marketplace: for each failing plugin, restore
# source.sha to main's value. Refuse if anything else differs — a
# difference outside source.sha on a bump-branch entry means the
# branch was tampered with.
jq -c -s \
'.[0] as $head | .[1] as $base | (.[2] | map({(.): true}) | add // {}) as $fail
| ($base.plugins | map({(.name): .}) | add // {}) as $b
| $head | .plugins = [
.plugins[] |
if ($fail[.name] // false) and ($b[.name] // null) != null then
# Verify the only delta is source.sha — never silently
# accept a structural change masquerading as a bump.
if (. | del(.source.sha)) == ($b[.name] | del(.source.sha)) then
.source.sha = $b[.name].source.sha
else
error("entry \(.name) differs from main beyond source.sha — refusing to revert")
end
else . end
]' \
work/head.json work/base.json scan-out/run-failed.json > work/reverted.json.compact
# Match the marketplace's existing pretty-print so the diff is
# human-reviewable.
jq --indent 2 '.' work/reverted.json.compact > work/reverted.json
# Two no-action cases:
# - nothing actually reverted (failed names not in this PR's diff)
# - everything reverted (the file is back to main → PR is empty)
if cmp -s work/reverted.json.compact <(jq -c '.' work/head.json); then
echo "::notice::No entries to revert (failing names not in this PR)."
echo "committed=false" >> "$GITHUB_OUTPUT"
echo "empty=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if cmp -s work/reverted.json.compact <(jq -c '.' work/base.json); then
echo "::warning::Every bumped entry failed policy — the PR would be empty."
echo "committed=false" >> "$GITHUB_OUTPUT"
echo "empty=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Vendored entries have a string `source` — restrict to object
# sources or `.source.sha` errors.
reverted="$(jq -c -s \
'.[0] as $head | .[1] as $rev
| ($head.plugins | map(select(.source | type == "object") | {(.name): .source.sha}) | add // {}) as $h
| [$rev.plugins[] | select(.source | type == "object")
| select(($h[.name] // null) != .source.sha) | .name]' \
work/head.json work/reverted.json.compact)"
echo "Reverted: $reverted"
echo "reverted=$reverted" >> "$GITHUB_OUTPUT"
msg="Drop $(jq 'length' <<<"$reverted") policy-failing entries from bump"
# createCommitOnBranch: GitHub-signed, expectedHeadOid CAS so a
# concurrent force-reset from the nightly bump fails this push
# loudly instead of being clobbered. The base64'd marketplace can
# exceed MAX_ARG_STRLEN, so the body travels via stdin.
oid="$(jq -n \
--rawfile content work/reverted.json \
--arg repo "$REPO" \
--arg branch "$BUMP_BRANCH" \
--arg oid "$HEAD_SHA" \
--arg msg "$msg" \
--arg path "$MARKETPLACE" \
'{
query: "mutation($repo:String!,$branch:String!,$oid:GitObjectID!,$msg:String!,$path:String!,$contents:Base64String!){createCommitOnBranch(input:{branch:{repositoryNameWithOwner:$repo,branchName:$branch},message:{headline:$msg},fileChanges:{additions:[{path:$path,contents:$contents}]},expectedHeadOid:$oid}){commit{oid}}}",
variables: { repo: $repo, branch: $branch, oid: $oid, msg: $msg, path: $path, contents: ($content | @base64) }
}' \
| gh api graphql --input - --jq '.data.createCommitOnBranch.commit.oid')"
[[ "$oid" =~ ^[0-9a-f]{40}$ ]] || { echo "::error::createCommitOnBranch did not return a commit OID."; exit 1; }
echo "committed=true" >> "$GITHUB_OUTPUT"
echo "empty=false" >> "$GITHUB_OUTPUT"
echo "::notice::Pushed revert commit $oid to $BUMP_BRANCH."
- name: Close empty bump PR
if: steps.revert.outputs.empty == 'true'
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR: ${{ steps.pr.outputs.number }}
run: |
set -euo pipefail
gh pr comment "$PR" --repo "$REPO" --body \
"$REVERT_MARKER"$'\n\n'"Every bumped entry failed the policy scan. Closing — the next nightly run will retry."
gh pr close "$PR" --repo "$REPO"
- name: Comment with revert detail
if: steps.revert.outputs.committed == 'true'
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR: ${{ steps.pr.outputs.number }}
REVERTED: ${{ steps.revert.outputs.reverted }}
SCAN_RUN_URL: ${{ github.event.workflow_run.html_url }}
run: |
set -euo pipefail
{
printf '%s\n\n' "$REVERT_MARKER"
echo "Dropped $(jq 'length' <<<"$REVERTED") entrie(s) that failed the policy scan. The remaining bumps were unaffected."
echo
echo "| Plugin | Violations |"
echo "|---|---|"
# `violations` is model-generated text shaped by a cloned external
# repo. Strip markdown control characters and wrap in a code span
# so a prompt-injected upstream can't smuggle links/images/table
# breakouts into a public PR comment.
jq -r --argjson rev "$REVERTED" \
'def neutralize: gsub("[|\n\r\\[\\]<>`]"; " ");
.[] | select(.name as $n | $rev | index($n))
| "| \(.name) | `\(.violations | neutralize | .[0:200])` |"' \
scan-out/run-verdicts.json
echo
echo "These entries will be retried at their next upstream SHA. See the [scan run]($SCAN_RUN_URL) for full verdicts."
} > /tmp/comment.md
gh pr comment "$PR" --repo "$REPO" --body-file /tmp/comment.md
- name: Re-dispatch scan on revised bump branch
if: steps.revert.outputs.committed == 'true'
env:
GH_TOKEN: ${{ github.token }}
run: gh workflow run scan-plugins.yml --ref "$BUMP_BRANCH"
+546
View File
@@ -0,0 +1,546 @@
name: Scan Plugins
# Claude policy scan of changed external marketplace entries.
#
# `scan` is a required status check on main. A path-filtered workflow never
# reports a check run when its paths don't match, which would leave unrelated
# PRs blocked forever — so this workflow runs on every PR and skips the heavy
# scan setup at the step level when nothing scan-relevant changed. The check
# always reports.
#
# Verdict cache: each (plugin, sha) pair is scanned at most once. The bump
# workflow force-resets bump/plugin-shas every night, which makes the same
# SHAs reappear in the diff on consecutive nights — without a cache, the
# scan would re-burn ~90s of Claude time per entry per night. The cache is
# keyed on the policy hash so a prompt or schema change invalidates all
# verdicts and triggers a clean re-scan.
#
# Failure handling: a cached `passes:false` verdict still fails the job. The
# Revert Failed Bumps workflow (revert-failed-bumps.yml) reacts to that by
# dropping the failing entries from the bump PR, so one bad upstream can't
# block the rest. After the revert, the re-dispatched scan finds only
# cached-pass entries and goes green in seconds.
on:
pull_request:
workflow_dispatch:
inputs:
scan_all:
description: Scan every external entry (full re-review). Slow.
type: boolean
default: false
permissions:
contents: read
id-token: write # Anthropic Workload Identity Federation (scan-plugins action)
# Serialize scans per ref so concurrent runs (a re-dispatch racing the
# original, or a manual dispatch) don't both restore the same cache, scan
# overlapping sets, and lose one another's verdicts on save.
concurrency:
group: scan-plugins-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: false
env:
MARKETPLACE: .claude-plugin/marketplace.json
CACHE_DIR: ${{ github.workspace }}/.scan-cache
CACHE_TTL_DAYS: '30'
jobs:
scan:
runs-on: ubuntu-latest
timeout-minutes: 360
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# Same paths the workflow-level filter used to gate on. workflow_dispatch
# always runs the scan (no PR diff to inspect).
- name: Check for scan-relevant changes
id: changes
env:
EVENT_NAME: ${{ github.event_name }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
set -euo pipefail
if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
echo "relevant=true" >> "$GITHUB_OUTPUT"
echo "base_ref=origin/main" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "base_ref=$BASE_SHA" >> "$GITHUB_OUTPUT"
if git diff --quiet "$BASE_SHA" HEAD -- "$MARKETPLACE" .github/policy/; then
echo "relevant=false" >> "$GITHUB_OUTPUT"
echo "::notice::No changes to marketplace.json or policy/ — skipping policy scan."
else
echo "relevant=true" >> "$GITHUB_OUTPUT"
fi
# Auth: the shared scan-plugins action below uses Workload Identity
# Federation (anthropic-federation-rule-id input) — the IDs are literal
# in this file, so the action's "skip if no auth" path can't trigger.
# The previous "Require ANTHROPIC_API_KEY" fail-closed guard is
# therefore no longer needed.
# Verdict cache, keyed on the policy content hash. A prompt change
# invalidates every cached verdict — that is intentional. The save key
# includes run_id so each run writes a fresh cache; restore-keys picks
# the most recent one. Verdicts older than CACHE_TTL_DAYS are pruned on
# restore to bound cache size as the marketplace grows.
- name: Restore verdict cache
if: steps.changes.outputs.relevant == 'true'
id: cache-restore
uses: actions/cache/restore@v4
with:
path: .scan-cache
# run_attempt so a re-run can save its own verdicts (cache keys are
# immutable; without it a re-run would silently fail to save).
key: scan-verdicts-${{ hashFiles('.github/policy/**') }}-${{ github.run_id }}-${{ github.run_attempt }}
restore-keys: |
scan-verdicts-${{ hashFiles('.github/policy/**') }}-
# Split the diff into cached (skip) and uncached (scan) entries. The
# cache key is "<name>@<sha>" — a SHA is immutable, so a verdict for a
# given (plugin, sha) is permanent under a fixed policy.
- name: Filter scan targets against cache
if: steps.changes.outputs.relevant == 'true'
id: filter
env:
BASE_REF: ${{ steps.changes.outputs.base_ref }}
SCAN_ALL: ${{ inputs.scan_all || 'false' }}
TTL_DAYS: ${{ env.CACHE_TTL_DAYS }}
run: |
set -euo pipefail
mkdir -p "$CACHE_DIR"
# Initialize / prune the verdict map.
if [[ -f "$CACHE_DIR/verdicts.json" ]] && jq -e 'type == "object"' "$CACHE_DIR/verdicts.json" >/dev/null 2>&1; then
# Drop entries older than TTL. Verdicts are immutable per (plugin, sha)
# but pruning keeps the cache from accumulating forever.
cutoff="$(date -u -d "-${TTL_DAYS} days" +%Y-%m-%dT%H:%M:%SZ)"
jq --arg cutoff "$cutoff" \
'with_entries(select(.value.scanned_at >= $cutoff))' \
"$CACHE_DIR/verdicts.json" > "$CACHE_DIR/verdicts.json.tmp"
mv "$CACHE_DIR/verdicts.json.tmp" "$CACHE_DIR/verdicts.json"
else
echo '{}' > "$CACHE_DIR/verdicts.json"
fi
# Build the change set: entries in HEAD whose object differs from base.
# scan_all overrides to "every external entry" (full re-review).
if [[ "$SCAN_ALL" == "true" ]]; then
jq -c '[.plugins[] | select(.source | type == "object")]' "$MARKETPLACE" \
> "$CACHE_DIR/changed.json"
else
if git cat-file -e "${BASE_REF}:${MARKETPLACE}" 2>/dev/null; then
git show "${BASE_REF}:${MARKETPLACE}" > "$CACHE_DIR/base.json"
else
echo '{"plugins":[]}' > "$CACHE_DIR/base.json"
fi
jq -c -s \
'(.[0].plugins | map({(.name): .}) | add // {}) as $b
| [.[1].plugins[]
| select(.source | type == "object")
| select(($b[.name] // null) != .)]' \
"$CACHE_DIR/base.json" "$MARKETPLACE" > "$CACHE_DIR/changed.json"
fi
changed_count="$(jq 'length' "$CACHE_DIR/changed.json")"
# Split changed entries into cached vs uncached. A hit requires the
# *whole* source object (repo, sha, path, ref) to match the cached
# entry, not just name@sha — a repo migration or path change with the
# same SHA is different scan content and must miss the cache.
jq -c -s \
'.[0] as $cache
| (.[1] | map(. + {key: (.name + "@" + (.source.sha // "")) })) as $entries
| {
to_scan: [$entries[] | select(($cache[.key].source // null) != .source)],
cached: [$entries[] | select(($cache[.key].source // null) == .source)
| . + {verdict: $cache[.key]}]
}' \
"$CACHE_DIR/verdicts.json" "$CACHE_DIR/changed.json" > "$CACHE_DIR/split.json"
jq -c '.to_scan' "$CACHE_DIR/split.json" > "$CACHE_DIR/to-scan.json"
jq -c '.cached' "$CACHE_DIR/split.json" > "$CACHE_DIR/cached.json"
to_scan_count="$(jq 'length' "$CACHE_DIR/to-scan.json")"
cached_count="$(jq 'length' "$CACHE_DIR/cached.json")"
cached_fail_count="$(jq '[.[] | select(.verdict.passes == false)] | length' "$CACHE_DIR/cached.json")"
# Build a filtered marketplace containing only the uncached entries.
# Passing this as the action's marketplace-path means the action's own
# base diff (which can't resolve a path outside git) falls back to an
# empty base and scans everything in the file — which is exactly the
# to-scan set. Annotations point to the temp file rather than the real
# marketplace, but the per-entry verdicts still land in the artifact
# and the step summary.
jq -c '{plugins: .}' "$CACHE_DIR/to-scan.json" > "$CACHE_DIR/scan-targets.json"
{
echo "changed=$changed_count"
echo "to_scan=$to_scan_count"
echo "cached=$cached_count"
echo "cached_failures=$cached_fail_count"
} >> "$GITHUB_OUTPUT"
echo "::notice::$changed_count changed entrie(s): $cached_count cached ($cached_fail_count failing), $to_scan_count to scan."
- name: Scan uncached entries
if: steps.changes.outputs.relevant == 'true' && steps.filter.outputs.to_scan != '0'
id: scan
# Capture the action's per-entry outputs even when it exits nonzero.
# The verdict (cached + fresh) is what gates the job, not the action's
# exit code, and the revert workflow needs the artifact even on failure.
continue-on-error: true
# Pinned to claude-plugins-community#34 (WIF input support).
# TODO: re-pin to a main-branch SHA once #34 merges.
uses: anthropics/claude-plugins-community/.github/actions/scan-plugins@426e469f322952061102b286b378c0c9733a0934
with:
# Anthropic auth via Workload Identity Federation — the action
# mints a GitHub OIDC token (id-token: write above) and the claude
# CLI exchanges it for a short-lived bearer. The federation rule is
# bound to this repository (repository_id-pinned).
anthropic-federation-rule-id: fdrl_0147kJdru6bZKTtzwFNEqsDf
anthropic-organization-id: 1ec12c5c-6542-4da8-bf2f-c15919aef01c
anthropic-service-account-id: svac_01DnC3BtPHGjYJEGeuUUXZ8v
marketplace-path: .scan-cache/scan-targets.json
policy-prompt: .github/policy/prompt.md
fail-on-findings: "true"
claude-cli-version: latest
# Merge fresh verdicts into the cache and assemble this run's full
# verdict set (cached + fresh) for downstream consumers. Runs even when
# the scan step failed so that fail verdicts are also cached — that is
# what lets the revert workflow drop them and what stops the same
# failing SHA from being re-scanned every night.
- name: Merge verdicts and assemble run report
if: steps.changes.outputs.relevant == 'true'
id: report
# The action's `scanned` output travels here via an env var, which is
# subject to the OS argv/envp size limit (~128 KiB on Linux). At ~300
# bytes/entry that is ~400 entries — an order of magnitude above the
# cold-start case, and steady state with the cache is ~10/night. If
# the limit is ever hit the runner fails the step before the script
# runs ("argument list too long") — the right response is to clear
# the cache key and lower max-bumps temporarily. Documented here so
# nobody has to rediscover it.
env:
SCANNED_JSON: ${{ steps.scan.outputs.scanned || '[]' }}
run: |
set -euo pipefail
mkdir -p "$CACHE_DIR"
[[ -f "$CACHE_DIR/cached.json" ]] || echo '[]' > "$CACHE_DIR/cached.json"
[[ -f "$CACHE_DIR/changed.json" ]] || echo '[]' > "$CACHE_DIR/changed.json"
# Defensive: a partial or unparseable action output must not poison
# the cache. Treat it as "scanned nothing".
printf '%s' "$SCANNED_JSON" > "$CACHE_DIR/scanned-raw.json"
if ! jq -e 'type == "array"' "$CACHE_DIR/scanned-raw.json" >/dev/null 2>&1; then
echo "::warning::scan action output is not a valid JSON array — treating as empty."
echo '[]' > "$CACHE_DIR/scanned-raw.json"
fi
# Defense in depth: the scan action runs Claude with Read access over
# a cloned external repo. With WIF auth the process env carries a
# short-lived OIDC JWT (masked) and the CLI's exchanged bearer
# rather than a long-lived sk-ant- key, which bounds the blast
# radius of a prompt-injection exfil to a token that expires in
# minutes. The sk-ant- scrubber stays as defense-in-depth (covers
# any future static-key fallback) so key-shaped strings still never
# reach the cache, artifact, or PR comment.
jq -c '(.. | strings) |= gsub("sk-ant-[A-Za-z0-9_-]{8,}"; "[REDACTED]")' \
"$CACHE_DIR/scanned-raw.json" > "$CACHE_DIR/scanned-raw.json.tmp"
mv "$CACHE_DIR/scanned-raw.json.tmp" "$CACHE_DIR/scanned-raw.json"
now="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
# The action's `scanned` output has no SHA or source — join it with
# the change set by name to recover both for the cache key + the
# source-equality lookup guard.
jq -c -s --arg now "$now" \
'.[0] as $changed
| (.[1] // []) as $scanned
| ($changed | map({(.name): .source}) | add // {}) as $srcs
| [$scanned[]
| . + {source: ($srcs[.name] // null), sha: ($srcs[.name].sha // ""), scanned_at: $now}]' \
"$CACHE_DIR/changed.json" "$CACHE_DIR/scanned-raw.json" \
> "$CACHE_DIR/fresh.json"
# Merge fresh verdicts into the cache, keyed by name@sha. The
# full source object is stored so a future repo/path change with the
# same SHA fails the lookup guard. summary/violations are model
# output — truncate to bound cache size (the artifact carries the
# full text for the run that produced it).
jq -c -s \
'.[0] + ([.[1][] | select(.sha != "") | {(.name + "@" + .sha): {
source: .source,
passes: .passes,
summary: ((.summary // "") | .[0:300]),
violations: ((.violations // "") | .[0:500]),
scanned_at: .scanned_at
}}] | add // {})' \
"$CACHE_DIR/verdicts.json" "$CACHE_DIR/fresh.json" \
> "$CACHE_DIR/verdicts.json.tmp"
mv "$CACHE_DIR/verdicts.json.tmp" "$CACHE_DIR/verdicts.json"
# The full per-entry verdict for THIS run's diff: cached verdicts
# plus freshly-scanned verdicts. The revert workflow consumes the
# `failed` list to know exactly which SHAs to drop.
jq -c -s \
'(.[0] | map({name, sha: .source.sha, passes: .verdict.passes,
summary: (.verdict.summary // ""),
violations: (.verdict.violations // ""),
source: "cache"}))
+ (.[1] | map({name, sha, passes,
summary: (.summary // ""),
violations: (.violations // ""),
source: "scan"}))' \
"$CACHE_DIR/cached.json" "$CACHE_DIR/fresh.json" \
> "$CACHE_DIR/run-verdicts.json"
jq -c '[.[] | select(.passes == false) | .name]' "$CACHE_DIR/run-verdicts.json" \
> "$CACHE_DIR/run-failed.json"
fail_count="$(jq 'length' "$CACHE_DIR/run-failed.json")"
total="$(jq 'length' "$CACHE_DIR/run-verdicts.json")"
{
echo "failed_count=$fail_count"
echo "total=$total"
} >> "$GITHUB_OUTPUT"
# `summary` and `violations` are model-generated text shaped by a
# cloned external repo. Strip markdown control characters AND wrap
# in code spans before they hit a publicly-rendered sink — code
# spans neutralize auto-linked bare URLs that a prompt-injected
# upstream could smuggle in. Stripping backticks first stops a
# breakout from the code span.
{
echo "## Policy scan (with verdict cache)"
echo
echo "Changed entries: ${total} · cached: $(jq 'length' "$CACHE_DIR/cached.json") · scanned fresh: $(jq 'length' "$CACHE_DIR/fresh.json") · failures: ${fail_count}"
echo
if [[ "$total" -gt 0 ]]; then
echo "| Plugin | SHA | Passes | Source | Summary |"
echo "|---|---|---|---|---|"
jq -r 'def neutralize: gsub("[|\n\r\\[\\]<>`]"; " ");
.[] | "| \(.name) | `\(.sha[0:8])` | \(if .passes then "✅" else "❌" end) | \(.source) | `\(.summary | neutralize | .[0:120])` |"' \
"$CACHE_DIR/run-verdicts.json"
fi
if [[ "$fail_count" -gt 0 ]]; then
echo
echo "### Violations"
jq -r 'def neutralize: gsub("[|\n\r\\[\\]<>`]"; " ");
.[] | select(.passes == false) | "- **\(.name)** — `\(.violations | neutralize | .[0:500])`"' "$CACHE_DIR/run-verdicts.json"
fi
} >> "$GITHUB_STEP_SUMMARY"
# Used by revert-failed-bumps.yml to know which entries to drop. Always
# uploaded when relevant so the revert workflow can distinguish "scan
# found policy failures" from "scan never ran" (infra error → no revert).
- name: Upload scan verdicts artifact
if: steps.changes.outputs.relevant == 'true'
uses: actions/upload-artifact@v4
with:
name: scan-verdicts
path: |
.scan-cache/run-verdicts.json
.scan-cache/run-failed.json
retention-days: 7
# Save even when the scan failed — fail verdicts are what stop us from
# re-burning Claude time on a known-bad SHA every night.
- name: Save verdict cache
if: always() && steps.changes.outputs.relevant == 'true'
uses: actions/cache/save@v4
with:
path: .scan-cache
key: scan-verdicts-${{ hashFiles('.github/policy/**') }}-${{ github.run_id }}-${{ github.run_attempt }}
# Required-check gate. Fails on either fresh or cached policy failures —
# a known-bad SHA must keep failing until it is reverted or upstream
# fixes it (a new SHA is a new cache key and gets a fresh scan).
- name: Gate on policy verdict
if: steps.changes.outputs.relevant == 'true'
env:
FAILED: ${{ steps.report.outputs.failed_count || '0' }}
SCAN_OUTCOME: ${{ steps.scan.outcome }}
run: |
set -euo pipefail
if [[ "$FAILED" != "0" ]]; then
echo "::error::$FAILED entrie(s) fail policy. See the run summary for verdicts."
exit 1
fi
# The action can also fail without a policy verdict (clone error,
# API error, schema mismatch). With zero parsed failures and a
# nonzero exit, that is an infra error — fail loudly so the revert
# workflow does NOT misread it as "everything passed".
if [[ "$SCAN_OUTCOME" == "failure" ]]; then
echo "::error::Scan step failed without a parseable policy verdict (likely an infra error)."
exit 1
fi
# ─────────────────────────────────────────────────────────────────────────────
# emit-verdict: post a sticky comment per entry to the bump PR with the
# structured verdict, so downstream tooling (label automation, delist
# authoring) can read verdicts directly instead of scraping job logs.
# Sticky comment marker: `<!-- bump-pr-verdict:<name> -->`.
#
# Mirrors the schema_v1 contract from
# anthropics/claude-plugins-community-internal#3908 so the triage scripts
# in mcp-local-directory/scripts/triage/ work uniformly across both repos.
# -official doesn't run per-entry static checks (zombie, schema, binaries,
# etc.) so the `scan.*` axes are emitted as "skipped". The granular policy
# booleans (`has_broad_scope_hooks`, `has_undisclosed_telemetry`,
# `description_matches_behavior`) aren't surfaced by this workflow's
# per-entry artifact yet, so they're emitted as null; the triage
# `triage_bool_to_str` helper maps null → "?" so display is graceful.
# Status describes the execution state, not the outcome — `ran` when the
# scan action evaluated this SHA fresh, `cached` when a prior verdict was
# reused (cf. run-verdicts.json's `source` field). Outcome lives in
# `policy.passes`. policy-sweep.sh dispatches on this exact vocabulary.
#
# PR resolution: pull_request events carry the PR number directly. The
# bump workflow creates bump PRs via GITHUB_TOKEN (which doesn't fire
# pull_request triggers — recursion guard) and dispatches this scan via
# workflow_dispatch on the bump branch. In that case we look up the
# open PR by head ref. No PR (scan_all dispatch on main, etc.) → no-op.
#
# continue-on-error at the job level: emit failure must NOT block the
# `scan` required check. Consumers fall back to log-scraping if the
# comment is absent (gradual migration; no flag day).
# ─────────────────────────────────────────────────────────────────────────────
emit-verdict:
needs: [scan]
if: always() && needs.scan.result != 'skipped' && needs.scan.result != 'cancelled'
runs-on: ubuntu-latest
continue-on-error: true
permissions:
contents: read
pull-requests: write
steps:
- name: Download scan verdicts
uses: actions/download-artifact@v4
with:
name: scan-verdicts
path: /tmp/scan-verdicts
continue-on-error: true
- name: Resolve PR number for this ref
id: pr
env:
GH_TOKEN: ${{ github.token }}
EVENT_NAME: ${{ github.event_name }}
PR_FROM_EVENT: ${{ github.event.pull_request.number }}
REF: ${{ github.ref_name }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
if [[ "$EVENT_NAME" == "pull_request" && -n "$PR_FROM_EVENT" ]]; then
echo "number=$PR_FROM_EVENT" >> "$GITHUB_OUTPUT"
exit 0
fi
# workflow_dispatch on the bump branch: find the open PR for it.
# head filter takes the form owner:branch.
owner="${REPO%%/*}"
pr=$(gh api "/repos/${REPO}/pulls?state=open&head=${owner}:${REF}&per_page=1" \
--jq '.[0].number // ""')
if [[ -z "$pr" ]]; then
echo "::notice::No open PR for ref ${REF} — sticky comments skipped (verdicts still in scan-verdicts artifact)"
fi
echo "number=$pr" >> "$GITHUB_OUTPUT"
- name: Build and post sticky comments
if: steps.pr.outputs.number != ''
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR: ${{ steps.pr.outputs.number }}
RUN_ID: ${{ github.run_id }}
run: |
set -euo pipefail
verdicts_path=/tmp/scan-verdicts/run-verdicts.json
# Missing/empty artifact: scan job ran but didn't produce verdicts
# (e.g. the relevance gate said "no changes"). Nothing to comment;
# exit clean.
if [[ ! -s "$verdicts_path" ]]; then
echo "::notice::No run-verdicts.json artifact — nothing to emit"
exit 0
fi
count=$(jq 'length' "$verdicts_path")
if [[ "$count" == "0" ]]; then
echo "::notice::run-verdicts.json is empty — nothing to emit"
exit 0
fi
ran_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)
# scan.* axes: -official doesn't run per-entry static checks; emit
# "skipped" for each so the schema is shape-compatible with -internal.
scan_stub='{"clone":"skipped","subpath_missing":"skipped","schema":"skipped","zombie":"skipped","tool_allowlist":"skipped","binaries":"skipped","unique":"skipped","mcp":"skipped"}'
# Pre-fetch all PR comments once (paginated) for the marker lookup.
gh api --paginate "/repos/$REPO/issues/$PR/comments" \
--jq '.[] | {id, body}' > /tmp/comments.ndjson
jq -c '.[]' "$verdicts_path" | while read -r entry; do
name=$(jq -r '.name' <<< "$entry")
passes=$(jq -r '.passes' <<< "$entry")
summary=$(jq -r '.summary // ""' <<< "$entry")
violations=$(jq -r '.violations // ""' <<< "$entry")
source=$(jq -r '.source // "scan"' <<< "$entry")
# status = execution state (cf. -internal#3908 vocabulary).
# Outcome is in `passes`. Map source → status: scan-action-run
# → "ran"; cache-served → "cached". Anything else falls through
# as "ran" (only those two values appear in run-verdicts.json).
case "$source" in
cache) status="cached" ;;
scan) status="ran" ;;
*) status="ran" ;;
esac
policy=$(jq -n \
--argjson passes "$passes" \
--arg summary "$summary" \
--arg violations "$violations" \
--arg source "$source" \
--arg status "$status" \
'{passes: $passes,
has_broad_scope_hooks: null,
has_undisclosed_telemetry: null,
description_matches_behavior: null,
summary: $summary,
violations: $violations,
source: $source,
status: $status}')
verdict=$(jq -n \
--argjson scan "$scan_stub" \
--argjson policy "$policy" \
--arg ran_at "$ran_at" \
--arg run_id "$RUN_ID" \
'{schema_version: 1, ran_at: $ran_at, run_id: $run_id, scan: $scan, policy: $policy}')
marker="<!-- bump-pr-verdict:$name -->"
body=$(printf '%s\n```json\n%s\n```' "$marker" "$verdict")
# jq's first() short-circuits and avoids SIGPIPE under pipefail if
# duplicate markers exist (shouldn't, but a prior buggy run could
# double-post). -s slurps NDJSON; `// empty` yields no output when
# no match.
existing=$(jq -rs --arg m "$marker" \
'first(.[] | select(.body | startswith($m)) | .id) // empty' \
/tmp/comments.ndjson)
if [[ -n "$existing" ]]; then
gh api -X PATCH "/repos/$REPO/issues/comments/$existing" -f body="$body" >/dev/null
echo "Updated comment $existing for $name"
else
gh api -X POST "/repos/$REPO/issues/$PR/comments" -f body="$body" >/dev/null
echo "Created comment for $name"
fi
done
@@ -0,0 +1,42 @@
name: Validate Frontmatter
on:
pull_request:
paths:
- '**/agents/*.md'
- '**/skills/*/SKILL.md'
- '**/commands/*.md'
jobs:
validate:
# Fork PRs are auto-closed by close-external-prs.yml, so skip validation
# for them entirely. This also prevents untrusted filenames from forks
# from ever reaching the shell steps below.
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 (sha-pinned)
- name: Install dependencies
run: cd .github/scripts && bun install yaml
- name: Get changed frontmatter files
id: changed
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
# Use diff-filter=AMRC to exclude deleted files (D) - only Added, Modified, Renamed, Copied
FILES=$(gh pr diff "$PR_NUMBER" --name-only --diff-filter=AMRC | grep -E '(agents/.*\.md|skills/.*/SKILL\.md|commands/.*\.md)$' || true)
echo "files<<EOF" >> "$GITHUB_OUTPUT"
echo "$FILES" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
- name: Validate frontmatter
if: steps.changed.outputs.files != ''
env:
FILES: ${{ steps.changed.outputs.files }}
run: |
printf '%s\n' "$FILES" | xargs bun .github/scripts/validate-frontmatter.ts
+49
View File
@@ -0,0 +1,49 @@
name: Validate Plugin Licenses
on:
pull_request:
paths:
- 'plugins/**'
push:
branches: [main]
paths:
- 'plugins/**'
permissions:
contents: read
jobs:
validate-licenses:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check every plugin has an Apache 2.0 LICENSE file
run: |
set -euo pipefail
missing=()
wrong_content=()
for plugin_dir in plugins/*/; do
plugin="${plugin_dir%/}"
if [[ ! -f "$plugin/LICENSE" ]]; then
missing+=("$plugin")
elif ! grep -q "Apache License" "$plugin/LICENSE" || \
! grep -q "Version 2.0" "$plugin/LICENSE"; then
wrong_content+=("$plugin")
fi
done
if [[ "${#missing[@]}" -gt 0 ]]; then
echo "::error::The following plugins are missing a LICENSE file:"
for p in "${missing[@]}"; do
echo " - $p"
done
exit 1
fi
if [[ "${#wrong_content[@]}" -gt 0 ]]; then
echo "::error::The following plugins have a LICENSE file that does not contain Apache 2.0 text:"
for p in "${wrong_content[@]}"; do
echo " - $p"
done
exit 1
fi
echo "All $(ls -d plugins/*/ | wc -l) plugins have an Apache 2.0 LICENSE file."
+53
View File
@@ -0,0 +1,53 @@
name: Validate Plugins
on:
pull_request:
paths:
- '.claude-plugin/**'
- '*/.claude-plugin/**'
- '*/agents/**'
- '*/skills/**'
- '*/commands/**'
# `validate` is a required status check, so a PR that touches ONLY workflow
# files (e.g. an action-SHA re-pin) would otherwise never trigger validate
# and sit "Expected — Waiting for status to be reported" forever (workflow_dispatch
# check runs aren't associated with the PR, so they don't satisfy it). Run
# validate on workflow changes too so those PRs can clear the gate in-context.
- '.github/workflows/**'
# Same rationale for the scan policy prompt: a policy-only PR (.github/policy/**)
# touches none of the plugin paths above, so validate would never trigger via
# pull_request and the required check would sit "Expected" forever (a dispatch
# check run isn't associated with the PR, so it can't satisfy the gate either).
- '.github/policy/**'
push:
branches: [main]
paths:
- '.claude-plugin/**'
# `validate` is a required status check on main. Bump PRs are opened with
# GITHUB_TOKEN, which doesn't fire on:pull_request (recursion guard), so the
# path-filtered trigger above never reports on them and the PR would be
# blocked forever. The bump workflow dispatches this against each per-entry
# bump branch instead; the check run lands on the branch HEAD (= PR head)
# and satisfies the required check. The validate job runs unconditionally,
# so a dispatch always reports.
workflow_dispatch:
permissions:
contents: read
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: anthropics/claude-plugins-community/.github/actions/validate-plugins@426e469f322952061102b286b378c0c9733a0934
with:
marketplace-path: .claude-plugin/marketplace.json
# Official curated marketplace: SHA-pin (I5) is a HARD error.
# I8/I11 are warnings until the 15 known vendored-path/name issues
# are cleaned up (see PR body); tighten to "I1 I3" after.
warn-invariants: "I1 I3 I8 I11"
claude-cli-version: latest