Skip to content

Require fix pull requests to change a test and name their reproducer - #2325

Merged
MaxGhenis merged 7 commits into
mainfrom
fix-pr-evidence-check
Oct 12, 2026
Merged

MaxGhenis merged 7 commits into
mainfrom
fix-pr-evidence-check

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 11, 2026 •

Copy link
Copy Markdown
Collaborator

Part of workstream W2 of the Autumn Budget CI plan (deliverable 4: a fix-PR inclusion check).

Why

The model keeps changing up to Budget day (28 October). Every fix that lands should carry a test that reproduces the bug, so a reviewer can run it and the bug can't come back silently. Nothing checks that today. The audit found merged fix #1915, whose old YAML test sat outside the collected test tree, so it never protected anything.

What

A Fix evidence check (.github/workflows/pr_fix_evidence.yaml, .github/check_fix_pr.py).

Which PRs are fix PRs: those with the bug or bug-fix label, or that add, modify or type-change a fragment directly in changelog.d/ with fixed in any dot-separated part of its name after the first, as towncrier reads it. Other PRs pass, with a one-line note.

A fix PR must:

  1. Add, modify or type-change an executable test. That is a test_*.py or *_test.py module under policyengine_uk/tests/, or a YAML policy test (.yaml or .yml) under policyengine_uk/tests/policy/. Deleted files don't count.
  2. Name its reproducer on a Reproducer: line in the description. Several ids can be separated by commas or semicolons, and backticks are optional. Lines inside HTML comments, such as the template's guidance, are ignored. The id must be one of:
    • A pytest node id in a changed file: file.py::test or file.py::TestClass::test, ignoring any [param] suffix. It must name a test pytest collects and runs:
      • a module-level function, or a method of a module-level Test* class;
      • the class derives only from object or unittest.TestCase (also as a bare TestCase whose last binding before the class is from unittest import TestCase) and has no __init__ or __new__;
      • no __test__ other than True, and no fixture;
      • no skip or xfail mark, or unittest skip or expectedFailure, on the test, its class or its module. skipif is allowed, because its condition is usually a platform check that holds false in CI. A test that skips itself at run time (pytest.skip) can't be seen statically; the docs say not to.
    • A changed YAML test, optionally file.yaml::case name. The named entry, or any entry for a bare file, has a string name and a non-empty output mapping.
    • n/a: <reason>, with a non-empty reason. It's accepted only when no file under policyengine_uk/ outside the tests is added, modified, type-changed or deleted, and neither pyproject.toml nor uv.lock changes.

Reporting: every failed requirement is reported, with the accepted formats, in the log and the job summary. The CLI exits 0 for pass, 1 for fail and 2 for unusable input.

Workflow safety: it runs on pull_request_target, so the checker is always the trusted base-branch copy. The job never executes, installs or imports PR code:

  • It checks out the base branch with no ref and persist-credentials: false.
  • The PR title, body and labels reach the script only through environment variables. They are written to a fresh directory under RUNNER_TEMP.
  • It fetches the PR head as git objects and diffs it against the merge base with -z --no-renames.
  • It runs the base checker with uv run --no-project --with pyyaml==6.0.2.
  • It reads test files from git objects at the head SHA and rejects symlinks and gitlinks.
  • YAML validation fails closed without PyYAML.

Merge queue: the check also runs on merge_group and passes there, so a branch rule can require it under a merge queue. Each PR's evidence was checked before it joined the queue.

Docs: the PR template gains a Reproducer section, and docs/engineering/skills/github-prs.md describes the rules.

Out of scope for now, per the plan: running the reproducer against the old code to show it failed before the fix.

Invariants

Tested in policyengine_uk/tests/code_health/test_check_fix_pr.py: 167 tests, about 7 s. The check reads tests statically, so it catches mistakes, not deliberate evasion; reviewers still read the test.

  1. Exactly an added, modified or type-changed fixed fragment, or the bug/bug-fix label, classifies a PR as a fix (property).
  2. Deleted tests never count as changed tests or reproducers (property).
  3. Any list of ids, with any supported separators, spacing and backticks, round-trips through the Reproducer: parser (property).
  4. A pytest reproducer is accepted exactly when pytest collects the named test from the changed file. This is checked differentially against real pytest collection, including falsy __test__, __new__, inherited constructors and name.__test__. Collected tests marked to skip or expected to fail are rejected, an intended difference.
  5. n/a is accepted exactly when the reason is non-empty and no model source outside the tests or dependency file changed (property).
  6. A non-fix PR always passes, whatever its body (property).
  7. The CLI returns 0, 1 or 2 for pass, fail or unusable input, and appends to the job summary without replacing it.
  8. NUL-delimited git diffs keep literal paths. Git-object reads check committed content without following symlinks or running PR code, and the standalone script follows the same rules.
  9. The workflow runs the trusted base checker on pull_request_target, keeps its files under RUNNER_TEMP, keeps no token in the checkout, and reports on merge groups, with no path filter.
  10. YAML reproducers need parsed list entries with a string name and a non-empty output mapping. A missing PyYAML fails closed.
  11. Reproducer lines inside HTML comments never count as evidence.

