Repository navigation
Require fix pull requests to change a test and name their reproducer - #2325
Merged
Merged
Conversation
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
marked this pull request as ready for review
October 12, 2026 08:51
Collaborator
Author
|
Hand-off to the UK hub (W2, Autumn Budget CI plan)
🤖 Generated with Claude Code |
Collaborator
Author
|
UK hub landing audit for #2325, head
|
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
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
bugorbug-fixlabel, or that add, modify or type-change a fragment directly inchangelog.d/withfixedin 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:
test_*.pyor*_test.pymodule underpolicyengine_uk/tests/, or a YAML policy test (.yamlor.yml) underpolicyengine_uk/tests/policy/. Deleted files don't count.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:file.py::testorfile.py::TestClass::test, ignoring any[param]suffix. It must name a test pytest collects and runs:Test*class;objectorunittest.TestCase(also as a bareTestCasewhose last binding before the class isfrom unittest import TestCase) and has no__init__or__new__;__test__other thanTrue, and no fixture;skiporxfailmark, or unittestskiporexpectedFailure, on the test, its class or its module.skipifis 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.file.yaml::case name. The named entry, or any entry for a bare file, has a stringnameand a non-emptyoutputmapping.n/a: <reason>, with a non-empty reason. It's accepted only when no file underpolicyengine_uk/outside the tests is added, modified, type-changed or deleted, and neitherpyproject.tomlnoruv.lockchanges.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:refandpersist-credentials: false.RUNNER_TEMP.-z --no-renames.uv run --no-project --with pyyaml==6.0.2.Merge queue: the check also runs on
merge_groupand 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.mddescribes 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.bug/bug-fixlabel, classifies a PR as a fix (property).Reproducer:parser (property).__test__,__new__, inherited constructors andname.__test__. Collected tests marked to skip or expected to fail are rejected, an intended difference.n/ais accepted exactly when the reason is non-empty and no model source outside the tests or dependency file changed (property).pull_request_target, keeps its files underRUNNER_TEMP, keeps no token in the checkout, and reports on merge groups, with no path filter.Verified
outputmapping.Reviews
All in
~/reviews/budget-ci-w2/, by GPT-6.1 Sol:review-2325-2353d1710.md, REQUEST_CHANGES with 6 blocking findings, fixed in 9e378d9.review-2325-4ddff4c59.md, APPROVE with 4 non-blocking findings, closed in 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).review-2325d-2a6fc1b48.md, APPROVE; its non-blocking items 2, 5, 7 and 8 closed in 354a1a7 (a relative or shadowedTestCase,pytestmarkarguments, a unittest class in the pytest oracle, docs).review-2325e-354a1a7ed.md, APPROVE. Its finding 2 was a gap 354a1a7 opened (an aliased mark such asm.skipinpytestmark). 3de2f81 adopts its prototype fix for that and for theTestCasebinding edges, with seven regression cases.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 insidepytestmark, a localTestCasein a helper, match captures) that need deliberate effort, which the docs now exclude.Effect on open PRs
pull_request_targetalways 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 aReproducer: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