revert-failed-bumps.yml 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. name: Revert Failed Bumps
  2. # Drops policy-failing entries from a bump PR so one bad upstream can't
  3. # block the rest. Runs after a Scan Plugins workflow_run on bump/plugin-shas
  4. # concludes with a failure: read the per-entry verdicts the scan uploaded,
  5. # revert just the failing entries' source.sha back to main's pin, push a
  6. # follow-up signed commit, and re-dispatch the scan. The re-dispatched scan
  7. # finds only cached-pass entries in the new diff and goes green in seconds.
  8. #
  9. # Scope and guardrails — this job has contents:write so it must be tight:
  10. # - Only acts on bump/plugin-shas (literal branch match).
  11. # - Only acts when the scan was dispatched (workflow_dispatch event), i.e.
  12. # by bump-plugin-shas.yml. A scan on a regular PR never triggers this.
  13. # - Only reverts source.sha. If any other field in a failing entry differs
  14. # from main, the run aborts — that means the bump branch was tampered
  15. # with and a human needs to look.
  16. # - Bounded at MAX_REVERT_PASSES per night via a PR comment marker; a
  17. # persistent loop means the cache or scan is broken and a human needs
  18. # to look.
  19. # - The revert commit is created with createCommitOnBranch (GitHub-signed,
  20. # compare-and-swap via expectedHeadOid) — no signing key on the runner.
  21. on:
  22. workflow_run:
  23. workflows: ["Scan Plugins"]
  24. types: [completed]
  25. permissions:
  26. contents: read
  27. env:
  28. MARKETPLACE: .claude-plugin/marketplace.json
  29. BUMP_BRANCH: bump/plugin-shas
  30. MAX_REVERT_PASSES: '3'
  31. REVERT_MARKER: '<!-- revert-failed-bumps -->'
  32. jobs:
  33. revert:
  34. # Tight gate: the triggering scan must be a workflow_dispatch run on the
  35. # bump branch (i.e. the one bump-plugin-shas.yml dispatched) that failed.
  36. # A scan on a regular PR, a passing scan, or a manual dispatch on another
  37. # branch must never reach this job.
  38. if: >
  39. github.event.workflow_run.conclusion == 'failure' &&
  40. github.event.workflow_run.event == 'workflow_dispatch' &&
  41. github.event.workflow_run.head_branch == 'bump/plugin-shas'
  42. runs-on: ubuntu-latest
  43. timeout-minutes: 15
  44. permissions:
  45. contents: write # createCommitOnBranch on bump/plugin-shas
  46. pull-requests: write # comment on / close the bump PR
  47. actions: write # gh workflow run scan-plugins.yml --ref bump/plugin-shas
  48. concurrency:
  49. group: revert-failed-bumps
  50. cancel-in-progress: false
  51. steps:
  52. # The artifact carries run-failed.json (just plugin names) and
  53. # run-verdicts.json (full per-entry verdicts for the PR comment). It is
  54. # uploaded by scan-plugins.yml for every relevant run so we can tell
  55. # "policy failures found" from "scan never ran" (infra error → no revert).
  56. # The artifact won't exist when the scan died before the upload step
  57. # (cache restore error, jq failure, timeout) — that is an infra error,
  58. # not a policy failure, so the right move is to do nothing. The
  59. # download must not fail the job; the next step handles the missing file.
  60. - name: Download scan verdicts
  61. continue-on-error: true
  62. uses: actions/download-artifact@v4
  63. with:
  64. name: scan-verdicts
  65. run-id: ${{ github.event.workflow_run.id }}
  66. github-token: ${{ github.token }}
  67. path: scan-out
  68. - name: Determine revert set
  69. id: plan
  70. run: |
  71. set -euo pipefail
  72. if [[ ! -f scan-out/run-failed.json ]]; then
  73. echo "::warning::No run-failed.json in scan artifact — nothing to revert."
  74. echo "act=false" >> "$GITHUB_OUTPUT"
  75. exit 0
  76. fi
  77. if ! jq -e 'type == "array"' scan-out/run-failed.json >/dev/null 2>&1; then
  78. echo "::warning::run-failed.json is not a JSON array — refusing to act."
  79. echo "act=false" >> "$GITHUB_OUTPUT"
  80. exit 0
  81. fi
  82. fail_count="$(jq 'length' scan-out/run-failed.json)"
  83. if [[ "$fail_count" -eq 0 ]]; then
  84. # The scan job failed but reported zero policy failures: that is
  85. # an infra error (API key missing, clone failure, schema break).
  86. # Reverting nothing is correct; surfacing the infra error is the
  87. # scan job's responsibility.
  88. echo "::notice::Scan failed with zero parsed policy failures — infra error, not a policy failure. Not reverting."
  89. echo "act=false" >> "$GITHUB_OUTPUT"
  90. exit 0
  91. fi
  92. echo "act=true" >> "$GITHUB_OUTPUT"
  93. echo "fail_count=$fail_count" >> "$GITHUB_OUTPUT"
  94. echo "Failing entries:"
  95. jq -r '.[]' scan-out/run-failed.json
  96. - name: Locate bump PR and check revert budget
  97. if: steps.plan.outputs.act == 'true'
  98. id: pr
  99. env:
  100. GH_TOKEN: ${{ github.token }}
  101. REPO: ${{ github.repository }}
  102. run: |
  103. set -euo pipefail
  104. # Resolve the bump PR by head ref. `gh pr list --head <ref>` matches
  105. # by ref name across forks, so reject any PR whose head repo isn't
  106. # ours — a fork PR named bump/plugin-shas must never reach the
  107. # contents:write paths below.
  108. pr_json="$(gh api "repos/$REPO/pulls?head=${REPO%%/*}:$BUMP_BRANCH&base=main&state=open&per_page=1" \
  109. --jq '.[0] // empty')"
  110. if [[ -z "$pr_json" ]]; then
  111. echo "::warning::No open bump PR on $BUMP_BRANCH — nothing to revert."
  112. echo "act=false" >> "$GITHUB_OUTPUT"
  113. exit 0
  114. fi
  115. pr_number="$(jq -r '.number' <<<"$pr_json")"
  116. head_repo="$(jq -r '.head.repo.full_name' <<<"$pr_json")"
  117. head_sha="$(jq -r '.head.sha' <<<"$pr_json")"
  118. # The list endpoint omits `commits`; the single-PR endpoint has it.
  119. commit_count="$(gh api "repos/$REPO/pulls/$pr_number" --jq '.commits')"
  120. if [[ "$head_repo" != "$REPO" ]]; then
  121. echo "::error::Bump PR head is from $head_repo, not $REPO — refusing to act."
  122. echo "act=false" >> "$GITHUB_OUTPUT"
  123. exit 0
  124. fi
  125. # Loop bound: every nightly bump force-resets the branch to a single
  126. # commit and every revert pass adds exactly one. Counting commits is
  127. # therefore the per-night pass count + 1, with no date math, no
  128. # pagination, and no exposure to comment spoofing.
  129. if [[ "$commit_count" -gt $(( MAX_REVERT_PASSES + 1 )) ]]; then
  130. 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."
  131. gh pr comment "$pr_number" --repo "$REPO" --body \
  132. "$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."
  133. echo "act=false" >> "$GITHUB_OUTPUT"
  134. exit 0
  135. fi
  136. echo "Bump PR #$pr_number @ $head_sha ($commit_count commit(s))"
  137. {
  138. echo "act=true"
  139. echo "number=$pr_number"
  140. echo "head_sha=$head_sha"
  141. } >> "$GITHUB_OUTPUT"
  142. - name: Revert failing SHAs
  143. if: steps.plan.outputs.act == 'true' && steps.pr.outputs.act == 'true'
  144. id: revert
  145. env:
  146. GH_TOKEN: ${{ github.token }}
  147. REPO: ${{ github.repository }}
  148. HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
  149. run: |
  150. set -euo pipefail
  151. mkdir -p work
  152. gh api "repos/$REPO/contents/${MARKETPLACE}?ref=$HEAD_SHA" --jq '.content' | base64 -d > work/head.json
  153. gh api "repos/$REPO/contents/${MARKETPLACE}?ref=main" --jq '.content' | base64 -d > work/base.json
  154. # Build the reverted marketplace: for each failing plugin, restore
  155. # source.sha to main's value. Refuse if anything else differs — a
  156. # difference outside source.sha on a bump-branch entry means the
  157. # branch was tampered with.
  158. jq -c -s \
  159. '.[0] as $head | .[1] as $base | (.[2] | map({(.): true}) | add // {}) as $fail
  160. | ($base.plugins | map({(.name): .}) | add // {}) as $b
  161. | $head | .plugins = [
  162. .plugins[] |
  163. if ($fail[.name] // false) and ($b[.name] // null) != null then
  164. # Verify the only delta is source.sha — never silently
  165. # accept a structural change masquerading as a bump.
  166. if (. | del(.source.sha)) == ($b[.name] | del(.source.sha)) then
  167. .source.sha = $b[.name].source.sha
  168. else
  169. error("entry \(.name) differs from main beyond source.sha — refusing to revert")
  170. end
  171. else . end
  172. ]' \
  173. work/head.json work/base.json scan-out/run-failed.json > work/reverted.json.compact
  174. # Match the marketplace's existing pretty-print so the diff is
  175. # human-reviewable.
  176. jq --indent 2 '.' work/reverted.json.compact > work/reverted.json
  177. # Two no-action cases:
  178. # - nothing actually reverted (failed names not in this PR's diff)
  179. # - everything reverted (the file is back to main → PR is empty)
  180. if cmp -s work/reverted.json.compact <(jq -c '.' work/head.json); then
  181. echo "::notice::No entries to revert (failing names not in this PR)."
  182. echo "committed=false" >> "$GITHUB_OUTPUT"
  183. echo "empty=false" >> "$GITHUB_OUTPUT"
  184. exit 0
  185. fi
  186. if cmp -s work/reverted.json.compact <(jq -c '.' work/base.json); then
  187. echo "::warning::Every bumped entry failed policy — the PR would be empty."
  188. echo "committed=false" >> "$GITHUB_OUTPUT"
  189. echo "empty=true" >> "$GITHUB_OUTPUT"
  190. exit 0
  191. fi
  192. # Vendored entries have a string `source` — restrict to object
  193. # sources or `.source.sha` errors.
  194. reverted="$(jq -c -s \
  195. '.[0] as $head | .[1] as $rev
  196. | ($head.plugins | map(select(.source | type == "object") | {(.name): .source.sha}) | add // {}) as $h
  197. | [$rev.plugins[] | select(.source | type == "object")
  198. | select(($h[.name] // null) != .source.sha) | .name]' \
  199. work/head.json work/reverted.json.compact)"
  200. echo "Reverted: $reverted"
  201. echo "reverted=$reverted" >> "$GITHUB_OUTPUT"
  202. msg="Drop $(jq 'length' <<<"$reverted") policy-failing entries from bump"
  203. # createCommitOnBranch: GitHub-signed, expectedHeadOid CAS so a
  204. # concurrent force-reset from the nightly bump fails this push
  205. # loudly instead of being clobbered. The base64'd marketplace can
  206. # exceed MAX_ARG_STRLEN, so the body travels via stdin.
  207. oid="$(jq -n \
  208. --rawfile content work/reverted.json \
  209. --arg repo "$REPO" \
  210. --arg branch "$BUMP_BRANCH" \
  211. --arg oid "$HEAD_SHA" \
  212. --arg msg "$msg" \
  213. --arg path "$MARKETPLACE" \
  214. '{
  215. 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}}}",
  216. variables: { repo: $repo, branch: $branch, oid: $oid, msg: $msg, path: $path, contents: ($content | @base64) }
  217. }' \
  218. | gh api graphql --input - --jq '.data.createCommitOnBranch.commit.oid')"
  219. [[ "$oid" =~ ^[0-9a-f]{40}$ ]] || { echo "::error::createCommitOnBranch did not return a commit OID."; exit 1; }
  220. echo "committed=true" >> "$GITHUB_OUTPUT"
  221. echo "empty=false" >> "$GITHUB_OUTPUT"
  222. echo "::notice::Pushed revert commit $oid to $BUMP_BRANCH."
  223. - name: Close empty bump PR
  224. if: steps.revert.outputs.empty == 'true'
  225. env:
  226. GH_TOKEN: ${{ github.token }}
  227. REPO: ${{ github.repository }}
  228. PR: ${{ steps.pr.outputs.number }}
  229. run: |
  230. set -euo pipefail
  231. gh pr comment "$PR" --repo "$REPO" --body \
  232. "$REVERT_MARKER"$'\n\n'"Every bumped entry failed the policy scan. Closing — the next nightly run will retry."
  233. gh pr close "$PR" --repo "$REPO"
  234. - name: Comment with revert detail
  235. if: steps.revert.outputs.committed == 'true'
  236. env:
  237. GH_TOKEN: ${{ github.token }}
  238. REPO: ${{ github.repository }}
  239. PR: ${{ steps.pr.outputs.number }}
  240. REVERTED: ${{ steps.revert.outputs.reverted }}
  241. SCAN_RUN_URL: ${{ github.event.workflow_run.html_url }}
  242. run: |
  243. set -euo pipefail
  244. {
  245. printf '%s\n\n' "$REVERT_MARKER"
  246. echo "Dropped $(jq 'length' <<<"$REVERTED") entrie(s) that failed the policy scan. The remaining bumps were unaffected."
  247. echo
  248. echo "| Plugin | Violations |"
  249. echo "|---|---|"
  250. # `violations` is model-generated text shaped by a cloned external
  251. # repo. Strip markdown control characters and wrap in a code span
  252. # so a prompt-injected upstream can't smuggle links/images/table
  253. # breakouts into a public PR comment.
  254. jq -r --argjson rev "$REVERTED" \
  255. 'def neutralize: gsub("[|\n\r\\[\\]<>`]"; " ");
  256. .[] | select(.name as $n | $rev | index($n))
  257. | "| \(.name) | `\(.violations | neutralize | .[0:200])` |"' \
  258. scan-out/run-verdicts.json
  259. echo
  260. echo "These entries will be retried at their next upstream SHA. See the [scan run]($SCAN_RUN_URL) for full verdicts."
  261. } > /tmp/comment.md
  262. gh pr comment "$PR" --repo "$REPO" --body-file /tmp/comment.md
  263. - name: Re-dispatch scan on revised bump branch
  264. if: steps.revert.outputs.committed == 'true'
  265. env:
  266. GH_TOKEN: ${{ github.token }}
  267. run: gh workflow run scan-plugins.yml --ref "$BUMP_BRANCH"