Verified

Reviews

All in ~/reviews/budget-ci-w2/, by GPT-6.1 Sol:

  • 2353d17: review-2325-2353d1710.md, REQUEST_CHANGES with 6 blocking findings, fixed in 9e378d9.
  • 4ddff4c: review-2325-4ddff4c59.md, APPROVE with 4 non-blocking findings, closed in 11059ed.
  • 11059ed: review-2325c-out.md, APPROVE with 3 non-blocking findings; 5 and 6 closed in 2a6fc1b (item 7, the conservative edge cases, fail closed and none occur in the repository).
  • 2a6fc1b: Opus 5.5, review-2325d-2a6fc1b48.md, APPROVE; its non-blocking items 2, 5, 7 and 8 closed in 354a1a7 (a relative or shadowed TestCase, pytestmark arguments, a unittest class in the pytest oracle, docs).
  • 354a1a7: Opus 5.5, review-2325e-354a1a7ed.md, APPROVE. Its finding 2 was a gap 354a1a7 opened (an aliased mark such as m.skip in pytestmark). 3de2f81 adopts its prototype fix for that and for the TestCase binding edges, with seven regression cases.
  • 3de2f81: Opus 5.5 confirmation, review-2325f-3de2f81a0.md, APPROVE. The checker matches the prototype, the seven cases fail on 354a1a7 and pass here, and the sweep is unchanged. Its remaining notes are contrived edges (calls inside pytestmark, a local TestCase in a helper, match captures) that need deliberate effort, which the docs now exclude.

Effect on open PRs

pull_request_target always runs the base branch's workflow, so this check doesn't run on this PR, and it starts reporting only after merge. PRs already open get it on their next push, edit or label change. Open fix PRs without a Reproducer: line will then show it red. It is not a required check until the coordinating session adds it to the branch rules (decision d1368). Those PRs should be re-triggered first, or they'd sit at "Expected — waiting".

Reproducer: n/a: this PR adds a check, it does not fix a bug

axiom: n/a: CI tooling

🤖 Generated with Claude Code

