Skip to content

feat: doc-only fast-skip + SHA marker + prior-comment collapse - #47

Merged
twistedmelonman merged 4 commits into
mainfrom
claude/reduce-ci-review-cost-20260418
Apr 18, 2026
Merged

feat: doc-only fast-skip + SHA marker + prior-comment collapse#47
twistedmelonman merged 4 commits into
mainfrom
claude/reduce-ci-review-cost-20260418

Conversation

@twistedmelonman

Copy link
Copy Markdown
Member

Summary

Three coordinated CI-cost reductions targeting failure modes documented in the 2026-04-17 AAR:

  • Doc-only fast-skip — new pre-step detects diffs that only touch docs/meta files (*.md, LICENSE, CHANGELOG, .gitignore, docs/, .github/ISSUE_TEMPLATE/**, .github/FUNDING.yml) and short-circuits the review with VERDICT: SKIPPED. PR bodies like kebab-tax#1162 (doc-only, hit 3× flakes this week) would cost $0 instead of 3× timeout-out runs.
  • SHA marker in every posted review (<!-- claude-blocking-review sha=<sha> run=<id> -->). Downstream scrapers can filter to the current HEAD. Fixes the post-push-status.sh phantom-findings issue seen on kebab-tax#1165.
  • Minimize prior reviews via minimizeComment GraphQL mutation (classifier: OUTDATED). Keeps the PR conversation tidy across iterations without breaking the rendered review body.

Intentional scope boundary

.github/CODEOWNERS and .github/dependabot.yml are explicitly excluded from the doc-only allowlist. They look meta but have real security impact (approval authority, dependency sourcing). Changes there should still get a Claude review. See commit 0b45b33 for the rationale.

What this PR does NOT change

  • No change to the BLOCK/PASS contract.
  • No change to the required status check name (claude-review / run-review).
  • No change to the reusable workflow's public inputs or secrets.
  • No change to the allowed-tools list at the Claude action invocation — the marker emission uses three sequential printf / cat / gh pr comment commands that already match the existing prefix allowlist.

Expected cost impact

Scenario Before After
Doc-only PR Full review (~5–10 min Claude) $0 — skipped
PR with N push iterations N reviews accumulate N reviews, but prior N-1 collapsed
Code PR on first iteration Full review Full review (unchanged)

Commits

  • 7e5d8f3 — feat: add doc-only skip, SHA marker, and prior-comment collapse
  • 0b45b33 — fix: exclude CODEOWNERS and dependabot.yml from doc-only allowlist
  • 8584ec1 — fix: use last: 100 and explicit-string GraphQL vars

Test plan

  • Local doc-only detection tested against 7 classification cases
  • GraphQL comments(last: 100) query verified against PR chore: restore self-applying caller for dogfooding + required status check #41
  • OUTDATED classifier verified via GraphQL introspection
  • Pre-commit + pre-push reviewers all PASS
  • Dogfood: this very PR will exercise the Claude review on the new code (real review since the workflow already exists on main)
  • Post-merge, the next PR in this repo (e.g. PR Add Claude Code GitHub Workflow #3) will be the first to exercise the SHA marker, the prior-comment minimize, and a re-review iteration

Follow-ups (non-blocking, filed during local review)

Closes

🤖 Generated with Claude Code

Claude Code Bot and others added 3 commits April 18, 2026 13:04
…viewer

Three CI-cost reductions aimed at the failure modes documented in the
2026-04-17 AAR. All three land together because they share the same
`Check for doc-only diff` step output and the same marker contract.

1. Doc-only fast-skip (new Check for doc-only diff step, lines 126-170)
   Before spinning up the Claude action, check whether every changed
   file matches a doc/meta pattern (*.md, LICENSE, CHANGELOG, .gitignore,
   .github/ISSUE_TEMPLATE/**, docs/, etc.). If yes, skip the paid review
   and post a SKIPPED verdict to the step summary. The BLOCK criteria
   (bug, reliability regression, security, data-loss) don't apply to
   prose changes, so the review was adding cost and flake surface area
   for zero signal. kebab-tax#1162 was a doc-only PR that failed three
   times in a row on infrastructure flakes this week — this skip would
   have saved the full cost of all three runs.

2. SHA marker in every posted review (prompt Step 3 change)
   Each review comment now starts with:
     <!-- claude-blocking-review sha=<head_sha> run=<run_id> -->
   Downstream consumers (status scrapers, the new minimize step below,
   future tooling) can filter to the review for the current HEAD and
   ignore phantom findings from prior runs. kebab-tax#1165's post-push
   loop saw FINDINGS=1→2→3→4 across four iterations precisely because
   top-level issue comments carry no original_commit_id; the marker
   closes that gap without changing the comment's rendered body.

3. Collapse prior review comments (new Minimize prior step, lines 240-284)
   Before the Claude action runs, query the PR for comments whose body
   begins with the claude-blocking-review marker and minimize them via
   the minimizeComment GraphQL mutation (classifier: OUTDATED). Keeps
   the PR conversation readable across iterations — GitHub collapses
   minimized comments under a "Show resolved" affordance and
   `gh pr view --comments` excludes them by default. Non-fatal: any
   failure here logs a warning but does not block the review.

Implementation notes:
- Estimate, Minimize, and Run Claude steps are all guarded with
  `if: steps.doc-check.outputs.skip != 'true'`. The final Check review
  verdict step keeps `if: always()` and short-circuits at the top when
  DOC_SKIP=true so doc-only PRs exit 0 cleanly.
- The marker emission is three sequential commands (printf > file,
  cat >> file, gh pr comment --body-file file) rather than a pipeline
  so every command stays within the existing allowed-tools prefix
  allowlist at line 294 without broadening it.
- The minimize query uses --jq to filter for `.isMinimized == false`
  AND body starts-with the marker, so repeated runs are idempotent
  (already-minimized comments are skipped).
- OUTDATED classifier confirmed valid via GraphQL introspection;
  requires pull-requests: write (already declared).

Expected cost impact:
- Doc-only PRs: eliminated (was the full ~5-10 min Claude review cost).
- PRs with multiple push iterations: each run still costs one review,
  but prior reviews collapse instead of accumulating noise.
- Diff-unrelated: no change.

No change to the BLOCK/PASS contract, no change to the required status
check name, no change to the reusable workflow's inputs or secrets.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tighten the doc-only fast-skip classification per local code-reviewer
feedback. Both files live under .github/ alongside true display-only
config (FUNDING.yml, issue templates), but they have real security
impact:

- .github/CODEOWNERS controls which reviewers are required to approve
  changes. A malicious PR that weakens CODEOWNERS (e.g., removes an
  approval requirement on a sensitive path, or adds the attacker as
  an owner of security-critical code) should not skip review.

- .github/dependabot.yml controls which ecosystems, registries, and
  directories get dependency updates, and what the update cadence
  looks like. Changes here can silently weaken supply-chain posture
  (e.g., pointing to an attacker-controlled registry, or disabling
  security updates).

FUNDING.yml stays in the allowlist — it's purely display metadata for
the "Sponsor this project" button and has no authorization or
dependency semantics.

Issue templates and .gitignore-class meta files also stay; those are
build-time noise, not runtime behavior.

Follow-up to the preceding commit in this branch. No other changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Address two non-blocking observations from the pre-push codebase reviewer
(issues #42 and #43).

#42: GraphQL `comments(first: 100)` returns the OLDEST 100 comments first.
On a PR that has collected >100 comments before any claude-blocking-review
ran, or on a very active PR that accumulates non-bot comments faster than
the minimize step can keep up, recent markers would fall outside the
window and never be minimized. `last: 100` gets the newest 100 instead,
which is the natural fit for "find my recent review markers to collapse"
and aligns the window with how reviews accumulate chronologically.

#43: `gh api -F` auto-detects argument types — repo names that happen to
parse as integers (e.g. a name like "123456") would be coerced to int
and fail the `String!` schema check. Switch owner/name to `-f` (explicit
string). Keep `-F number=...` where int coercion is desired for the
Int! PR number variable.

No behavior change on the current PRs in flight. Follow-up to the two
preceding commits in this branch. Closes #42 and #43 on merge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@claude

This comment has been minimized.

Two correctness bugs surfaced by PR #47's own self-review run (which
failed with an "INCOMPLETE" verdict that the step output confirmed was
a false BLOCK).

1. Fallback verdict grep was unanchored (pre-existing bug, finally bit).
   The Check review verdict step reads the last PR comment when the
   verdict file isn't present, using `grep -q "VERDICT: BLOCK"` /
   `grep -q "VERDICT: PASS"`. These have no line anchors, so any
   occurrence of those strings ANYWHERE in the comment body counts as
   a match. PR #47's review prose discussed the workflow's own grep
   pattern and included the literal string `VERDICT: BLOCK` in its
   analysis — the fallback parser treated that as the verdict and
   reported the review as BLOCK despite the real terminal verdict line
   reading PASS.

   Fix: anchor both greps to line start/end (`grep -qE '^VERDICT: X$'`),
   so only a real terminal verdict line matches. Verified locally
   against the actual broken PR #47 comment:
     unanchored: BLOCK=1 (false), PASS=2
     anchored:   BLOCK=0,          PASS=1

2. Verdict file was written AFTER the comment post (also pre-existing).
   If the Claude action ran out of turns or wall-clock time during the
   three-command comment post (or the comment post itself hung), the
   verdict file never got written and CI fell through to the more
   fragile fallback grep path. Reordering so the verdict file is
   written immediately after the VERDICT line is appended to
   /tmp/review.md means a partial run still produces the primary
   signal. The comment post is the nice-to-have; the verdict file is
   the contract.

Step order is now:
  1. Write review to /tmp/review.md
  2. Append VERDICT line to review.md
  3. Write verdict file  <- moved up (was Step 4)
  4. Post review comment  <- moved down (was Step 3)

Both changes are in the Check review verdict step and the prompt's
Step 3/4 ordering — no change to public inputs, outputs, or the
required status check name.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Apr 18, 2026

Copy link
Copy Markdown

Claude Code Review — PR #47: doc-only fast-skip + SHA marker + prior-comment collapse

Summary

Three coordinated improvements: doc-only skip, SHA-tagged comment markers, and prior-comment minimization. The changes are well-scoped and address documented failure modes. No blocking issues found.


Security

Shell injection — clean. All ${{ ... }} interpolations into shell reach the runner via safe paths:

  • PR_NUMBER and similar inputs arrive via env: and are double-quoted at every use site ("$PR_NUMBER").
  • ${{ github.event.pull_request.head.sha }} and ${{ github.run_id }} expand into the printf template in the Claude prompt. The SHA is 40 hex chars; the run ID is numeric — neither can carry shell metacharacters. Both are single-quoted in the generated shell, adding a second layer.
  • ${{ inputs.pr_number }} expands numerically in gh pr comment ${{ inputs.pr_number }} inside the prompt — same safe class.

CODEOWNERS / dependabot.yml exclusion — correct. The explicit exclusions from the doc-only allowlist are appropriate and well-documented.


Reliability

Grep-anchoring fix (^VERDICT: BLOCK$) — correct and necessary. The previous unanchored pattern would spuriously match any review prose that discussed the verdict contract (exactly what happened in the self-review cited in the diff comment). The new grep -qE '^VERDICT: BLOCK$' pattern correctly requires an exact full-line match. ✓

Verdict-before-comment reordering — improvement, not a regression. Old steps 3/4 (post comment, then write verdict file) are now swapped: write file first, then post comment. If Claude exhausts turns mid-post, CI still reads a usable verdict file. No consumer depends on the old order. ✓

Doc-skip DOC_SKIP check in verdict step — correct. Line 425 checks [ "$DOC_SKIP" = "true" ] before any verdict enforcement. If the doc-check step was somehow skipped or produced no output, DOC_SKIP is empty, the check evaluates false, and the review proceeds — safe default. ✓

Minimize step failure isolation — correct. Every external call in the minimize step is guarded: gh api graphql ... 2>/dev/null || echo "" for the query; >/dev/null 2>&1 with a ::warning:: fallback for each mutation. The step cannot exit non-zero unintentionally and will not block Run Claude Code Review. ✓


Non-Blocking Observations

  1. Empty SHA in non-pull_request dispatch contexts. ${{ github.event.pull_request.head.sha }} is undefined when this reusable workflow is called from a workflow_dispatch or push trigger. The marker would become <!-- claude-blocking-review sha= run=12345 -->. Downstream status scrapers would need to tolerate an absent SHA. Low-risk since the primary use case is pull_request triggers, and the fallback verdict path is unaffected. Consider ${{ inputs.head_sha || github.event.pull_request.head.sha }} with an explicit input if broader trigger support is needed.

  2. .github/PULL_REQUEST_TEMPLATE.md in the doc-only allowlist. PR templates can embed reviewer checklists, required sign-offs, or label instructions. A template change that removes a required security checklist would sail through with no review. Worth discussing whether to treat it like CODEOWNERS (excluded) rather than prose. Not a current bug — a judgment call.

  3. break on first non-doc file means NON_DOC is always a single filename. The log line "Non-doc file present ('$NON_DOC')" is accurate (one example is sufficient for diagnostics), but a reader might expect all non-doc files to be listed. Cosmetic only.

VERDICT: PASS

@twistedmelonman
twistedmelonman merged commit 454f1ef into main Apr 18, 2026
3 checks passed
@twistedmelonman
twistedmelonman deleted the claude/reduce-ci-review-cost-20260418 branch April 18, 2026 20:33
twistedmelonman pushed a commit that referenced this pull request Apr 18, 2026
Closes #46. Under the previous implementation, `gh pr diff --name-only`
returned only the destination paths for renamed files, so a rename from
`src/foo.py` → `docs/foo.md` would appear as only `docs/foo.md`,
classify as doc-only, and skip review — even though the diff contains
significant code deletion at the source path.

Switch to the `repos/{owner}/{repo}/pulls/{pr}/files` API and emit the
UNION of `previous_filename` (non-null when renamed or copied) and
`filename` for every entry. The doc-only check then requires every
pre-rename AND post-rename path to match the allowlist — closing the
blind spot.

For non-renamed files `previous_filename` is null and filtered by the
jq `select(. != null)` clause, so non-rename behavior is unchanged.

Verified against PRs #39, #47, #52 (all modified-only) — output is
identical to the prior `--name-only` approach. An actual rename case
wasn't available in recent history to test empirically, but the jq
emits `previous_filename, filename` separately so the case is covered
by construction: if ANY of those paths is non-doc, the final
classification flips to non-doc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
twistedmelonman added a commit that referenced this pull request Apr 18, 2026
) (#53)

* fix: use -f (explicit string) for minimizeComment ID variable

Consistency with the preceding query that already uses -f for owner
and name. GitHub Relay node IDs are base64-encoded and always contain
non-digit characters, so the practical risk of -F type-coercing an
ID to an integer is zero today — but the stated rationale in the
comment above (line 262-264) recommends -f for String-typed variables,
and ID values serialize the same way as String. This aligns the code
with the documented pattern.

Closes #44.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: tighten doc-only allowlist — remove *.txt, PR_TEMPLATE; add images

Three allowlist adjustments in one commit — they share the same case
block and ship together to avoid rewriting it three times.

1. Remove *.txt (resolves #48). Matched dependency manifests like
   requirements.txt, constraints.txt, packages.txt, which are
   code-adjacent and deserve review on change. `.txt` is rarely used
   for prose in modern repos; README/docs are conventionally .md.
   If a consumer genuinely has prose .txt files, they'll get a cheap
   review — the failure mode of skipping a requirements.txt change is
   significantly worse.

2. Remove .github/PULL_REQUEST_TEMPLATE.md (resolves #49). Templates
   can embed required security checklists, reviewer sign-offs, or
   label instructions. A PR that removes a required security checklist
   should not sail through unreviewed. ISSUE_TEMPLATE/* stays in the
   allowlist — those are user-facing forms with lower enforcement
   significance.

   Note: this required reordering the case statement — the explicit
   NON-DOC exclusion for PULL_REQUEST_TEMPLATE.md / CODEOWNERS /
   dependabot.yml now comes FIRST, because they'd otherwise match the
   general *.md / *.yml patterns that follow. The CODEOWNERS and
   dependabot.yml rules were already correct (they don't match *.md
   or the ISSUE_TEMPLATE glob) but collecting them into the same
   explicit exclusion block documents the "security-critical meta
   file" category cleanly.

3. Add image assets (resolves #45). *.png, *.jpg, *.jpeg, *.gif,
   *.svg, *.webp, *.ico, *.bmp. README screenshot updates, docs
   figures, and logo swaps don't need a paid review — they can't
   meet any BLOCK criterion (no runtime behavior). Conservative
   set — excludes *.pdf, *.psd, and other heavier binary formats.

No functional change to the rest of the workflow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: detect renames in doc-only classification via files API

Closes #46. Under the previous implementation, `gh pr diff --name-only`
returned only the destination paths for renamed files, so a rename from
`src/foo.py` → `docs/foo.md` would appear as only `docs/foo.md`,
classify as doc-only, and skip review — even though the diff contains
significant code deletion at the source path.

Switch to the `repos/{owner}/{repo}/pulls/{pr}/files` API and emit the
UNION of `previous_filename` (non-null when renamed or copied) and
`filename` for every entry. The doc-only check then requires every
pre-rename AND post-rename path to match the allowlist — closing the
blind spot.

For non-renamed files `previous_filename` is null and filtered by the
jq `select(. != null)` clause, so non-rename behavior is unchanged.

Verified against PRs #39, #47, #52 (all modified-only) — output is
identical to the prior `--name-only` approach. An actual rename case
wasn't available in recent history to test empirically, but the jq
emits `previous_filename, filename` separately so the case is covered
by construction: if ANY of those paths is non-doc, the final
classification flips to non-doc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: fall back to github.sha in SHA marker for non-pull_request triggers

Closes #50. The marker previously interpolated
`github.event.pull_request.head.sha`, which is only populated when the
reusable workflow is called from a `pull_request` caller. A consumer
that invokes it from `workflow_dispatch`, `push`, or another trigger
would produce:

  <!-- claude-blocking-review sha= run=12345 -->

with an empty `sha=` value. Downstream consumers that filter by SHA
(e.g. post-push status scrapers) would then see every review for that
commit as "unknown SHA" and fall through to phantom-finding behavior.

Use the GitHub Actions expression `||` fallback so the marker always
has a non-empty SHA. The operator returns the first truthy operand —
empty strings from unpopulated nested paths like
`github.event.pull_request.head.sha` evaluate falsy, so the fallback
to `github.sha` fires on non-PR triggers.

Behavior by trigger:
- pull_request (this repo's self-review): PR head SHA, unchanged.
- workflow_dispatch / push / schedule: the triggering commit SHA,
  which is the closest analogue to "what this review is reviewing."
- scheduled / manual runs with no associated commit: github.sha is
  still populated with the workflow file's commit SHA, so the marker
  has a value rather than being empty.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Code Bot <claude-code@smartwatermelon.github>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant