-
Notifications
You must be signed in to change notification settings - Fork 0
feat(pr-risk): advisory PR risk grader + label-only shadow check (reusable) #111
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
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| # CONTRACT: this is one of THIS repo's own path-filtered script tests, NOT a reusable | ||
| # workflow. It is not callable and has no caller pattern: | ||
| # TRIGGERS — `pull_request` and `push` to main, both filtered to scripts/pr-risk/** | ||
| # and this file. A change elsewhere in the repo does not run it. | ||
| # INPUTS — none (no `workflow_call`, no `workflow_dispatch`). | ||
| # SECRETS — none. Read-only `contents: read`; the suites are hermetic and make no | ||
| # network call (the live-PR path stubs `gh` on PATH with a fixture). | ||
| # The reusable workflow these scripts back is .github/workflows/pr-risk.yml — that is | ||
| # the file with the inputs/secrets/caller-pattern header. | ||
|
|
||
| name: Test pr-risk scripts | ||
|
|
||
| # Runs the hermetic test suites (+ shellcheck) for the grader and label-sync | ||
| # scripts behind pr-risk.yml. The grader stamps an advisory risk tier on every | ||
| # consumer repo's PRs, so a grading regression silently mislabels PRs org-wide | ||
| # — cheap to guard with a unit run on change. No network: the suites feed the | ||
| # grader synthetic records and stub `gh` for the live-PR path. | ||
|
|
||
| on: | ||
| pull_request: | ||
| paths: | ||
| - 'scripts/pr-risk/**' | ||
| - '.github/workflows/test-pr-risk.yml' | ||
| push: | ||
| branches: [main] | ||
| paths: | ||
| - 'scripts/pr-risk/**' | ||
| - '.github/workflows/test-pr-risk.yml' | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| jobs: | ||
| test: | ||
| name: shellcheck + suites | ||
| runs-on: ubuntu-latest | ||
| defaults: | ||
| run: | ||
| working-directory: scripts/pr-risk | ||
| steps: | ||
| - name: Checkout | ||
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | ||
| with: | ||
| persist-credentials: false | ||
|
|
||
| - name: shellcheck | ||
| run: shellcheck -x grade-pr-risk.sh apply-risk-label.sh tests/test_grade_pr_risk.sh tests/test_apply_risk_label.sh | ||
|
|
||
| - name: default map + registry parse and validate | ||
| # The shipped defaults must pass the grader's own structural validation: | ||
| # a malformed default would fail every consumer at once. | ||
| run: | | ||
| bash -c 'source ./grade-pr-risk.sh; read_map risk-map.v0.json map >/dev/null && read_map runbook-registry.v0.json runbooks >/dev/null' | ||
| echo "defaults validate" | ||
|
|
||
| - name: grader suite | ||
| run: bash tests/test_grade_pr_risk.sh | ||
|
|
||
| - name: label suite | ||
| run: bash tests/test_apply_risk_label.sh | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,130 @@ | ||
| # pr-risk — advisory PR risk grading (the shadow check) | ||
|
|
||
| The scripts behind [`pr-risk.yml`](../../.github/workflows/pr-risk.yml). Every PR | ||
| event is graded into a tier and gets ONE label: | ||
|
|
||
| | tier | label (default) | meaning | eventual routing (later phases — nothing routes today) | | ||
| |---|---|---|---| | ||
| | R0 | `risk:R0` | inert — docs, tests, provably-shaped runbook output | auto-merge candidate | | ||
| | R1 | `risk:R1` | contained — bounded, covered, revertable in one click | rubber-stamp | | ||
| | R2 | `risk:R2` | standard — ordinary product code | normal review | | ||
| | R3 | `risk:R3` | elevated — auth, billing, migrations, IaC, CI, deps, secrets | owner + e2e | | ||
| | — | `risk:ungraded` | an input could not be read; deliberately NOT a tier | human review | | ||
|
|
||
| **The label is the entire product.** Nothing is gated, blocked, routed, commented | ||
| on, or merged. Humans glance at the label and either agree or disagree. | ||
| Disagree by adding the `risk-dispute` label (never touched by the grader) plus a | ||
| comment saying why — disputes are the pilot's calibration data. | ||
|
|
||
| ## How a grade is computed | ||
|
|
||
| `grade = worst(path_floor, provenance, reversibility)` — three deterministic | ||
| axes, worst wins, so no axis can move a PR into a safer lane than another axis | ||
| put it. No LLM anywhere; the whole thing is `gh` + `jq` over the PR's API | ||
| record. | ||
|
|
||
| 1. **Path floor** — [`risk-map.v0.json`](risk-map.v0.json): versioned path-glob | ||
| rules. The floor is the worst tier over every rule any changed path matches, | ||
| so a docs file can never cancel a migration in the same PR. Matching covers | ||
| every path the diff touches, **destination and origin** — a renamed file is | ||
| graded under its previous path too, so `git mv auth/x.go misc/x.go` cannot | ||
| walk a file out of the rule that guards it. In globs `**` crosses `/` and `*` | ||
| does not, and matching is whole-path anchored: a rule without a leading `**/` | ||
| matches root-level files ONLY. | ||
| 2. **Provenance** — what PROCESS produced the diff: `runbook` (a registered | ||
| producer in [`runbook-registry.v0.json`](runbook-registry.v0.json) whose | ||
| identity AND diff shape both assert), `agent-supervised`, `human`, or | ||
| `external` (fork / first-time contributor — R3, no exceptions, even when a | ||
| runbook shape matches). Identity is the server-attributed author login, | ||
| never the forgeable commit author string. | ||
| 3. **Reversibility** — mutates persistent state or deletes data → R3; **removes** | ||
| a file under a sensitive class → R3 (a delete, or a rename out of that class); | ||
| no green check rollup → R2; green but no test file touched → R1; green with | ||
| tests touched → R0. "Green" means at least one check actually CONCLUDED | ||
| success: a rollup of nothing but skipped/neutral answers "did tests covering | ||
| these lines run?" with nothing, so it cannot drop the axis below R2. What | ||
| counts as a test file is `reversibility.test_path_patterns` in the map (omit | ||
| the key and the grader falls back to a built-in regex that only knows the | ||
| Go/TS shapes). | ||
|
|
||
| Anything unreadable grades `unknown` (labeled `risk:ungraded`), never a | ||
| confident tier, and never "the axes that did resolve" — a PR whose file list we | ||
| could not read is exactly the PR that might touch auth. | ||
|
|
||
| Two CI-specific mechanics worth knowing: | ||
|
|
||
| - **The grading run excludes itself from the check rollup it reads** (its own | ||
| check is always in-flight at grade time), and the job re-polls until the rest | ||
| of the rollup settles or `wait_for_checks_minutes` runs out — otherwise every | ||
| live grade would floor at R2 as an artifact of the measurement. Exclusion is | ||
| keyed on `github.run_id` (`--self-run-id`), and a **FAILING check is never | ||
| excluded**: self-exclusion may only ever hide our own pending run, never a red | ||
| one. Enroll pr-risk as its **own workflow** rather than a job inside an | ||
| existing CI workflow — a job sharing a run with the rest of CI excludes its | ||
| siblings too, and lands on the honest R2 floor instead of a full rollup. | ||
| - **The label is applied with the plain `GITHUB_TOKEN`**, which cannot fire | ||
| `labeled` triggers — the shadow check is structurally unable to start a | ||
| workflow cascade. Later phases that WANT label-triggered routing switch to an | ||
| app token deliberately. | ||
|
|
||
| ## Per-repo overrides (read from the base ref) | ||
|
|
||
| The shipped map and registry are deliberately generic. A consumer repo sharpens | ||
| them by committing: | ||
|
|
||
| - `.github/risk.json` — the repo's own path→tier map (same schema as | ||
| [`risk-map.v0.json`](risk-map.v0.json)) | ||
| - `.github/risk-runbooks.json` — the repo's own producer registry (same schema | ||
| as [`runbook-registry.v0.json`](runbook-registry.v0.json)) | ||
|
|
||
| Both are read from the PR's **base ref**, so a PR cannot edit the rules that | ||
| judge it (editing them — or the grader — at all is R3 by the map's own first | ||
| rule). A genuine 404 falls back to the shipped defaults; a present-but-invalid | ||
| file fails the run loudly rather than silently grading generic, and so does any | ||
| non-404 read failure (a 403 rate-limit or 5xx must not quietly demote the PR to | ||
| the generic map, which would be a lower tier computed from an input nobody read). | ||
|
|
||
| A map must MAP every provenance class (`runbook`, `agent-supervised`, `human`, | ||
| `external`). Omitting one is refused at load time rather than filled in with a | ||
| tier nobody chose — that is how a map that forgot `external` used to grade a fork | ||
| the same as a teammate. | ||
|
|
||
| Every graded record carries `map_version` + `registry_version`, so grades made | ||
| under different maps stay comparable and a map revision can be replayed against | ||
| accumulated records. | ||
|
|
||
| ## Relabeling (R0–R3 vs R1–R4 and friends) | ||
|
|
||
| Tier SEMANTICS are fixed (R0 safest .. R3 riskiest, `unknown` separate) | ||
| everywhere records are stored. The label TEXT is the caller's, via `label_map`: | ||
|
|
||
| ```yaml | ||
| with: | ||
| label_map: "R0=risk:R1,R1=risk:R2,R2=risk:R3,R3=risk:R4,unknown=risk:ungraded" | ||
| ``` | ||
|
|
||
| Labels are created on first use, color-coded green → red (gray for ungraded). | ||
|
|
||
| ## Files | ||
|
|
||
| - `grade-pr-risk.sh` — the grader. `--pr N --repo o/r` grades a live PR; | ||
| `--stdin` grades synthetic records (the no-network test surface). Extracted | ||
| from the fleet's offline corpus grader (BE-5507); the identity jq is inlined | ||
| from its collector (BE-5030) — keep the two in sync when either changes. The | ||
| changed-file list comes from REST `pulls/{n}/files`, not the GraphQL `files` | ||
| connection: GraphQL has no previous-path field (so renames are invisible) and | ||
| its connection capped the list at 100, which put exactly the PRs a risk grade | ||
| helps most in the ungraded lane. A read that comes back short of `changedFiles` | ||
| is still `unknown`. | ||
| - `apply-risk-label.sh` — the one write. Owns exactly the five mapped labels: | ||
| removes stale ones, applies the computed one, touches nothing else. | ||
| - `risk-map.v0.json` / `runbook-registry.v0.json` — the generic defaults. | ||
| - `tests/` — hermetic suites (synthetic records + a stubbed `gh`); run via | ||
| [`test-pr-risk.yml`](../../.github/workflows/test-pr-risk.yml). | ||
|
|
||
| ## What is deliberately NOT here | ||
|
|
||
| No auto-merge, no routing, no required check, no PR comment, no LLM judgement, | ||
| no linked-ticket requirement. Those are later rungs of the ladder and each one | ||
| is its own explicit switch — this workflow exists to accumulate the | ||
| agree/disagree evidence that decides whether any of them turn on. |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.