Skip to content

feat(pr-risk): gate grading behind an enabled switch, off by default - #115

Merged
mattmillerai merged 1 commit into
mainfrom
matt/pr-risk-enabled-switch
Aug 4, 2026
Merged

feat(pr-risk): gate grading behind an enabled switch, off by default#115
mattmillerai merged 1 commit into
mainfrom
matt/pr-risk-enabled-switch

Conversation

@mattmillerai

Copy link
Copy Markdown
Contributor

ELI-5

Grading is now off unless switched on. Enrolling the workflow and turning it on become two separate decisions: a repo can land the caller, get it reviewed, and start grading when it's ready — or stop grading without reverting anything.

The switch has two layers, and the variable outranks the input in both directions:

# reviewed, pinned in the caller's workflow file
with:
  enabled: true
# operational, no PR — and it can also turn it OFF
gh variable set RISK_CONFIG --repo <owner>/<repo> --body '{"enabled": true}'

This works because the vars context inside a reusable resolves against the caller's repository — the same mechanism groom.yml already uses for vars.GROOM_CONFIG.

Why the variable can also disable

A kill switch that needs a PR isn't a kill switch. {"enabled": false} stops grading on the next run even when the caller pins enabled: true, so the escape hatch works at the moment you need it — which is by definition a moment when something is already going wrong.

A malformed variable degrades to the reviewed value, never to off

"The variable is broken" and "the operator switched it off" must not look alike. If a typo read as false, one bad edit would silently un-enrol a repo and look entirely deliberate. So a value this can't read is announced in an annotation and discarded, and the reviewed input stands.

A well-formed object with no enabled key is silent rather than warning — it's the only key today, and a per-run annotation would train operators to ignore the one that flags a real typo (and would start lying the moment a second key lands there).

The bug the tests caught

Resolution lives in scripts/pr-risk/resolve-enabled.sh rather than inline YAML so it's testable — the same reasoning that moved the target loop into grade-targets.sh in #113. That paid for itself immediately:

jq -e sets its exit code from the TRUTHINESS of its output. So a perfectly valid {"enabled": false} made jq exit 1, was mistaken for a parse failure, fell through to the fallback — and silently disarmed the kill switch. The one half of this lever that has to work when things are already going wrong. It now reads the value as text and tests the text.

I would not have found that by inspection; it took the test that asserts the disable direction specifically.

Shape

A gate job resolving enablement, holding no token scopes (permissions: {}), with grade gated on its output. Its own job rather than a first step of grade so:

  • a disabled repo pays one bare runner instead of booting the grading job to immediately no-op
  • the decision, and the reason for it, is visible in the run graph rather than buried in a step log
  • a disabled run explains itself in the step summary instead of looking like a workflow that quietly did nothing

A disabled run touches no label. Whatever the last enabled run left stands — it does not grade, and it does not clean up.

Verification

test_resolve_enabled.sh, 21 assertions: both override directions, all four malformed shapes (unparseable / non-object / string "true" / numeric 1) degrading to the reviewed value in both the enabled and disabled starting states, the silent no-key case, whitespace-only handling, the disabled summary, and that an enabled run writes no summary at all.

Wired into test-pr-risk.yml explicitly — that workflow enumerates its suites rather than globbing, so a new file is otherwise silently un-run. Added to its shellcheck list for the same reason.

All four suites pass; shellcheck -x clean; both workflow files parse.

Consumer impact — read this before merging

This is a breaking change for the two live consumers, by design. Comfy-Org/cloud and Comfy-Org/comfy-cloud-mcp-server are enrolled and grading today; when they bump past this SHA they stop, unless enabled first.

Neither is affected until it bumps its own pin, so there is no rush — but the bump and the enable belong in the same change. Two options per repo, either works:

  1. add enabled: true to the caller's with: block in the same PR as the pin bump, or
  2. set RISK_CONFIG to {"enabled": true} before merging the bump

The disabled step summary names both, so a repo that bumps and goes dark finds out on its next PR rather than wondering where its labels went.

Enrolling the workflow and switching it on become two decisions: a repo
can land the caller, get it reviewed, and start grading later — or stop
grading without reverting anything.

`vars.RISK_CONFIG` on the CALLING repo outranks the reviewed input in
BOTH directions. `{"enabled": true}` switches a caller on with no `with:`
change; `{"enabled": false}` is a kill switch that needs no PR at all.
The `vars` context inside a reusable resolves against the caller's
repository, which is what makes this reachable — the same mechanism
groom.yml already uses for vars.GROOM_CONFIG.