MaxGhenis and others added 7 commits October 11, 2026 12:49
A pull request is a fix when it adds or modifies a changelog.d/*.fixed.md
fragment or carries the bug or bug-fix label. The new Fix evidence check
fails such a pull request unless it adds or modifies an executable test
(a pytest module under policyengine_uk/tests/ or a YAML policy test) and
its description names the reproducer on a 'Reproducer:' line: a pytest
node id or YAML test that exists in a changed test file. A CI- or
docs-only fix can write 'Reproducer: n/a: <reason>' when it changes no
model source. The check does not yet run the reproducer against the old
code.

The workflow uses pull_request with the PR text passed through the
environment, and also reports on merge queue groups so a branch rule can
require it. The PR template gains the Reproducer line, and
docs/engineering/skills/github-prs.md describes the check.

Built on a GPT-6.1 Sol lane from W2's specification; reviewed and
completed in session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Run the checker from the base branch under pull_request_target and read
  the pull request's files as git objects at its head SHA, never from a
  checkout: a pull request can no longer replace the checker that judges
  it, and a symlink cannot redirect a read or overwrite a file. Metadata
  goes to a fresh directory under RUNNER_TEMP. The job executes, installs
  and imports nothing from the pull request.
- Classify a fragment as a fix the way towncrier reads names: 'fixed' in
  any part after the first (x.fixed.1.md, x.fixed.md.md), with T (type
  change) counted as a change.
- A pytest reproducer must be something pytest collects: a test* function,
  or a test* method of a Test* class without __init__, outside a module or
  class that sets __test__ = False.
- A YAML reproducer must parse (PyYAML) to a list with a test mapping
  that has a name and an output; the named entry must be such a mapping.
- 'n/a' is refused when pyproject.toml or uv.lock change, not only model
  source; HTML comments are ignored when reading Reproducer: lines; the
  change list is NUL-separated.

Built on a GPT-6.1 Sol lane from W2's design notes; checked in session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From the GPT-6.1 Sol review of 4ddff4c (APPROVE, N1-N4):

- N1: a named pytest reproducer must be a test pytest collects. A falsy
  __test__ of any value, an assigned name.__test__, a fixture, a class
  with __new__, and a class with any base but object or unittest.TestCase
  are rejected. The real-pytest oracle covers the new shapes.
- N2: the reproducer must run and pass, so a skip or xfail mark on it,
  its class or its module's pytestmark is rejected. skipif stays allowed:
  its condition is usually a platform check that holds false in CI. Over
  the repository's tests, the check accepts 1,006 and rejects only the
  two strict xfails.
- N3: the base checkout keeps no token (persist-credentials: false).
- N4: a YAML reproducer entry needs a non-empty output mapping, as all
  3,007 existing entries have.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From the GPT-6.1 Sol review of 11059ed (APPROVE, non-blocking 5 and 6):

- A bare TestCase base counts only when the module imports it from
  unittest; any other TestCase could bring its own constructor.
- unittest's skip and expectedFailure are rejected like pytest's skip and
  xfail marks; skipIf, like skipif, stays allowed.
- The docs say a reproducer must not skip itself at run time
  (pytest.skip, pytest.importorskip), which the check cannot see.
- The test module's invariants 4 and 10 now state the collection and
  non-empty output rules.

Correction to 11059ed's message: over the repository's tests the check
accepts 1,008 of 1,010 pytest tests (not 1,006), rejecting only the two
strict xfails, and all 340 YAML files. That is unchanged here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From the Opus review of 2a6fc1b (APPROVE, non-blocking 2, 5, 7, 8):

- A bare TestCase base counts as unittest's only when the module's last
  top-level binding of TestCase is an absolute import from unittest. A
  relative import or a later import or definition no longer passes.
- pytestmark contributes only names with a mark part, so an argument
  such as config.skip in pytest.mark.skipif(config.skip, ...) is not
  taken for a skip mark.
- The real-pytest oracle includes a unittest TestCase class.
- The docs paragraph is reflowed and names unittest's skipIf/skipUnless.

Each new case fails against 2a6fc1b's checker. Over the repository the
check still accepts 1,008 of 1,010 pytest tests and all 340 YAML files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From the Opus review of 354a1a7 (APPROVE; findings 2-5, 8), adopting
its prototype:

- pytestmark is walked without descending into call arguments, instead
  of requiring a mark part in each name. An aliased mark such as m.skip
  or m.xfail counts again (354a1a7 had let it through), and config.skip
  in pytest.mark.skipif(config.skip, ...) still does not.
- A bare TestCase base is unittest's only when its last binding before
  the class is an absolute import from unittest. Any star import or any
  other binding of TestCase, including inside a block or a tuple
  assignment, resets it; a rebinding after the class no longer matters.
- The docs say the check reads tests statically: it catches mistakes, not
  deliberate evasion, and reviewers still read the test.

The seven new cases fail against 354a1a7's checker and pass here. Over
the repository the check still accepts 1,008 of 1,010 pytest tests and
all 340 YAML files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis marked this pull request as ready for review October 12, 2026 08:51
@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

Hand-off to the UK hub (W2, Autumn Budget CI plan)

  • Head: 3de2f81.
  • Reviews: all in ~/reviews/budget-ci-w2/.
    • GPT-6.1 Sol: review-2325-2353d1710.md requested changes, fixed in 9e378d9. review-2325-4ddff4c59.md and review-2325c-out.md (at 11059ed) both approve.
    • Opus 5.5: review-2325d-2a6fc1b48.md and review-2325e-354a1a7ed.md approve, and review-2325f-3de2f81a0.md confirms the current head.
  • CI: all seven checks pass at 3de2f81.
  • Depends on: nothing.
  • After merge: the check runs on pull_request_target, so it can't run on this PR. It starts reporting only after merge, and PRs already open get it on their next push, edit or label change.
    • Re-trigger the open fix PRs before Fix evidence becomes a required check. It is one of the checks in d1368's list.
    • Open fix PRs without a Reproducer: line will show it red until they add one.

🤖 Generated with Claude Code

@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

UK hub landing audit for #2325, head 3de2f81a01611b829a2f87fbce5d868fa884e082.

  • Reviews:
    • GPT-6.1 Sol approved at 4ddff4c59 and 11059edbc, after requesting changes at 2353d1710, which were fixed.
    • Opus 5.5 approved later deltas and confirmed this head (~/reviews/budget-ci-w2/review-2325f-3de2f81a0.md).
  • Gates:
  • After merge:
    • Fix evidence runs on pull_request_target, so it starts on open PRs at their next push, edit or label change.
    • It is not a required check. Making it one is part of d1368 (Max). Before then, re-trigger the open fix PRs, because ones without a Reproducer: line will show red until they add one.

@MaxGhenis
MaxGhenis merged commit 3381500 into main Oct 12, 2026
7 checks passed
@MaxGhenis
MaxGhenis deleted the fix-pr-evidence-check branch October 12, 2026 08:54
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