scan-plugins.yml 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546
  1. name: Scan Plugins
  2. # Claude policy scan of changed external marketplace entries.
  3. #
  4. # `scan` is a required status check on main. A path-filtered workflow never
  5. # reports a check run when its paths don't match, which would leave unrelated
  6. # PRs blocked forever — so this workflow runs on every PR and skips the heavy
  7. # scan setup at the step level when nothing scan-relevant changed. The check
  8. # always reports.
  9. #
  10. # Verdict cache: each (plugin, sha) pair is scanned at most once. The bump
  11. # workflow force-resets bump/plugin-shas every night, which makes the same
  12. # SHAs reappear in the diff on consecutive nights — without a cache, the
  13. # scan would re-burn ~90s of Claude time per entry per night. The cache is
  14. # keyed on the policy hash so a prompt or schema change invalidates all
  15. # verdicts and triggers a clean re-scan.
  16. #
  17. # Failure handling: a cached `passes:false` verdict still fails the job. The
  18. # Revert Failed Bumps workflow (revert-failed-bumps.yml) reacts to that by
  19. # dropping the failing entries from the bump PR, so one bad upstream can't
  20. # block the rest. After the revert, the re-dispatched scan finds only
  21. # cached-pass entries and goes green in seconds.
  22. on:
  23. pull_request:
  24. workflow_dispatch:
  25. inputs:
  26. scan_all:
  27. description: Scan every external entry (full re-review). Slow.
  28. type: boolean
  29. default: false
  30. permissions:
  31. contents: read
  32. id-token: write # Anthropic Workload Identity Federation (scan-plugins action)
  33. # Serialize scans per ref so concurrent runs (a re-dispatch racing the
  34. # original, or a manual dispatch) don't both restore the same cache, scan
  35. # overlapping sets, and lose one another's verdicts on save.
  36. concurrency:
  37. group: scan-plugins-${{ github.event.pull_request.number || github.ref }}
  38. cancel-in-progress: false
  39. env:
  40. MARKETPLACE: .claude-plugin/marketplace.json
  41. CACHE_DIR: ${{ github.workspace }}/.scan-cache
  42. CACHE_TTL_DAYS: '30'
  43. jobs:
  44. scan:
  45. runs-on: ubuntu-latest
  46. timeout-minutes: 360
  47. steps:
  48. - uses: actions/checkout@v4
  49. with:
  50. fetch-depth: 0
  51. # Same paths the workflow-level filter used to gate on. workflow_dispatch
  52. # always runs the scan (no PR diff to inspect).
  53. - name: Check for scan-relevant changes
  54. id: changes
  55. env:
  56. EVENT_NAME: ${{ github.event_name }}
  57. BASE_SHA: ${{ github.event.pull_request.base.sha }}
  58. run: |
  59. set -euo pipefail
  60. if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
  61. echo "relevant=true" >> "$GITHUB_OUTPUT"
  62. echo "base_ref=origin/main" >> "$GITHUB_OUTPUT"
  63. exit 0
  64. fi
  65. echo "base_ref=$BASE_SHA" >> "$GITHUB_OUTPUT"
  66. if git diff --quiet "$BASE_SHA" HEAD -- "$MARKETPLACE" .github/policy/; then
  67. echo "relevant=false" >> "$GITHUB_OUTPUT"
  68. echo "::notice::No changes to marketplace.json or policy/ — skipping policy scan."
  69. else
  70. echo "relevant=true" >> "$GITHUB_OUTPUT"
  71. fi
  72. # Auth: the shared scan-plugins action below uses Workload Identity
  73. # Federation (anthropic-federation-rule-id input) — the IDs are literal
  74. # in this file, so the action's "skip if no auth" path can't trigger.
  75. # The previous "Require ANTHROPIC_API_KEY" fail-closed guard is
  76. # therefore no longer needed.
  77. # Verdict cache, keyed on the policy content hash. A prompt change
  78. # invalidates every cached verdict — that is intentional. The save key
  79. # includes run_id so each run writes a fresh cache; restore-keys picks
  80. # the most recent one. Verdicts older than CACHE_TTL_DAYS are pruned on
  81. # restore to bound cache size as the marketplace grows.
  82. - name: Restore verdict cache
  83. if: steps.changes.outputs.relevant == 'true'
  84. id: cache-restore
  85. uses: actions/cache/restore@v4
  86. with:
  87. path: .scan-cache
  88. # run_attempt so a re-run can save its own verdicts (cache keys are
  89. # immutable; without it a re-run would silently fail to save).
  90. key: scan-verdicts-${{ hashFiles('.github/policy/**') }}-${{ github.run_id }}-${{ github.run_attempt }}
  91. restore-keys: |
  92. scan-verdicts-${{ hashFiles('.github/policy/**') }}-
  93. # Split the diff into cached (skip) and uncached (scan) entries. The
  94. # cache key is "<name>@<sha>" — a SHA is immutable, so a verdict for a
  95. # given (plugin, sha) is permanent under a fixed policy.
  96. - name: Filter scan targets against cache
  97. if: steps.changes.outputs.relevant == 'true'
  98. id: filter
  99. env:
  100. BASE_REF: ${{ steps.changes.outputs.base_ref }}
  101. SCAN_ALL: ${{ inputs.scan_all || 'false' }}
  102. TTL_DAYS: ${{ env.CACHE_TTL_DAYS }}
  103. run: |
  104. set -euo pipefail
  105. mkdir -p "$CACHE_DIR"
  106. # Initialize / prune the verdict map.
  107. if [[ -f "$CACHE_DIR/verdicts.json" ]] && jq -e 'type == "object"' "$CACHE_DIR/verdicts.json" >/dev/null 2>&1; then
  108. # Drop entries older than TTL. Verdicts are immutable per (plugin, sha)
  109. # but pruning keeps the cache from accumulating forever.
  110. cutoff="$(date -u -d "-${TTL_DAYS} days" +%Y-%m-%dT%H:%M:%SZ)"
  111. jq --arg cutoff "$cutoff" \
  112. 'with_entries(select(.value.scanned_at >= $cutoff))' \
  113. "$CACHE_DIR/verdicts.json" > "$CACHE_DIR/verdicts.json.tmp"
  114. mv "$CACHE_DIR/verdicts.json.tmp" "$CACHE_DIR/verdicts.json"
  115. else
  116. echo '{}' > "$CACHE_DIR/verdicts.json"
  117. fi
  118. # Build the change set: entries in HEAD whose object differs from base.
  119. # scan_all overrides to "every external entry" (full re-review).
  120. if [[ "$SCAN_ALL" == "true" ]]; then
  121. jq -c '[.plugins[] | select(.source | type == "object")]' "$MARKETPLACE" \
  122. > "$CACHE_DIR/changed.json"
  123. else
  124. if git cat-file -e "${BASE_REF}:${MARKETPLACE}" 2>/dev/null; then
  125. git show "${BASE_REF}:${MARKETPLACE}" > "$CACHE_DIR/base.json"
  126. else
  127. echo '{"plugins":[]}' > "$CACHE_DIR/base.json"
  128. fi
  129. jq -c -s \
  130. '(.[0].plugins | map({(.name): .}) | add // {}) as $b
  131. | [.[1].plugins[]
  132. | select(.source | type == "object")
  133. | select(($b[.name] // null) != .)]' \
  134. "$CACHE_DIR/base.json" "$MARKETPLACE" > "$CACHE_DIR/changed.json"
  135. fi
  136. changed_count="$(jq 'length' "$CACHE_DIR/changed.json")"
  137. # Split changed entries into cached vs uncached. A hit requires the
  138. # *whole* source object (repo, sha, path, ref) to match the cached
  139. # entry, not just name@sha — a repo migration or path change with the
  140. # same SHA is different scan content and must miss the cache.
  141. jq -c -s \
  142. '.[0] as $cache
  143. | (.[1] | map(. + {key: (.name + "@" + (.source.sha // "")) })) as $entries
  144. | {
  145. to_scan: [$entries[] | select(($cache[.key].source // null) != .source)],
  146. cached: [$entries[] | select(($cache[.key].source // null) == .source)
  147. | . + {verdict: $cache[.key]}]
  148. }' \
  149. "$CACHE_DIR/verdicts.json" "$CACHE_DIR/changed.json" > "$CACHE_DIR/split.json"
  150. jq -c '.to_scan' "$CACHE_DIR/split.json" > "$CACHE_DIR/to-scan.json"
  151. jq -c '.cached' "$CACHE_DIR/split.json" > "$CACHE_DIR/cached.json"
  152. to_scan_count="$(jq 'length' "$CACHE_DIR/to-scan.json")"
  153. cached_count="$(jq 'length' "$CACHE_DIR/cached.json")"
  154. cached_fail_count="$(jq '[.[] | select(.verdict.passes == false)] | length' "$CACHE_DIR/cached.json")"
  155. # Build a filtered marketplace containing only the uncached entries.
  156. # Passing this as the action's marketplace-path means the action's own
  157. # base diff (which can't resolve a path outside git) falls back to an
  158. # empty base and scans everything in the file — which is exactly the
  159. # to-scan set. Annotations point to the temp file rather than the real
  160. # marketplace, but the per-entry verdicts still land in the artifact
  161. # and the step summary.
  162. jq -c '{plugins: .}' "$CACHE_DIR/to-scan.json" > "$CACHE_DIR/scan-targets.json"
  163. {
  164. echo "changed=$changed_count"
  165. echo "to_scan=$to_scan_count"
  166. echo "cached=$cached_count"
  167. echo "cached_failures=$cached_fail_count"
  168. } >> "$GITHUB_OUTPUT"
  169. echo "::notice::$changed_count changed entrie(s): $cached_count cached ($cached_fail_count failing), $to_scan_count to scan."
  170. - name: Scan uncached entries
  171. if: steps.changes.outputs.relevant == 'true' && steps.filter.outputs.to_scan != '0'
  172. id: scan
  173. # Capture the action's per-entry outputs even when it exits nonzero.
  174. # The verdict (cached + fresh) is what gates the job, not the action's
  175. # exit code, and the revert workflow needs the artifact even on failure.
  176. continue-on-error: true
  177. # Pinned to claude-plugins-community#34 (WIF input support).
  178. # TODO: re-pin to a main-branch SHA once #34 merges.
  179. uses: anthropics/claude-plugins-community/.github/actions/scan-plugins@426e469f322952061102b286b378c0c9733a0934
  180. with:
  181. # Anthropic auth via Workload Identity Federation — the action
  182. # mints a GitHub OIDC token (id-token: write above) and the claude
  183. # CLI exchanges it for a short-lived bearer. The federation rule is
  184. # bound to this repository (repository_id-pinned).
  185. anthropic-federation-rule-id: fdrl_0147kJdru6bZKTtzwFNEqsDf
  186. anthropic-organization-id: 1ec12c5c-6542-4da8-bf2f-c15919aef01c
  187. anthropic-service-account-id: svac_01DnC3BtPHGjYJEGeuUUXZ8v
  188. marketplace-path: .scan-cache/scan-targets.json
  189. policy-prompt: .github/policy/prompt.md
  190. fail-on-findings: "true"
  191. claude-cli-version: latest
  192. # Merge fresh verdicts into the cache and assemble this run's full
  193. # verdict set (cached + fresh) for downstream consumers. Runs even when
  194. # the scan step failed so that fail verdicts are also cached — that is
  195. # what lets the revert workflow drop them and what stops the same
  196. # failing SHA from being re-scanned every night.
  197. - name: Merge verdicts and assemble run report
  198. if: steps.changes.outputs.relevant == 'true'
  199. id: report
  200. # The action's `scanned` output travels here via an env var, which is
  201. # subject to the OS argv/envp size limit (~128 KiB on Linux). At ~300
  202. # bytes/entry that is ~400 entries — an order of magnitude above the
  203. # cold-start case, and steady state with the cache is ~10/night. If
  204. # the limit is ever hit the runner fails the step before the script
  205. # runs ("argument list too long") — the right response is to clear
  206. # the cache key and lower max-bumps temporarily. Documented here so
  207. # nobody has to rediscover it.
  208. env:
  209. SCANNED_JSON: ${{ steps.scan.outputs.scanned || '[]' }}
  210. run: |
  211. set -euo pipefail
  212. mkdir -p "$CACHE_DIR"
  213. [[ -f "$CACHE_DIR/cached.json" ]] || echo '[]' > "$CACHE_DIR/cached.json"
  214. [[ -f "$CACHE_DIR/changed.json" ]] || echo '[]' > "$CACHE_DIR/changed.json"
  215. # Defensive: a partial or unparseable action output must not poison
  216. # the cache. Treat it as "scanned nothing".
  217. printf '%s' "$SCANNED_JSON" > "$CACHE_DIR/scanned-raw.json"
  218. if ! jq -e 'type == "array"' "$CACHE_DIR/scanned-raw.json" >/dev/null 2>&1; then
  219. echo "::warning::scan action output is not a valid JSON array — treating as empty."
  220. echo '[]' > "$CACHE_DIR/scanned-raw.json"
  221. fi
  222. # Defense in depth: the scan action runs Claude with Read access over
  223. # a cloned external repo. With WIF auth the process env carries a
  224. # short-lived OIDC JWT (masked) and the CLI's exchanged bearer
  225. # rather than a long-lived sk-ant- key, which bounds the blast
  226. # radius of a prompt-injection exfil to a token that expires in
  227. # minutes. The sk-ant- scrubber stays as defense-in-depth (covers
  228. # any future static-key fallback) so key-shaped strings still never
  229. # reach the cache, artifact, or PR comment.
  230. jq -c '(.. | strings) |= gsub("sk-ant-[A-Za-z0-9_-]{8,}"; "[REDACTED]")' \
  231. "$CACHE_DIR/scanned-raw.json" > "$CACHE_DIR/scanned-raw.json.tmp"
  232. mv "$CACHE_DIR/scanned-raw.json.tmp" "$CACHE_DIR/scanned-raw.json"
  233. now="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
  234. # The action's `scanned` output has no SHA or source — join it with
  235. # the change set by name to recover both for the cache key + the
  236. # source-equality lookup guard.
  237. jq -c -s --arg now "$now" \
  238. '.[0] as $changed
  239. | (.[1] // []) as $scanned
  240. | ($changed | map({(.name): .source}) | add // {}) as $srcs
  241. | [$scanned[]
  242. | . + {source: ($srcs[.name] // null), sha: ($srcs[.name].sha // ""), scanned_at: $now}]' \
  243. "$CACHE_DIR/changed.json" "$CACHE_DIR/scanned-raw.json" \
  244. > "$CACHE_DIR/fresh.json"
  245. # Merge fresh verdicts into the cache, keyed by name@sha. The
  246. # full source object is stored so a future repo/path change with the
  247. # same SHA fails the lookup guard. summary/violations are model
  248. # output — truncate to bound cache size (the artifact carries the
  249. # full text for the run that produced it).
  250. jq -c -s \
  251. '.[0] + ([.[1][] | select(.sha != "") | {(.name + "@" + .sha): {
  252. source: .source,
  253. passes: .passes,
  254. summary: ((.summary // "") | .[0:300]),
  255. violations: ((.violations // "") | .[0:500]),
  256. scanned_at: .scanned_at
  257. }}] | add // {})' \
  258. "$CACHE_DIR/verdicts.json" "$CACHE_DIR/fresh.json" \
  259. > "$CACHE_DIR/verdicts.json.tmp"
  260. mv "$CACHE_DIR/verdicts.json.tmp" "$CACHE_DIR/verdicts.json"
  261. # The full per-entry verdict for THIS run's diff: cached verdicts
  262. # plus freshly-scanned verdicts. The revert workflow consumes the
  263. # `failed` list to know exactly which SHAs to drop.
  264. jq -c -s \
  265. '(.[0] | map({name, sha: .source.sha, passes: .verdict.passes,
  266. summary: (.verdict.summary // ""),
  267. violations: (.verdict.violations // ""),
  268. source: "cache"}))
  269. + (.[1] | map({name, sha, passes,
  270. summary: (.summary // ""),
  271. violations: (.violations // ""),
  272. source: "scan"}))' \
  273. "$CACHE_DIR/cached.json" "$CACHE_DIR/fresh.json" \
  274. > "$CACHE_DIR/run-verdicts.json"
  275. jq -c '[.[] | select(.passes == false) | .name]' "$CACHE_DIR/run-verdicts.json" \
  276. > "$CACHE_DIR/run-failed.json"
  277. fail_count="$(jq 'length' "$CACHE_DIR/run-failed.json")"
  278. total="$(jq 'length' "$CACHE_DIR/run-verdicts.json")"
  279. {
  280. echo "failed_count=$fail_count"
  281. echo "total=$total"
  282. } >> "$GITHUB_OUTPUT"
  283. # `summary` and `violations` are model-generated text shaped by a
  284. # cloned external repo. Strip markdown control characters AND wrap
  285. # in code spans before they hit a publicly-rendered sink — code
  286. # spans neutralize auto-linked bare URLs that a prompt-injected
  287. # upstream could smuggle in. Stripping backticks first stops a
  288. # breakout from the code span.
  289. {
  290. echo "## Policy scan (with verdict cache)"
  291. echo
  292. echo "Changed entries: ${total} · cached: $(jq 'length' "$CACHE_DIR/cached.json") · scanned fresh: $(jq 'length' "$CACHE_DIR/fresh.json") · failures: ${fail_count}"
  293. echo
  294. if [[ "$total" -gt 0 ]]; then
  295. echo "| Plugin | SHA | Passes | Source | Summary |"
  296. echo "|---|---|---|---|---|"
  297. jq -r 'def neutralize: gsub("[|\n\r\\[\\]<>`]"; " ");
  298. .[] | "| \(.name) | `\(.sha[0:8])` | \(if .passes then "✅" else "❌" end) | \(.source) | `\(.summary | neutralize | .[0:120])` |"' \
  299. "$CACHE_DIR/run-verdicts.json"
  300. fi
  301. if [[ "$fail_count" -gt 0 ]]; then
  302. echo
  303. echo "### Violations"
  304. jq -r 'def neutralize: gsub("[|\n\r\\[\\]<>`]"; " ");
  305. .[] | select(.passes == false) | "- **\(.name)** — `\(.violations | neutralize | .[0:500])`"' "$CACHE_DIR/run-verdicts.json"
  306. fi
  307. } >> "$GITHUB_STEP_SUMMARY"
  308. # Used by revert-failed-bumps.yml to know which entries to drop. Always
  309. # uploaded when relevant so the revert workflow can distinguish "scan
  310. # found policy failures" from "scan never ran" (infra error → no revert).
  311. - name: Upload scan verdicts artifact
  312. if: steps.changes.outputs.relevant == 'true'
  313. uses: actions/upload-artifact@v4
  314. with:
  315. name: scan-verdicts
  316. path: |
  317. .scan-cache/run-verdicts.json
  318. .scan-cache/run-failed.json
  319. retention-days: 7
  320. # Save even when the scan failed — fail verdicts are what stop us from
  321. # re-burning Claude time on a known-bad SHA every night.
  322. - name: Save verdict cache
  323. if: always() && steps.changes.outputs.relevant == 'true'
  324. uses: actions/cache/save@v4
  325. with:
  326. path: .scan-cache
  327. key: scan-verdicts-${{ hashFiles('.github/policy/**') }}-${{ github.run_id }}-${{ github.run_attempt }}
  328. # Required-check gate. Fails on either fresh or cached policy failures —
  329. # a known-bad SHA must keep failing until it is reverted or upstream
  330. # fixes it (a new SHA is a new cache key and gets a fresh scan).
  331. - name: Gate on policy verdict
  332. if: steps.changes.outputs.relevant == 'true'
  333. env:
  334. FAILED: ${{ steps.report.outputs.failed_count || '0' }}
  335. SCAN_OUTCOME: ${{ steps.scan.outcome }}
  336. run: |
  337. set -euo pipefail
  338. if [[ "$FAILED" != "0" ]]; then
  339. echo "::error::$FAILED entrie(s) fail policy. See the run summary for verdicts."
  340. exit 1
  341. fi
  342. # The action can also fail without a policy verdict (clone error,
  343. # API error, schema mismatch). With zero parsed failures and a
  344. # nonzero exit, that is an infra error — fail loudly so the revert
  345. # workflow does NOT misread it as "everything passed".
  346. if [[ "$SCAN_OUTCOME" == "failure" ]]; then
  347. echo "::error::Scan step failed without a parseable policy verdict (likely an infra error)."
  348. exit 1
  349. fi
  350. # ─────────────────────────────────────────────────────────────────────────────
  351. # emit-verdict: post a sticky comment per entry to the bump PR with the
  352. # structured verdict, so downstream tooling (label automation, delist
  353. # authoring) can read verdicts directly instead of scraping job logs.
  354. # Sticky comment marker: `<!-- bump-pr-verdict:<name> -->`.
  355. #
  356. # Mirrors the schema_v1 contract from
  357. # anthropics/claude-plugins-community-internal#3908 so the triage scripts
  358. # in mcp-local-directory/scripts/triage/ work uniformly across both repos.
  359. # -official doesn't run per-entry static checks (zombie, schema, binaries,
  360. # etc.) so the `scan.*` axes are emitted as "skipped". The granular policy
  361. # booleans (`has_broad_scope_hooks`, `has_undisclosed_telemetry`,
  362. # `description_matches_behavior`) aren't surfaced by this workflow's
  363. # per-entry artifact yet, so they're emitted as null; the triage
  364. # `triage_bool_to_str` helper maps null → "?" so display is graceful.
  365. # Status describes the execution state, not the outcome — `ran` when the
  366. # scan action evaluated this SHA fresh, `cached` when a prior verdict was
  367. # reused (cf. run-verdicts.json's `source` field). Outcome lives in
  368. # `policy.passes`. policy-sweep.sh dispatches on this exact vocabulary.
  369. #
  370. # PR resolution: pull_request events carry the PR number directly. The
  371. # bump workflow creates bump PRs via GITHUB_TOKEN (which doesn't fire
  372. # pull_request triggers — recursion guard) and dispatches this scan via
  373. # workflow_dispatch on the bump branch. In that case we look up the
  374. # open PR by head ref. No PR (scan_all dispatch on main, etc.) → no-op.
  375. #
  376. # continue-on-error at the job level: emit failure must NOT block the
  377. # `scan` required check. Consumers fall back to log-scraping if the
  378. # comment is absent (gradual migration; no flag day).
  379. # ─────────────────────────────────────────────────────────────────────────────
  380. emit-verdict:
  381. needs: [scan]
  382. if: always() && needs.scan.result != 'skipped' && needs.scan.result != 'cancelled'
  383. runs-on: ubuntu-latest
  384. continue-on-error: true
  385. permissions:
  386. contents: read
  387. pull-requests: write
  388. steps:
  389. - name: Download scan verdicts
  390. uses: actions/download-artifact@v4
  391. with:
  392. name: scan-verdicts
  393. path: /tmp/scan-verdicts
  394. continue-on-error: true
  395. - name: Resolve PR number for this ref
  396. id: pr
  397. env:
  398. GH_TOKEN: ${{ github.token }}
  399. EVENT_NAME: ${{ github.event_name }}
  400. PR_FROM_EVENT: ${{ github.event.pull_request.number }}
  401. REF: ${{ github.ref_name }}
  402. REPO: ${{ github.repository }}
  403. run: |
  404. set -euo pipefail
  405. if [[ "$EVENT_NAME" == "pull_request" && -n "$PR_FROM_EVENT" ]]; then
  406. echo "number=$PR_FROM_EVENT" >> "$GITHUB_OUTPUT"
  407. exit 0
  408. fi
  409. # workflow_dispatch on the bump branch: find the open PR for it.
  410. # head filter takes the form owner:branch.
  411. owner="${REPO%%/*}"
  412. pr=$(gh api "/repos/${REPO}/pulls?state=open&head=${owner}:${REF}&per_page=1" \
  413. --jq '.[0].number // ""')
  414. if [[ -z "$pr" ]]; then
  415. echo "::notice::No open PR for ref ${REF} — sticky comments skipped (verdicts still in scan-verdicts artifact)"
  416. fi
  417. echo "number=$pr" >> "$GITHUB_OUTPUT"
  418. - name: Build and post sticky comments
  419. if: steps.pr.outputs.number != ''
  420. env:
  421. GH_TOKEN: ${{ github.token }}
  422. REPO: ${{ github.repository }}
  423. PR: ${{ steps.pr.outputs.number }}
  424. RUN_ID: ${{ github.run_id }}
  425. run: |
  426. set -euo pipefail
  427. verdicts_path=/tmp/scan-verdicts/run-verdicts.json
  428. # Missing/empty artifact: scan job ran but didn't produce verdicts
  429. # (e.g. the relevance gate said "no changes"). Nothing to comment;
  430. # exit clean.
  431. if [[ ! -s "$verdicts_path" ]]; then
  432. echo "::notice::No run-verdicts.json artifact — nothing to emit"
  433. exit 0
  434. fi
  435. count=$(jq 'length' "$verdicts_path")
  436. if [[ "$count" == "0" ]]; then
  437. echo "::notice::run-verdicts.json is empty — nothing to emit"
  438. exit 0
  439. fi
  440. ran_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)
  441. # scan.* axes: -official doesn't run per-entry static checks; emit
  442. # "skipped" for each so the schema is shape-compatible with -internal.
  443. scan_stub='{"clone":"skipped","subpath_missing":"skipped","schema":"skipped","zombie":"skipped","tool_allowlist":"skipped","binaries":"skipped","unique":"skipped","mcp":"skipped"}'
  444. # Pre-fetch all PR comments once (paginated) for the marker lookup.
  445. gh api --paginate "/repos/$REPO/issues/$PR/comments" \
  446. --jq '.[] | {id, body}' > /tmp/comments.ndjson
  447. jq -c '.[]' "$verdicts_path" | while read -r entry; do
  448. name=$(jq -r '.name' <<< "$entry")
  449. passes=$(jq -r '.passes' <<< "$entry")
  450. summary=$(jq -r '.summary // ""' <<< "$entry")
  451. violations=$(jq -r '.violations // ""' <<< "$entry")
  452. source=$(jq -r '.source // "scan"' <<< "$entry")
  453. # status = execution state (cf. -internal#3908 vocabulary).
  454. # Outcome is in `passes`. Map source → status: scan-action-run
  455. # → "ran"; cache-served → "cached". Anything else falls through
  456. # as "ran" (only those two values appear in run-verdicts.json).
  457. case "$source" in
  458. cache) status="cached" ;;
  459. scan) status="ran" ;;
  460. *) status="ran" ;;
  461. esac
  462. policy=$(jq -n \
  463. --argjson passes "$passes" \
  464. --arg summary "$summary" \
  465. --arg violations "$violations" \
  466. --arg source "$source" \
  467. --arg status "$status" \
  468. '{passes: $passes,
  469. has_broad_scope_hooks: null,
  470. has_undisclosed_telemetry: null,
  471. description_matches_behavior: null,
  472. summary: $summary,
  473. violations: $violations,
  474. source: $source,
  475. status: $status}')
  476. verdict=$(jq -n \
  477. --argjson scan "$scan_stub" \
  478. --argjson policy "$policy" \
  479. --arg ran_at "$ran_at" \
  480. --arg run_id "$RUN_ID" \
  481. '{schema_version: 1, ran_at: $ran_at, run_id: $run_id, scan: $scan, policy: $policy}')
  482. marker="<!-- bump-pr-verdict:$name -->"
  483. body=$(printf '%s\n```json\n%s\n```' "$marker" "$verdict")
  484. # jq's first() short-circuits and avoids SIGPIPE under pipefail if
  485. # duplicate markers exist (shouldn't, but a prior buggy run could
  486. # double-post). -s slurps NDJSON; `// empty` yields no output when
  487. # no match.
  488. existing=$(jq -rs --arg m "$marker" \
  489. 'first(.[] | select(.body | startswith($m)) | .id) // empty' \
  490. /tmp/comments.ndjson)
  491. if [[ -n "$existing" ]]; then
  492. gh api -X PATCH "/repos/$REPO/issues/comments/$existing" -f body="$body" >/dev/null
  493. echo "Updated comment $existing for $name"
  494. else
  495. gh api -X POST "/repos/$REPO/issues/$PR/comments" -f body="$body" >/dev/null
  496. echo "Created comment for $name"
  497. fi
  498. done