Compare commits

..

2 Commits

Author SHA1 Message Date
tobin
3f2f08f955 Revert mintlify and netsuite-suitecloud changes
The validate-plugins check requires a plugin manifest at the pinned SHA
even for strict:false entries. Neither repo has one at any SHA, so a
SHA bump fails CI. Leave them at the existing pin until either the
upstream adds a manifest or the validator learns to honor strict:false.
2026-05-18 19:18:49 +00:00
tobin
791f2de6ce Fix broken plugin source configs and bump their SHAs
Several external plugins had source configs that no longer matched the
upstream layout, so the automated SHA bump skipped them indefinitely.
Add the missing path field where the manifest moved into a subdirectory,
correct stale ref/commit metadata, and update the skills list for the
one strict:false skills-only entry.

- rc, revenuecat: upstream moved the plugin from repo root into
  revenuecat/. Add path and bump SHA.
- zilliz: plugin moved from repo root into plugins/zilliz/. Add path
  and bump SHA.
- sumup: plugin lives at providers/claude/plugin/ (declared by the
  upstream marketplace.json) but our entry never had a path. Add it
  and bump SHA.
- mintlify: pure SHA bump. Repo layout unchanged between SHAs; the
  upstream remains a marketplace-style repo with no plugin.json, same
  as the currently pinned SHA.
- netsuite-suitecloud (strict:false skills entry): bump SHA and add
  the four new skill directories upstream added since the last pin.
- 42crunch-api-security-testing: ref said v1.0.1 but the pinned SHA
  is actually v1.5.5. Correct the label; the SHA is already current.
- jfrog: commit and sha fields had drifted apart. Set both to
  upstream HEAD.

Each new SHA verified to be on the upstream default branch and the
referenced manifest validated with claude plugin validate.
2026-05-18 19:13:39 +00:00
4 changed files with 55 additions and 672 deletions

View File