A malformed variable degrades to the REVIEWED value, never to off, and
says so in an annotation: "the variable is broken" and "the operator
switched it off" must not look alike, or one typo silently un-enrols a
repo and looks deliberate. A well-formed object with no `enabled` key is
silent rather than warning — it is the only key today, and a per-run
annotation would train operators to ignore the one that flags a real
typo.

Resolution lives in scripts/pr-risk/resolve-enabled.sh rather than inline
YAML so it is testable, following the same reasoning that moved the
target loop into grade-targets.sh. Its suite caught the bug that matters
most: `jq -e` sets its exit code from the TRUTHINESS of its output, so a
valid `{"enabled": false}` exited 1, read as a parse failure, and
disarmed the kill switch — the half of the lever that has to work when
something is already going wrong.

Runs as its own `gate` job holding no token scopes, so a disabled repo
pays one bare runner instead of booting the grading job to no-op, and a
disabled run explains itself in the step summary rather than looking
like a workflow that quietly did nothing. A disabled run touches no
label: whatever the last enabled run left stands.

Wired into test-pr-risk.yml explicitly — that workflow enumerates its
suites rather than globbing, so a new file is otherwise silently un-run.
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 27 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 4132840c-49c5-4ed3-a6b2-f80b68373355

📥 Commits

Reviewing files that changed from the base of the PR and between b858bd6 and 111d202.

📒 Files selected for processing (5)
  • .github/workflows/pr-risk.yml
  • .github/workflows/test-pr-risk.yml
  • README.md
  • scripts/pr-risk/resolve-enabled.sh
  • scripts/pr-risk/tests/test_resolve_enabled.sh

Comment @coderabbitai help to get the list of available commands.

@mattmillerai mattmillerai added the cursor-review Multi-model cursor review label Aug 4, 2026
@mattmillerai
mattmillerai marked this pull request as ready for review August 4, 2026 01:56
@mattmillerai
mattmillerai merged commit e4a8f7c into main Aug 4, 2026
10 checks passed
@mattmillerai
mattmillerai deleted the matt/pr-risk-enabled-switch branch August 4, 2026 01:56

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Cursor Review — Consolidated panel

Triggered by @mattmillerai.

Found 10 finding(s).

Severity Count
🟠 High 1
🟡 Medium 4
🟢 Low 4
⚪ Nit 1

Panel: 8/8 reviewers contributed findings.

name: Resolve enablement
runs-on: ubuntu-latest
timeout-minutes: 5
permissions: {}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High — The gate job declares permissions: {} but immediately runs actions/checkout against Comfy-Org/github-workflows, which presents the zero-scope GITHUB_TOKEN rather than falling back to anonymous read. If that clone is refused the gate job fails, enabled is empty, and the needs.gate.outputs.enabled == 'true' condition skips grading in every consumer — the sibling gate in groom.yml and the grade job here both declare contents: read, so match that proven pattern. Raised by 3 of 8 reviewers (gpt-5.6-sol-max edge-case, claude-opus-5-thinking-max adversarial, claude-opus-5-thinking-max edge-case).

fi
fi

[ -z "${GITHUB_OUTPUT:-}" ] || echo "enabled=$enabled" >> "$GITHUB_OUTPUT"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium — The append to $GITHUB_OUTPUT is unchecked and the script runs under set -uo pipefail without -e, so a failed write leaves the step green with an empty enabled output; grade then skips as though the repo were disabled — the exact "degrade toward off" the file header forbids. Check the write and fail the step (or treat a missing output as an error in the workflow). Raised by 4 of 8 reviewers (gpt-5.6-sol-max edge-case, kimi-k2.7-code edge-case, claude-opus-5-thinking-max adversarial, claude-opus-5-thinking-max edge-case).

degrades to this value and says so in an annotation.
type: boolean
required: false
default: false

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium — Defaulting enabled to false silently disables grading for every already-enrolled caller the next time it moves its SHA pin, and a disabled run deliberately leaves the previous risk:* label in place — so those PRs keep showing a stale grade indistinguishable from a current one. There is no bump-pr-risk-callers fleet, so pins move by hand with no coordinated migration; this is a behavior break riding the in-place v1 tag. Raised by 2 of 8 reviewers (claude-opus-5-thinking-max adversarial, claude-opus-5-thinking-max edge-case).

echo
echo "Decided by ${decided_by}. To switch grading on, either set the repository variable"
echo '`RISK_CONFIG` to `{"enabled": true}` (takes effect on the next run, no PR needed), or'
echo 'pass `enabled: true` in the caller'"'"'s `with:` block.'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium — The disabled-run summary always offers enabled: true in the caller's with: block as a remedy, but when decided_by is vars.RISK_CONFIG the variable outranks the input and that advice provably will not work. Branch the remediation text on decided_by and, for the kill-switch case, say the variable must be changed or removed first. Raised by 1 of 8 reviewers (claude-opus-5-thinking-max edge-case).


