-
Notifications
You must be signed in to change notification settings - Fork 0
fix(pr-risk): let a manual dispatch grade while enabled is false
#116
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -260,13 +260,25 @@ on: | |
| required: true | ||
| enabled: | ||
| description: >- | ||
| Whether to grade at all. DEFAULTS TO FALSE: enrolling this workflow and | ||
| switching it on are two separate decisions, so a repo can land the | ||
| caller, get it reviewed, and start grading later — or stop grading | ||
| without reverting anything. When it resolves false the `grade` job does | ||
| not run: nothing is read, nothing is labelled, and no existing `risk:*` | ||
| label is touched (a disabled run leaves whatever the last enabled one | ||
| left, it does not clean up). | ||
| Whether to grade AUTOMATICALLY. DEFAULTS TO FALSE: enrolling this | ||
| workflow and switching it on are two separate decisions, so a repo can | ||
| land the caller, get it reviewed, and start grading later — or stop | ||
| grading without reverting anything. When it resolves false a | ||
| `pull_request` run does not grade: nothing is read, nothing is | ||
| labelled, and no existing `risk:*` label is touched (a disabled run | ||
| leaves whatever the last enabled one left, it does not clean up). | ||
|
|
||
|
|
||
| A MANUAL `workflow_dispatch` GRADES REGARDLESS. Dispatching requires | ||
| write access and is an explicit, attributed act, so there is no | ||
| unattended behaviour left for this switch to govern — and without that | ||
| exemption the staged rollout this input exists for cannot happen: a | ||
| repo enrolled OFF could not grade even one PR to see what the grader | ||
| would say, so "try it here before turning it on" would require turning | ||
| it on. The cost, stated plainly: false is a MUTE on the PR stream, not | ||
| a lockout. A repo needing the harder guarantee should remove the | ||
| caller — nothing here can bind a maintainer who can edit the workflow | ||
| file anyway. | ||
|
|
||
|
|
||
| `vars.RISK_CONFIG` on the CALLING repo OVERRIDES this, in both | ||
|
|
@@ -320,7 +332,21 @@ jobs: | |
|
|
||
| grade: | ||
| needs: gate | ||
| if: needs.gate.outputs.enabled == 'true' | ||
| # `enabled` governs AUTOMATIC grading — the `pull_request` stream. A manual dispatch is | ||
| # never blocked by it: running a workflow_dispatch requires write access on the repo and is | ||
| # an explicit, attributed act, so there is no unattended behaviour left for the switch to | ||
| # govern. Without this clause the staged rollout the switch exists for does not work — a | ||
| # repo that enrols OFF cannot grade even a single PR to see what the grader would say, so | ||
| # "try it here before turning it on" requires switching the whole repo on first, which is | ||
| # the thing being evaluated. | ||
| # | ||
| # What this costs, stated rather than buried: `{"enabled": false}` stops the PR-stream | ||
| # noise, but it does NOT stop a human running the grader by hand. It is a mute, not a | ||
| # lockout. If a repo ever needs the harder guarantee, remove the caller — nothing here can | ||
| # bind a maintainer who can edit the workflow file anyway. | ||
| if: >- | ||
| needs.gate.outputs.enabled == 'true' || | ||
| github.event_name == 'workflow_dispatch' | ||
| name: Grade PR risk | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Medium — Nothing in the clause requires a resolvable target. |
||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 30 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,7 +14,7 @@ This repo is **public** so any repo — public or private, inside or outside the | |
| | [`assign-reviewers.yml`](.github/workflows/assign-reviewers.yml) | Auto-requests expertise-aware, load-balanced PR reviewers with new-folk randomization. Matches changed paths against a caller-repo `.github/reviewers.yml` (path-glob → reviewers, plus a `default_pool`), drops the author + `vars.REVIEWER_EXCLUDE`, ranks candidates by open review load (steering off anyone at/over `vars.REVIEWER_LOAD_CAP`), and may swap a slot for a `vars.REVIEWER_GROWTH_POOL` member. Requests go through the CLOUD_CODE_BOT app token so they work on fork PRs. Requires `vars.APP_ID` + `CLOUD_CODE_BOT_PRIVATE_KEY`. | | ||
| | [`assign-prs-to-author.yml`](.github/workflows/assign-prs-to-author.yml) | Housekeeping — assigns every open PR with no assignees to its author (bot-authored PRs skipped by default). Run on a schedule from a thin caller; useful when a team tracks PR ownership via assignees. The calling job needs `pull-requests: write` and `issues: write`. | | ||
| | [`pr-size.yml`](.github/workflows/pr-size.yml) | PR-size cap — fails (or, in `mode: warn`, only reports) when a PR's net diff exceeds `max_lines` non-generated changed lines, keeping diffs reviewable. Excludes dependency lockfiles, `linguist-generated` files (read from the base ref, so a PR can't exempt itself), Go generated-code markers, and per-repo `extra_lockfiles` / `extra_generated_globs`. A `bypass_label` (default `oversized-ok`) waves through a legitimately large change; a sticky bot comment explains overages when `bot_app_id` + `BOT_APP_PRIVATE_KEY` are supplied (degrades to status + step summary without them). Counting logic + tests live in [`scripts/check-pr-size/`](scripts/check-pr-size). | | ||
| | [`pr-risk.yml`](.github/workflows/pr-risk.yml) | **Advisory PR risk grading (shadow check)** — **off by default** (`enabled: false`); switch it on with `enabled: true` or by setting the caller repo's `RISK_CONFIG` variable to `{"enabled": true}`, which outranks the input in both directions so `{"enabled": false}` is a no-PR kill switch. Grades every PR into a tier `R0` (safest) .. `R3` (riskiest) and syncs one label (`risk:R0`..`risk:R3`, or `risk:ungraded` when an input was unreadable). The label is the entire product: nothing is gated, routed, commented, or merged. Deterministic (`gh` + `jq`, no LLM): `grade = worst(path_floor, provenance, reversibility)` — path-glob map, what-process-produced-the-diff (registered runbooks with identity + diff-shape assertions; forks are R3 with no exceptions), and revertability (persistent-state mutation, deletions under sensitive classes, did green checks cover the lines). Grader + generic defaults live in [`scripts/pr-risk/`](scripts/pr-risk); a consumer sharpens them with `.github/risk.json` / `.github/risk-runbooks.json`, read from the PR's **base ref** so a PR can't edit the rules that judge it. The job excludes its own run from the check rollup and waits (`wait_for_checks_minutes`) for the rest to settle before labeling. Labels ride the plain `GITHUB_TOKEN` (cannot fire `labeled` triggers — no cascade risk); disagreement is recorded with a human-owned `risk-dispute` label. Label text is remappable via `label_map`. `workflows_ref` is **required** — pin it to the same full commit SHA as `uses:`, so the grader cannot be loaded from a floating ref after the caller was reviewed. Enroll it as its own workflow rather than a job inside an existing CI workflow (the rollup exclusion is per-run). The calling job needs `contents: read` + `issues: write` + `pull-requests: write` + `checks: read` + `actions: read` + `statuses: read`; GitHub rejects a shorter grant at startup (a reusable workflow can only narrow the caller's token, never elevate it), so a caller enrolled from an older copy of this row fails before any step runs. Both writes are the ONE label: repo-side label creation on first use maps to `issues`, and labeling a PR maps to `pull-requests` (the labels endpoint is dual-mapped by what the "issue" is, so `issues: write` alone 403s on a PR). `actions: read` is for the rollup's `CheckRun -> checkSuite -> workflowRun` self-exclusion hop. No secrets. | | ||
| | [`pr-risk.yml`](.github/workflows/pr-risk.yml) | **Advisory PR risk grading (shadow check)** — **automatic grading off by default** (`enabled: false`; a manual `workflow_dispatch` grades regardless, so a repo can trial it before switching on); switch it on with `enabled: true` or by setting the caller repo's `RISK_CONFIG` variable to `{"enabled": true}`, which outranks the input in both directions so `{"enabled": false}` is a no-PR kill switch. Grades every PR into a tier `R0` (safest) .. `R3` (riskiest) and syncs one label (`risk:R0`..`risk:R3`, or `risk:ungraded` when an input was unreadable). The label is the entire product: nothing is gated, routed, commented, or merged. Deterministic (`gh` + `jq`, no LLM): `grade = worst(path_floor, provenance, reversibility)` — path-glob map, what-process-produced-the-diff (registered runbooks with identity + diff-shape assertions; forks are R3 with no exceptions), and revertability (persistent-state mutation, deletions under sensitive classes, did green checks cover the lines). Grader + generic defaults live in [`scripts/pr-risk/`](scripts/pr-risk); a consumer sharpens them with `.github/risk.json` / `.github/risk-runbooks.json`, read from the PR's **base ref** so a PR can't edit the rules that judge it. The job excludes its own run from the check rollup and waits (`wait_for_checks_minutes`) for the rest to settle before labeling. Labels ride the plain `GITHUB_TOKEN` (cannot fire `labeled` triggers — no cascade risk); disagreement is recorded with a human-owned `risk-dispute` label. Label text is remappable via `label_map`. `workflows_ref` is **required** — pin it to the same full commit SHA as `uses:`, so the grader cannot be loaded from a floating ref after the caller was reviewed. Enroll it as its own workflow rather than a job inside an existing CI workflow (the rollup exclusion is per-run). The calling job needs `contents: read` + `issues: write` + `pull-requests: write` + `checks: read` + `actions: read` + `statuses: read`; GitHub rejects a shorter grant at startup (a reusable workflow can only narrow the caller's token, never elevate it), so a caller enrolled from an older copy of this row fails before any step runs. Both writes are the ONE label: repo-side label creation on first use maps to `issues`, and labeling a PR maps to `pull-requests` (the labels endpoint is dual-mapped by what the "issue" is, so `issues: write` alone 403s on a PR). `actions: read` is for the rollup's `CheckRun -> checkSuite -> workflowRun` self-exclusion hop. No secrets. | | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Medium — The updated row contradicts itself in one sentence: a manual |
||
| | [`stale.yml`](.github/workflows/stale.yml) | Stale-PR sweeper (`actions/stale`) plus a Slack digest of what it touched. PRs inactive for N days are labeled `stale`; still-inactive PRs are closed. The digest header names the source repo so batches from different repos posted to the same channel are unambiguous. Thresholds, messages, exempt labels, and the Slack channel are inputs; the caller owns the schedule + dry-run toggle. The calling job needs `pull-requests: write` and `issues: write`. Optional `SLACK_BOT_TOKEN`. | | ||
| | [`groom.yml`](.github/workflows/groom.yml) | Scheduled/dispatch org-wide **code-cleanup sweep** (finds only — no commits, no PRs, never merges). A read-only FINDER agent scans a clean default-branch checkout (whole-repo, not a diff) for high-value refactors; an INDEPENDENT VERIFIER agent (fresh session) re-checks each as CONFIRM/DOWNGRADE/REJECT with a stable dedup signature; survivors are deduped against a durable GitHub-issue-state ledger and filed as `groom`-labeled GitHub issues (security-adjacent ones get `groom-security` — investigate, don't auto-implement). Mirrors the cursor-review topology: briefs + ledger live in [`.github/groom/`](.github/groom) as the single source of truth. The finder/verifier/builder agent jobs invoke the Claude CLI directly and mint no GitHub token, so they need nothing beyond `contents: read`; filing runs in a separate job as the bot you configure via `bot_app_id` (Comfy: cloud-code-bot). `dry_run` reports what it would file without opening issues. Runs on a **daily base cron** with a runtime cadence gate: set repo Actions variable `GROOM_INTERVAL_DAYS` (default 7 = weekly) to retune how often a real run happens — weekly → every-3-days → daily — with no workflow-file edit; a tick within the interval no-ops before the finder (`workflow_dispatch` bypasses the interval gate, but the volume gate — when the caller leaves it on — still applies). The calling job must grant `contents: read` + `issues: write` + `pull-requests: read` + `actions: read` — the first three are declared by the `file` / `build_select` jobs (needed even with `bot_app_id` set), and the interval gate needs `actions: read` (reads run history for the last real run); GitHub rejects a shorter grant at startup. Requires `ANTHROPIC_API_KEY` (+ `BOT_APP_PRIVATE_KEY` when `bot_app_id` is set). **Opt-in auto-builder** (`builder: true`, BE-4003): the top `max_prs` (default 5) CONFIRMED, non-security findings become **review-gated PRs** (full CI + cursor-review, **never auto-merged**) instead of issues; a credential-free `build` job emits only a patch artifact and a separate `build_pr` job opens the PR as the bot, preserving the security boundary. The ledger's PR-state (open/merged/closed) stops a built finding being re-proposed. Requires `bot_app_id`. `max_prs` is typed **`string`**, not `number`, so a caller can forward its own `workflow_dispatch` input straight through (`max_prs: ${{ github.event.inputs.max_prs \|\| '1' }}`) and let an operator raise the ceiling for one manual run — no `fromJSON()` cast in the caller, and the parse/clamp (empty → default, non-numeric → 0 PRs + warning, never a failed run) happens once inside the reusable. | | ||
| | [`agents-md-integrity.yml`](.github/workflows/agents-md-integrity.yml) | Enforces the Comfy `AGENTS.md` standard on the caller repo: a top-level `AGENTS.md` must exist and stay under a hard line ceiling (`max_lines`, default 200; warns over `warn_lines`, default 150), a `CLAUDE.md` (if present) must be a thin `@AGENTS.md` shim rather than a divergent copy, no legacy `.cursorrules` (gated `forbid_cursorrules`), every nested monorepo `AGENTS.md` needs a sibling `@AGENTS.md` shim and to be under the ceiling (gated `check_nested`), and `AGENTS.md` should have a CODEOWNERS DRI (`require_codeowners`, warn-only by default). Fails with a non-zero exit + GitHub annotations so it wires in as a required status check. The checker lives in [`.github/agents-md-integrity/`](.github/agents-md-integrity) (pin `workflows_ref` to the same ref as `uses:`); no secrets required. | | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -61,15 +61,39 @@ fi | |
| echo "pr-risk enabled=$enabled (decided by ${decided_by})" | ||
|
|
||
| if [ "$enabled" != true ] && [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then | ||
| # shellcheck disable=SC2016 # the body is literal markdown; backticks are code spans | ||
| { | ||
| 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." | ||
| 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.' | ||
| } >> "$GITHUB_STEP_SUMMARY" | ||
| # A manual dispatch grades even while disabled (see the `grade` job's `if:`), so the summary | ||
| # MUST distinguish the two. Claiming "no grade was computed" on a run that is about to compute | ||
| # one is worse than saying nothing: it is the one line a reader would trust to tell them | ||
| # whether their label is current. | ||
| if [ "${GITHUB_EVENT_NAME:-}" = workflow_dispatch ]; then | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Medium — |
||
| # shellcheck disable=SC2016 # the body is literal markdown; backticks are code spans | ||
| { | ||
| echo "## Automatic PR risk grading is OFF for this repository" | ||
| echo | ||
| echo "This MANUAL run grades anyway and will sync a \`risk:*\` label — \`enabled\` governs" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Medium — This gate step runs before |
||
| echo "the \`pull_request\` stream, not a dispatch anyone with write access chose to run." | ||
| echo | ||
| echo "So the label this run leaves will NOT be kept up to date by pushes: until grading is" | ||
| echo "switched on, it reflects this moment only and every later commit will go ungraded." | ||
| echo | ||
| echo "Decided by ${decided_by}. To grade automatically from here on, set the repository" | ||
| echo 'variable `RISK_CONFIG` to `{"enabled": true}` (no PR needed), or pass' | ||
| echo '`enabled: true` in the caller'"'"'s `with:` block.' | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟢 Low — "or pass |
||
| } >> "$GITHUB_STEP_SUMMARY" | ||
| else | ||
| # shellcheck disable=SC2016 # the body is literal markdown; backticks are code spans | ||
| { | ||
| 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." | ||
| 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.' | ||
| echo | ||
| echo 'To grade a single PR right now WITHOUT switching the repo on, run this workflow from' | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟢 Low — The new escape-hatch advice assumes the consumer's caller declares a |
||
| echo 'the Actions tab with `pr_number` set — a manual dispatch is not gated by this switch.' | ||
| } >> "$GITHUB_STEP_SUMMARY" | ||
| fi | ||
| fi | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -29,10 +29,10 @@ ok() { PASS=$((PASS+1)); printf 'ok %s\n' "$1"; } | |
| bad() { FAIL=$((FAIL+1)); printf 'FAIL %s\n got: %s\n' "$1" "${2:-}"; } | ||
| eq() { if [ "$2" = "$3" ]; then ok "$1"; else bad "$1 (expected '$2')" "$3"; fi } | ||
|
|
||
| # run <input_enabled> <risk_config> -> sets OUT (resolved value), ERR (stderr), SUMMARY (body) | ||
| # run <input_enabled> <risk_config> [event] -> sets OUT (resolved), ERR (stderr), SUMMARY (body) | ||
| run() { | ||
| : > "$SANDBOX/out"; : > "$SANDBOX/summary" | ||
| ERR="$(INPUT_ENABLED="$1" RISK_CONFIG="$2" \ | ||
| ERR="$(INPUT_ENABLED="$1" RISK_CONFIG="$2" GITHUB_EVENT_NAME="${3:-pull_request}" \ | ||
| GITHUB_OUTPUT="$SANDBOX/out" GITHUB_STEP_SUMMARY="$SANDBOX/summary" \ | ||
| bash "$SCRIPT" 2>&1 >/dev/null)" | ||
| OUT="$(sed -n 's/^enabled=//p' "$SANDBOX/out")" | ||
|
|
@@ -90,7 +90,40 @@ case "$SUMMARY" in *RISK_CONFIG*) ok "and names the switch" ;; *) bad "and names | |
| run true "" | ||
| eq "an enabled run writes no summary" "" "$SUMMARY" | ||
|
|
||
| echo "— phase 7: it is runnable outside Actions (neither env file set) —" | ||
| echo "— phase 7: a DISABLED dispatch must not claim nothing was graded —" | ||
| # A manual dispatch grades even while disabled (the `grade` job's `if:` lets it through), so the | ||
| # two summaries have to say different things. "No grade was computed" on a run that is about to | ||
| # compute one is the single most misleading line this file could emit: it is exactly the | ||
| # sentence a reader would trust to decide whether the label on their PR is current. | ||
| run false "" workflow_dispatch | ||
| case "$SUMMARY" in | ||
| *"No grade was computed"*) bad "a disabled dispatch does NOT claim nothing was graded" "$SUMMARY" ;; | ||
| *) ok "a disabled dispatch does NOT claim nothing was graded" ;; | ||
| esac | ||
| case "$SUMMARY" in | ||
| *"This MANUAL run grades anyway"*) ok "it says the manual run will grade" ;; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟢 Low — Phase 7 asserts the summary claims a manual run grades, but nothing tests the |
||
| *) bad "it says the manual run will grade" "$SUMMARY" ;; | ||
| esac | ||
| # And it must warn that the label it leaves goes stale, since pushes will not re-grade it. | ||
| case "$SUMMARY" in | ||
| *"every later commit will go ungraded"*) ok "and that the label will not stay current" ;; | ||
| *) bad "and that the label will not stay current" "$SUMMARY" ;; | ||
| esac | ||
| # The non-dispatch summary keeps its own claim, and points at the dispatch escape hatch. | ||
| run false "" pull_request | ||
| case "$SUMMARY" in | ||
| *"No grade was computed"*) ok "a disabled pull_request still reports nothing graded" ;; | ||
| *) bad "a disabled pull_request still reports nothing graded" "$SUMMARY" ;; | ||
| esac | ||
| case "$SUMMARY" in | ||
| *"not gated by this switch"*) ok "and offers the manual route" ;; | ||
| *) bad "and offers the manual route" "$SUMMARY" ;; | ||
| esac | ||
| # An ENABLED dispatch writes no summary at all — the grade job owns the output from there. | ||
| run true "" workflow_dispatch | ||
| eq "an enabled dispatch writes no summary" "" "$SUMMARY" | ||
|
|
||
| echo "— phase 8: it is runnable outside Actions (neither env file set) —" | ||
| out="$(INPUT_ENABLED=true RISK_CONFIG='' bash "$SCRIPT" 2>/dev/null)" | ||
| case "$out" in *"enabled=true"*) ok "reports to stdout with no GITHUB_OUTPUT" ;; *) bad "reports to stdout with no GITHUB_OUTPUT" "$out" ;; esac | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟠 High — The exemption is keyed on the event alone, but
workflow_dispatchis not necessarily a human act: any principal withactions: writecan fire it viagh workflow runor the REST endpoint, and since this file ison: workflow_callonly,github.event_namereports the CALLER's event — a dispatch aimed at something else still satisfies the clause. That turns{"enabled": false}into a mute rather than a kill switch for the privilegedgradejob (pull-requests: write+issues: write), which can then relabel up toMAX_TARGETS=50PRs per run. Gate on an explicit signal instead (e.g. a dedicatedmanual_gradeinput the caller must forward, or requiring a non-emptypr_number/pr_numbers). Raised by 4 of 8 reviewers (claude-opus-5-thinking-max adversarial, claude-opus-5-thinking-max edge-case, gpt-5.6-sol-max edge-case, kimi-k2.7-code adversarial).