@@ -217,7 +217,7 @@
"url": "https://github.com/auth0/agent-skills.git",
"path": "plugins/auth0",
"ref": "main",
"sha": "3aa943b620a640be8a04d462e2abce11671653c3"
"sha": "1c32754fcb934109451435ecd4a6ea9b068f0937"
},
"homepage": "https://auth0.com/docs/quickstart/agent-skills"
},
@@ -233,7 +233,7 @@
"url": "https://github.com/aws/agent-toolkit-for-aws.git",
"path": "plugins/aws-agents",
"ref": "main",
"sha": "ba1cc8ca4f063d88ca40c6acf3f670e6321b7a7f"
"sha": "14780bf3440aa1532eadfbb2ff547f58969fcfb2"
},
"homepage": "https://github.com/aws/agent-toolkit-for-aws"
},
@@ -262,7 +262,7 @@
"url": "https://github.com/aws/agent-toolkit-for-aws.git",
"path": "plugins/aws-core",
"ref": "main",
"sha": "ba1cc8ca4f063d88ca40c6acf3f670e6321b7a7f"
"sha": "14780bf3440aa1532eadfbb2ff547f58969fcfb2"
},
"homepage": "https://github.com/aws/agent-toolkit-for-aws"
},
@@ -278,7 +278,7 @@
"url": "https://github.com/aws/agent-toolkit-for-aws.git",
"path": "plugins/aws-data-analytics",
"ref": "main",
"sha": "ba1cc8ca4f063d88ca40c6acf3f670e6321b7a7f"
"sha": "14780bf3440aa1532eadfbb2ff547f58969fcfb2"
},
"homepage": "https://github.com/aws/agent-toolkit-for-aws"
},
@@ -318,7 +318,7 @@
"source": {
"source": "url",
"url": "https://github.com/microsoft/azure-skills.git",
"sha": "350e050ca30fe3464483f66193a8ff3a973b1d77"
"sha": "2a5c5080b8c501d00408eb00f7ee4ed8effa7b2c"
},
"homepage": "https://github.com/microsoft/azure-skills"
},
@@ -400,7 +400,7 @@
"url": "https://github.com/carta/plugins.git",
"path": "plugins/carta-cap-table",
"ref": "main",
"sha": "49db52aa7d59fd4a855c6b17a1cf31c245f41e2c"
"sha": "980fd3966ec79b61ff94f39db4592f7df9d6ed80"
},
"homepage": "https://carta.com"
},
@@ -416,7 +416,7 @@
"source": {
"source": "url",
"url": "https://github.com/cap-js/mcp-server.git",
"sha": "ef840d4315fa34264be6b71d0077a3b5288cb5fa"
"sha": "4d59d7070a52761a9b8028cbe710c8d7477cbc92"
},
"homepage": "https://cap.cloud.sap/"
},
@@ -427,7 +427,7 @@
"source": {
"source": "url",
"url": "https://github.com/ChromeDevTools/chrome-devtools-mcp.git",
"sha": "32dc50d59bdb87242c67391ddc755368ebe77104"
"sha": "a1612be8e01401cf1711c64bc2ef5da5763ba956"
},
"homepage": "https://github.com/ChromeDevTools/chrome-devtools-mcp"
},
@@ -505,7 +505,7 @@
"source": {
"source": "url",
"url": "https://github.com/ClickHouse/clickhouse-claude-code-plugin.git",
"sha": "13a2df004af0df46661c9de2d4ef4e85eba2f040"
"sha": "db1c108dde6e5c81a1ca65f3b6700d6fff288545"
},
"homepage": "https://github.com/ClickHouse/clickhouse-claude-code-plugin"
},
@@ -519,7 +519,7 @@
"source": {
"source": "url",
"url": "https://github.com/gemini-cli-extensions/cloud-sql-postgresql.git",
"sha": "966f7b883998692d05389e4c3d3793412ca0659f"
"sha": "69c0c820513d7f75a63eeb3ec84b01478037caeb"
},
"homepage": "https://cloud.google.com/sql"
},
@@ -528,7 +528,7 @@
"source": {
"source": "url",
"url": "https://github.com/cloudflare/skills.git",
"sha": "60147cbb773649eadca89cee92b4e0caf02234b4"
"sha": "0397d7d88fa6ac7517a88389622eb0799e86ded2"
},
"description": "Skills for the Cloudflare developer platform: Workers, Durable Objects, Agents SDK, MCP servers, Wrangler CLI, and web performance.",
"category": "deployment",
@@ -554,7 +554,7 @@
"source": {
"source": "url",
"url": "https://github.com/cockroachdb/claude-plugin.git",
"sha": "736bd11df55bac97e2a6c98be8e93503b125902c"
"sha": "31d0cc99fac1c97614cc787a96720104ea642375"
},
"homepage": "https://github.com/cockroachdb/claude-plugin"
},
@@ -623,20 +623,6 @@
"community-managed"
]
},
{
"name": "convex-backend",
"description": "Convex backend skill for building reactive, type-safe, production-grade backends. Helps Claude design schemas, server functions, auth, file storage, scheduled jobs, and real-time multiplayer features on Convex.",
"author": {
"name": "Convex"
},
"category": "development",
"source": {
"source": "url",
"url": "https://github.com/get-convex/convex-backend-skill.git",
"sha": "9acbc5495dd26749a5e6341dc2438146c4caa03b"
},
"homepage": "https://convex.dev"
},
{
"name": "crowdstrike-falcon-foundry",
"description": "CrowdStrike Falcon Foundry development skills for building cybersecurity applications on the Falcon platform. Includes UI development, collections, functions, workflows, API integration, security patterns, and debugging workflows.",
@@ -693,7 +679,7 @@
"source": {
"source": "url",
"url": "https://github.com/dash0hq/dash0-agent-plugin.git",
"sha": "feae46e4099d31a1a76debe39f22aebb72a18ce5"
"sha": "38c6d74e637bd7dbe1fa2c364de66d07efe88a9a"
},
"homepage": "https://dash0.com/"
},
@@ -704,7 +690,7 @@
"source": {
"source": "url",
"url": "https://github.com/astronomer/agents.git",
"sha": "535a040ca9e27aaed6da13f0f959625fb3294820"
"sha": "5935c4330dea4dfb8e93568956b10a543ecdb3d1"
},
"homepage": "https://github.com/astronomer/agents"
},
@@ -718,7 +704,7 @@
"source": {
"source": "url",
"url": "https://github.com/gemini-cli-extensions/data-agent-kit-starter-pack.git",
"sha": "7bc75b5e53d6eaae103132fd1a47de26239e4ae4"
"sha": "04c4354242c1192191c76fca2d4b03d94401d9fa"
},
"homepage": "https://github.com/gemini-cli-extensions/data-agent-kit-starter-pack"
},
@@ -728,7 +714,7 @@
"source": {
"source": "url",
"url": "https://github.com/astronomer/agents.git",
"sha": "535a040ca9e27aaed6da13f0f959625fb3294820"
"sha": "5935c4330dea4dfb8e93568956b10a543ecdb3d1"
},
"homepage": "https://github.com/astronomer/agents"
},
@@ -741,7 +727,7 @@
"url": "https://github.com/awslabs/agent-plugins.git",
"path": "plugins/databases-on-aws",
"ref": "main",
"sha": "95381e8bcb92f58a28edb4f83eb7e163c7461a0a"
"sha": "6cfb70e55aa142a8eda66e6ef7966d5921bdf9a2"
},
"homepage": "https://github.com/awslabs/agent-plugins"
},
@@ -755,7 +741,7 @@
"source": {
"source": "url",
"url": "https://github.com/datadog-labs/claude-code-plugin.git",
"sha": "eeb2f746a857f8d97f69cd0968fb63874541c112"
"sha": "95d38f561e3d5e4fe9fb66c3c0bb19fb75e0458a"
},
"homepage": "https://www.datadoghq.com/"
},
@@ -769,7 +755,7 @@
"source": {
"source": "url",
"url": "https://github.com/datarobot-oss/datarobot-agent-skills.git",
"sha": "6a13377ad0b3317c7c4133fce36b7fcc626334cd"
"sha": "b3e8fd33d7c36592c802359026c15f3e067a0646"
},
"homepage": "https://datarobot.com"
},
@@ -782,7 +768,7 @@
"url": "https://github.com/microsoft/Dataverse-skills.git",
"path": ".github/plugins/dataverse",
"ref": "main",
"sha": "5f186bf8ab1a3d6e242492d982276bbd7443ee0f"
"sha": "b2f21c1eec233d1b20e89618c3ffcb25cfdd55e4"
},
"homepage": "https://github.com/microsoft/Dataverse-skills"
},
@@ -795,7 +781,7 @@
"url": "https://github.com/awslabs/agent-plugins.git",
"path": "plugins/deploy-on-aws",
"ref": "main",
"sha": "95381e8bcb92f58a28edb4f83eb7e163c7461a0a"
"sha": "6cfb70e55aa142a8eda66e6ef7966d5921bdf9a2"
},
"homepage": "https://github.com/awslabs/agent-plugins"
},
@@ -811,7 +797,7 @@
"url": "https://github.com/wonderwhy-er/DesktopCommanderMCP.git",
"path": "plugins/claude",
"ref": "main",
"sha": "9c44119a480ec6460f82d59aeb90cf274bc3dd7b"
"sha": "8c03d3392d1633923057f4492f2b5014e2c4a6bf"
},
"homepage": "https://desktopcommander.app"
},
@@ -831,7 +817,7 @@
"source": {
"source": "url",
"url": "https://github.com/exa-labs/exa-mcp-server.git",
"sha": "5ce6c53bae8baa3248a1d197a4e89b7e464227e3"
"sha": "bd2ccdd52ca7a35fbc2207ad266bb2a961c0e793"
},
"homepage": "https://exa.ai/docs/reference/exa-mcp"
},
@@ -855,7 +841,7 @@
"url": "https://github.com/expo/skills.git",
"path": "plugins/expo",
"ref": "main",
"sha": "47f0ef64821f10e42a600758b5087bfe89c09474"
"sha": "786398d3574f33eb6714380f44ec09355819516e"
},
"homepage": "https://github.com/expo/skills/blob/main/plugins/expo/README.md"
},
@@ -871,7 +857,7 @@
"source": {
"source": "url",
"url": "https://github.com/fastly/fastly-agent-toolkit.git",
"sha": "e0f4205723b843de0b07da4a2aea6c84a3bcb579"
"sha": "329331c887512850f13e481b45c4298c0387a4d2"
},
"homepage": "https://github.com/fastly/fastly-agent-toolkit/blob/main/README.md"
},
@@ -892,7 +878,7 @@
"source": {
"source": "url",
"url": "https://github.com/voxel51/fiftyone-skills.git",
"sha": "a79e53c6fd1784e1476421185f3ed67637e642b4"
"sha": "02bd4ea170ca01a751c2d2dd6bf2df8f62e65626"
},
"homepage": "https://docs.voxel51.com/"
},
@@ -903,7 +889,7 @@
"source": {
"source": "url",
"url": "https://github.com/figma/mcp-server-guide.git",
"sha": "a742f0a700a7772ff5ed85f7c9fc1dad5afa9fcc"
"sha": "fabc1ca81d839602ba7c1ca0f445a64246b3870e"
},
"homepage": "https://github.com/figma/mcp-server-guide"
},
@@ -921,7 +907,7 @@
"source": {
"source": "url",
"url": "https://github.com/firecrawl/firecrawl-claude-plugin.git",
"sha": "48edd7943009eb4442a6f0102bbd0c251eecef3e"
"sha": "122a6ae6cefb4393c2c30740aee55ba02532ccdc"
},
"homepage": "https://github.com/firecrawl/firecrawl-claude-plugin.git"
},
@@ -1679,7 +1665,7 @@
"source": {
"source": "url",
"url": "https://github.com/Digital-Process-Tools/claude-remember.git",
"sha": "aa55ba3f553e23f4d84387f5d7ece1ba0ce68d93"
"sha": "914445ac5f06a164800ea90ba4db41a0486321ae"
},
"homepage": "https://github.com/Digital-Process-Tools/claude-remember"
},
@@ -1749,7 +1735,7 @@
"source": {
"source": "url",
"url": "https://github.com/sanity-io/agent-toolkit.git",
"sha": "236348e29b31e834ce71e4e2e3072184dd1c1e27"
"sha": "bc09fa9854507c538a856648aafbd4e1a775a95c"
},
"homepage": "https://www.sanity.io"
},
@@ -1765,7 +1751,7 @@
"source": {
"source": "url",
"url": "https://github.com/cap-js/mcp-server.git",
"sha": "ef840d4315fa34264be6b71d0077a3b5288cb5fa"
"sha": "8ce2e13ac70bd78415aedeaab0061af9396d3372"
},
"homepage": "https://cap.cloud.sap/"
},
@@ -1783,7 +1769,7 @@
"url": "https://github.com/SAP/open-ux-tools.git",
"path": "packages/fiori-mcp-server",
"ref": "main",
"sha": "157120fda8577fda6fb7546ed1b2305bfa65b9f5"
"sha": "d9d4ab7e69fe453f8fd682304ff1e3ac40a216c6"
},
"homepage": "https://github.com/SAP/open-ux-tools/tree/main/packages/fiori-mcp-server"
},
@@ -1799,7 +1785,7 @@
"source": {
"source": "url",
"url": "https://github.com/SAP/mdk-mcp-server.git",
"sha": "10ff6ccfee094b9fb3b3877a41f00fa278b1bcc4"
"sha": "af81fe6c2421c5748388c65241da6a1b319a2c8f"
},
"homepage": "https://help.sap.com/docs/MDK"
},
@@ -1838,7 +1824,7 @@
"source": "git-subdir",
"url": "https://github.com/semgrep/mcp-marketplace.git",
"path": "plugin",
"sha": "274846f6f9da5f56be53b19170bc008d357142a7"
"sha": "3711c33ad790df16e67c911eca792c473ec9a2a4"
},
"homepage": "https://github.com/semgrep/mcp-marketplace.git"
},
@@ -1849,7 +1835,7 @@
"source": {
"source": "url",
"url": "https://github.com/getsentry/sentry-for-claude.git",
"sha": "cf7efd373069d6fb073413324fe313319fb54ad9"
"sha": "fb398fdfff2055abc3d55917f6b6f0c0d5ad5e3b"
},
"homepage": "https://github.com/getsentry/sentry-for-claude/tree/main"
},
@@ -1914,7 +1900,7 @@
"source": {
"source": "url",
"url": "https://github.com/Shopify/Shopify-AI-Toolkit.git",
"sha": "c164cf45c4bc1d17bbc105168d99a4f744cfaac2"
"sha": "c5c18d86ce7b2a7ca51ebac7c4b1a4eda00c8e25"
},
"homepage": "https://shopify.dev"
},
@@ -1952,7 +1938,7 @@
"url": "https://github.com/Snowflake-Labs/snowflake-ai-kit.git",
"path": "plugins/cortex-code",
"ref": "main",
"sha": "b16692d548e9c785be640c06f3f3220ddf46c065"
"sha": "28192345cae4a758a909f5e510e24fea10666400"
},
"homepage": "https://docs.snowflake.com/en/user-guide/cortex-code"
},
@@ -1966,7 +1952,7 @@
"source": {
"source": "url",
"url": "https://github.com/SonarSource/sonarqube-agent-plugins.git",
"sha": "c64e09af314406a8d8806d57cd11cda81578ce20"
"sha": "91eb175d6cf5d47a3edadbe61bdf782c31f0a65a"
},
"homepage": "https://www.sonarsource.com"
},
@@ -1999,7 +1985,7 @@
"source": {
"source": "url",
"url": "https://github.com/spotify/ads-claude-plugin.git",
"sha": "cc3db744f4a4c14f7265ef3e9fb50f44cf08e0e7"
"sha": "63585cc919da51dd24fab594d829869595301922"
},
"homepage": "https://github.com/spotify/ads-claude-plugin"
},
@@ -2012,7 +1998,7 @@
"url": "https://github.com/stripe/ai.git",
"path": "providers/claude/plugin",
"ref": "main",
"sha": "ec93d4c4b9ffdbc994ac45ce692d4ec1cdb755f0"
"sha": "14623416d84fdfad0aea8744d4c6f838ebc87654"
},
"homepage": "https://github.com/stripe/ai/tree/main/providers/claude/plugin"
},
@@ -2097,7 +2083,7 @@
"source": {
"source": "url",
"url": "https://github.com/twilio/ai.git",
"sha": "7d15b215240df28e86a0b7305520524a2c005005"
"sha": "0713fb1f40b5e871cad4c1c99f603c812431692a"
},
"homepage": "https://www.twilio.com"
},
@@ -2145,7 +2131,7 @@
"url": "https://github.com/UI5/plugins-claude.git",
"path": "plugins/ui5",
"ref": "main",
"sha": "19b2fb384719425a25d55830d5dcdba75f13045c"
"sha": "cec940abd4b7b6866de8e7e4522f3dba0449379d"
},
"homepage": "https://github.com/UI5/plugins-claude"
},
@@ -2163,7 +2149,7 @@
"url": "https://github.com/UI5/plugins-claude.git",
"path": "plugins/ui5-typescript-conversion",
"ref": "main",
"sha": "19b2fb384719425a25d55830d5dcdba75f13045c"
"sha": "cec940abd4b7b6866de8e7e4522f3dba0449379d"
},
"homepage": "https://github.com/UI5/plugins-claude"
},
@@ -2188,7 +2174,7 @@
"source": {
"source": "url",
"url": "https://github.com/vercel/vercel-plugin.git",
"sha": "1edb125d13a29a1e6212f5ca5afcdf1b89b9b211"
"sha": "61f1903bed7b322c9745f6ba67095bc006de7e63"
},
"homepage": "https://github.com/vercel/vercel-plugin"
},
@@ -2213,7 +2199,7 @@
"source": {
"source": "url",
"url": "https://github.com/wix/skills.git",
"sha": "7ae38286b49e5e0cbf7069b6fd8cf6b5db2ba786"
"sha": "bf25b5a45b2413b3581f3dcbcd63f3737791a051"
},
"homepage": "https://dev.wix.com/docs/wix-cli/guides/development/about-wix-skills"
},
@@ -2237,7 +2223,7 @@
"source": {
"source": "url",
"url": "https://github.com/youdotcom-oss/agent-skills.git",
"sha": "4712250ae8e5ce3095cad3b43b62b33608888863"
"sha": "362d510732362bd679e1647f72f734ca2d2fa710"
},
"homepage": "https://you.com"
},
@@ -2276,7 +2262,7 @@
"source": {
"source": "url",
"url": "https://github.com/zoom/zoom-plugin.git",
"sha": "88f6ca3529c2dca7a38db24359ecf6fd15a23379"
"sha": "ab0f09b2ddc6682a7f69055c7861009ec6062775"
},
"homepage": "https://developers.zoom.us/"
},
@@ -2304,7 +2290,7 @@
"source": {
"source": "url",
"url": "https://github.com/zscaler/zscaler-mcp-server.git",
"sha": "246430c8d2d99726ad6cdcb00d1adc4e316cb966"
"sha": "6cf365968eb3b1e11306c973c51c1e54e98e704a"
},
"homepage": "https://github.com/zscaler/zscaler-mcp-server"
}

View File

@@ -13,14 +13,6 @@ 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:
@@ -30,7 +22,7 @@ on:
max_bumps:
description: Cap on plugins bumped this run
required: false
default: '130'
default: '20'
permissions:
contents: write
@@ -43,10 +35,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 +44,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

View File

@@ -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"

View File

@@ -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