raw="${RISK_CONFIG:-}"
if [ -n "${raw//[[:space:]]/}" ]; then
if ! jq -e 'type == "object"' >/dev/null 2>&1 <<<"$raw"; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium! jq -e 'type == "object"' with stderr discarded treats every non-zero exit as "not a JSON object" — including exit 127 when jq is missing from the runner, OOM, or any jq-internal error. A perfectly valid {"enabled": true} is then discarded, the kill switch stops working, and the annotation blames the operator's variable for a tool failure. Distinguish jq exit 1 (predicate false) from exit >1 (jq itself failed) and fail loudly on the latter. Raised by 2 of 8 reviewers (claude-opus-5-thinking-max adversarial, claude-opus-5-thinking-max edge-case).

eq "an enabled run writes no summary" "" "$SUMMARY"

echo "— phase 7: it is runnable outside Actions (neither env file set) —"
out="$(INPUT_ENABLED=true RISK_CONFIG='' bash "$SCRIPT" 2>/dev/null)"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low — Phase 7 claims to test the "runnable outside Actions" path but never unsets GITHUB_OUTPUT / GITHUB_STEP_SUMMARY, both of which ARE set when the suite runs in CI — so the script takes the write-to-file branch, the assertion passes vacuously, and the run appends a stray enabled=true into the live step's GITHUB_OUTPUT. Invoke via env -u GITHUB_OUTPUT -u GITHUB_STEP_SUMMARY. Raised by 1 of 8 reviewers (claude-opus-5-thinking-max adversarial).

*DISABLED*"no \`risk:*\` label was added, changed, or removed"*) ok "summary states nothing was touched" ;;
*) bad "summary states nothing was touched" "$SUMMARY" ;;
esac
case "$SUMMARY" in *RISK_CONFIG*) ok "and names the switch" ;; *) bad "and names the switch" "$SUMMARY" ;; esac

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low — Phase 6 only exercises the summary for the input-decided disable (run false ""); it never runs run true '{"enabled": false}' and asserts on the summary, which is precisely the kill-switch state whose remediation text is wrong. The suite also never asserts the script's exit status on any path, so a future non-zero exit would fail the gate job in CI while every test still reports green. Raised by 1 of 8 reviewers (claude-opus-5-thinking-max edge-case).

if [ -n "${raw//[[:space:]]/}" ]; then
if ! jq -e 'type == "object"' >/dev/null 2>&1 <<<"$raw"; then
warn "vars.RISK_CONFIG is set but is not a JSON object — ignoring it and using the reviewed input (enabled=${reviewed}). Expected {\"enabled\": true}."
elif jq -e 'has("enabled")' >/dev/null 2>&1 <<<"$raw"; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Lowjq -e exit status reflects only the truthiness of the LAST output value, so a multi-document RISK_CONFIG like {"enabled": true} {} passes the type == "object" test but makes has("enabled") exit 1, dropping into the deliberately-silent no-key path — the operator's enable is discarded with no warning at all. Assert a single document, e.g. jq -se 'length == 1 and (.[0] | type == "object")'. Raised by 1 of 8 reviewers (claude-opus-5-thinking-max adversarial).

INPUT_ENABLED: ${{ inputs.enabled }}
# Empty when the caller repo has no such variable — which is the common case and is
# NOT an error; it just means the reviewed input decides.
RISK_CONFIG: ${{ vars.RISK_CONFIG }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Lowvars.RISK_CONFIG is an out-of-band disable for a compliance-adjacent check that leaves no trace in git history, and because vars also resolves organization-level variables, one org variable of {"enabled": false} turns grading off across every enrolled repo at once. The disabled path deliberately emits no ::warning::, so the only evidence is a step summary nobody opens on a green run — emit an annotation whenever the variable is what turned grading off. Raised by 1 of 8 reviewers (claude-opus-5-thinking-max adversarial).

echo "## PR risk grading is DISABLED for this repository"
echo
echo "No grade was computed, and no \`risk:*\` label was added, changed, or removed —"
echo "any label already on this PR is left exactly as the last enabled run left it."

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit — The summary says "any label already on this PR is left exactly as the last enabled run left it", but the gate also runs on the pr_numbers batch-dispatch path where there are N target PRs (and on a dispatch with no target, none at all). Reword so it does not assume a single event-scoped pull request. Raised by 1 of 8 reviewers (claude-opus-5-thinking-max edge-case).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cursor-review Multi-model cursor review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants