mirror of
https://github.com/anthropics/claude-plugins-official.git
synced 2026-05-19 21:02:40 +00:00
Compare commits
3 Commits
bump-snowf
...
bump-crowd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a3cb43af9 | ||
|
|
40fb541de7 | ||
|
|
90803df4d0 |
File diff suppressed because it is too large
Load Diff
24
.github/workflows/bump-plugin-shas.yml
vendored
24
.github/workflows/bump-plugin-shas.yml
vendored
@@ -1,10 +1,8 @@
|
||||
name: Bump Plugin SHAs
|
||||
|
||||
# Nightly sweep: for each external entry whose upstream HEAD has moved past
|
||||
# Weekly 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 with all passing bumps. Each run force-resets the
|
||||
# bump/plugin-shas branch, so a previous night's unmerged PR is replaced (and
|
||||
# its review state discarded) — review and merge same-day to avoid churn.
|
||||
# inline, then open one PR with all passing bumps.
|
||||
#
|
||||
# Bot-free — uses the default GITHUB_TOKEN. PRs opened with GITHUB_TOKEN don't
|
||||
# trigger on:pull_request workflows, so the policy scan (`Scan Plugins`, a
|
||||
@@ -13,24 +11,16 @@ name: Bump Plugin SHAs
|
||||
# the scan ourselves on the bump branch after the PR is opened. The check run
|
||||
# lands on the branch HEAD — the same SHA as the PR head — and satisfies the
|
||||
# required check.
|
||||
#
|
||||
# max-bumps is set above the external-entry count so a single run can clear
|
||||
# any backlog. The cost-control mechanisms are downstream:
|
||||
# - scan-plugins.yml caches verdicts by (plugin, sha) so an unchanged SHA
|
||||
# is never re-scanned across nightly force-resets.
|
||||
# - revert-failed-bumps.yml drops policy-failing entries from the bump PR
|
||||
# so one bad upstream can't block the rest.
|
||||
# See those files for details.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '23 7 * * *' # Daily 07:23 UTC
|
||||
- cron: '23 7 * * 1' # Monday 07:23 UTC
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
max_bumps:
|
||||
description: Cap on plugins bumped this run
|
||||
required: false
|
||||
default: '130'
|
||||
default: '20'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -43,10 +33,6 @@ concurrency:
|
||||
jobs:
|
||||
bump:
|
||||
runs-on: ubuntu-latest
|
||||
# Per-bump cost is ~2s (ls-remote + shallow clone + validate); 130 entries
|
||||
# is ~5 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
|
||||
|
||||
@@ -56,7 +42,7 @@ jobs:
|
||||
id: bump
|
||||
with:
|
||||
marketplace-path: .claude-plugin/marketplace.json
|
||||
max-bumps: ${{ inputs.max_bumps || '130' }}
|
||||
max-bumps: ${{ inputs.max_bumps || '20' }}
|
||||
claude-cli-version: latest
|
||||
|
||||
# `bump/plugin-shas` is the action's default `pr-branch`. The scan diffs
|
||||
|
||||
129
.github/workflows/check-mcp-urls.yml
vendored
129
.github/workflows/check-mcp-urls.yml
vendored
@@ -1,129 +0,0 @@
|
||||
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.
|
||||
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")
|
||||
| "\($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.
|
||||
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 || echo "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 || echo "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."
|
||||
284
.github/workflows/revert-failed-bumps.yml
vendored
284
.github/workflows/revert-failed-bumps.yml
vendored
@@ -1,284 +0,0 @@
|
||||
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"
|
||||
317
.github/workflows/scan-plugins.yml
vendored
317
.github/workflows/scan-plugins.yml
vendored
@@ -7,19 +7,6 @@ name: Scan Plugins
|
||||
# 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:
|
||||
@@ -33,18 +20,6 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# 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
|
||||
@@ -62,14 +37,11 @@ jobs:
|
||||
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
|
||||
if git diff --quiet "$BASE_SHA" HEAD -- .claude-plugin/marketplace.json .github/policy/; then
|
||||
echo "relevant=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::No changes to marketplace.json or policy/ — skipping policy scan."
|
||||
else
|
||||
@@ -89,292 +61,13 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 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
|
||||
# Blocking: policy failures fail the job. Loosen by removing
|
||||
# fail-on-findings if the false-positive rate is too high.
|
||||
- if: steps.changes.outputs.relevant == 'true'
|
||||
uses: anthropics/claude-plugins-community/.github/actions/scan-plugins@b277757588871fe55b2620de8c6dfda470e2e9d8
|
||||
with:
|
||||
anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
marketplace-path: .scan-cache/scan-targets.json
|
||||
policy-prompt: .github/policy/prompt.md
|
||||
fail-on-findings: "true"
|
||||
scan-all-external: ${{ inputs.scan_all || 'false' }}
|
||||
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 and ANTHROPIC_API_KEY in its process env. A
|
||||
# successful prompt injection could coerce the model to put key
|
||||
# material into `summary`/`violations`. The action's own step summary
|
||||
# already carries that risk; this workflow adds an artifact and a PR
|
||||
# comment, both public sinks. Scrub any key-shaped token here so it
|
||||
# never reaches the cache, artifact, or 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
|
||||
|
||||
Reference in New Issue
Block a user