From b929803afd13ed1516cd000646d30ab4409f9f19 Mon Sep 17 00:00:00 2001 From: effece Date: Sun, 4 Oct 2026 11:55:03 -0300 Subject: [PATCH] [ci] feat: let pure Dependabot action-ref bumps pass the review guard claude-code-action skips itself on a PR that edits claude-review.yml, so every Dependabot bump of an action used there failed the required `review` check and needed an admin bypass merge. The enforce step now passes such a PR without a Claude verdict only when all of these hold: - github.actor is dependabot[bot], and every PR commit is authored by dependabot[bot], committed by web-flow and GitHub-verified (so nobody can push extra changes onto the branch and ride the exemption); - every changed file is an existing .github/workflows/*.yml (no renames, mode changes, new or deleted files); - every changed line is a `uses: owner/action@ref` whose removed and added versions pair up in order with the same indent and action name. Anything else still fails and needs a human merge. Checked against 22 diffs: the 4 real checkout bumps merged by hand on 2026-10-04 pass; 12 crafted bypasses (action swap via comment, rename out of workflows, moved steps, hidden lines, new files, ...) fail. --- .github/workflows/claude-review.yml | 89 ++++++++++++++++++++++++++++- 1 file changed, 87 insertions(+), 2 deletions(-) diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index 0861eda..d7bb75b 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -99,12 +99,97 @@ jobs: PR: ${{ github.event.pull_request.number }} SHA: ${{ github.event.pull_request.head.sha }} AUTHOR: ${{ github.event.pull_request.user.login }} + ACTOR: ${{ github.actor }} run: | + # True when a Dependabot PR only moves action refs. Its commits must all be + # Dependabot's own (author dependabot[bot], committed and verified by GitHub), + # so nobody can push extra changes onto its branch and ride this exemption. + # The diff may touch only existing workflow files, and each changed line must + # be a `uses:` whose old and new versions keep the indent and action name. + only_ref_bumps() { + [ "$ACTOR" = "dependabot[bot]" ] || return 1 + others=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR/commits" --paginate --jq \ + '.[] | select(.author.login != "dependabot[bot]" or .committer.login != "web-flow" or (.commit.verification.verified | not)) | .sha') || return 1 + [ -z "$others" ] || return 1 + gh pr diff "$PR" --repo "$GITHUB_REPOSITORY" > "$RUNNER_TEMP/pr.diff" || return 1 + python3 - "$RUNNER_TEMP/pr.diff" <<'PY' + import re, sys + + # A `uses:` line: optional list dash, optional quotes, owner/action@ref, + # optional trailing comment that cannot hold another action reference. + USES = re.compile( + r"""^(?P\s*(?:-\s+)?)uses:\s*(?P["']?)""" + r"""(?P[A-Za-z0-9._-]+/[A-Za-z0-9._/-]+)@(?P[A-Za-z0-9._/-]+)(?P=q)""" + r"""(?:\s+#[^@`$]*)?\s*$""" + ) + HEADER_OK = ("index ", "--- a/", "+++ b/") + + def check(text): + files = re.split(r"(?m)^(?=diff --git )", text) + files = [f for f in files if f.strip()] + if not files: + return False + for f in files: + lines = f.split("\n") + m = re.fullmatch(r"diff --git a/(\S+) b/(\S+)", lines[0]) + if not m or m.group(1) != m.group(2): + return False + path = m.group(1) + if not re.fullmatch(r"\.github/workflows/[^/]+\.ya?ml", path): + return False + i = 1 + while i < len(lines) and not lines[i].startswith("@@"): + if not lines[i].startswith(HEADER_OK): + return False # rename, copy, mode change, new/deleted, binary + i += 1 + body = lines[i:] + if not body or not any(l.startswith("@@") for l in body): + return False + minus, plus = [], [] + pairs = [] + def flush(): + if len(minus) != len(plus): + raise ValueError + pairs.extend(zip(minus, plus)) + minus.clear(); plus.clear() + try: + for l in body: + if l == "\\ No newline at end of file": + continue + if l.startswith("-"): + if plus: + flush() + minus.append(l[1:]) + elif l.startswith("+"): + plus.append(l[1:]) + else: + flush() + flush() + except ValueError: + return False + if not pairs: + return False + for old, new in pairs: + a, b = USES.match(old), USES.match(new) + if not a or not b: + return False + if a["indent"] != b["indent"] or a["action"] != b["action"]: + return False + return True + + sys.exit(0 if check(open(sys.argv[1]).read()) else 1) + PY + } # The action skips itself on a PR that edits this workflow (it must match the - # default branch). A human PR may do that; a Dependabot PR may not pass that way. + # default branch). A human PR may do that. A Dependabot PR may only when it + # does nothing but bump action refs; anything else needs a human merge. if gh pr diff "$PR" --repo "$GITHUB_REPOSITORY" --name-only | grep -qx '.github/workflows/claude-review.yml'; then if [ "$AUTHOR" = "dependabot[bot]" ]; then - echo "::error::Dependabot PR edits claude-review.yml; it cannot be reviewed by itself, merge by hand" + if only_ref_bumps; then + echo "::notice::Dependabot PR only bumps action refs; passing without Claude review" + exit 0 + fi + echo "::error::Dependabot PR edits claude-review.yml beyond action-ref bumps; merge by hand" exit 1 fi echo "::notice::PR edits claude-review.yml; Claude review is skipped by design"