Add base44 plugin #84
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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@e85f0d65b4fc87f07862e1dcdc467950514414ec | |
| 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_01AnM1ihR2h7PCjXfDqfedpq | |
| anthropic-organization-id: 1ec12c5c-6542-4da8-bf2f-c15919aef01c | |
| anthropic-service-account-id: svac_01UaBRpFouHrgVdfvAM7Bt39 | |
| 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 |