Skip to content

Publish only the policyengine-uk release files whose checks passed - #2306

Draft
MaxGhenis wants to merge 25 commits into
mainfrom
release-identity
Draft

MaxGhenis wants to merge 25 commits into
mainfrom
release-identity

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 11, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #2269 (test sharding) and includes #2303 (stale #1968 xfail), so their commits show here until they merge. Part of workstream W2 of the Autumn Budget CI plan: no policyengine-uk release reaches PyPI unless the exact files published passed the required checks.

The defect

  • Publish raced the tests. Publish in versioning.yaml needed only Versioning and checked out moving main. On 11 October, Test for the merge 3fd35c1 started at 11:35:10Z (run 38136097762). Publish checked out the version-bump child 4d80b29 and uploaded 2.126.6 from 11:36:12Z (run 38136097710), while that Test was still running.
  • Versioning raced itself. It failed 20 times in two weeks at "Update changelog"; the sampled logs are non-fast-forward races, and there was no concurrency group.
  • The tag script was unchecked. It tagged whatever main was and ran git push --tags || true.
  • Main runs were cancelled or skipped. A newer commit cancelled the previous main run, so 83001d5 and cfa0c55 never finished a Test. The main push filter left out pyproject.toml, uv.lock and Makefile.

Change

Release identity (versioning.yaml, with prepare_release.py, release_artifacts.py and publish-git-tag.sh):

  • Versioning makes the version commit on the latest main, one run at a time, never forcing. It retries when main moves and fails on any other rejected push. It refuses a commit that changes more than the version line, the changelog and the consumed fragments. It counts fragments the way the tip's bump_version.py does, so it works before and after Fail pull requests that add changelog fragments towncrier drops #2324. A main whose tip is already a version commit has nothing to release.
  • Release build checks out that exact SHA and runs uv build once, with the build backend pinned (.github/build-constraints.txt). check-dist proves the wheel and sdist hold exactly the policyengine_uk/ files tracked at that SHA, byte for byte, with that version in their metadata. The files' provenance is attested.
  • Release checks run on those files:
    • Release lint;
    • Release install: the wheel with the newest dependencies on Python 3.11–3.14, and the sdist;
    • Release test: the full suite on the installed wheel, in the locked environment, with the dataset required. It removes the checkout's package and confirms the import resolves to the wheel.
  • Publish runs only if all of them passed, in the pypi environment, one run per version at a time. It re-verifies the hashes and check-dist, and pypi-state refuses if PyPI holds other bytes under the version. It then uploads the same files and waits until PyPI serves exactly them. Next it sends policyengine.py a policyengine-uk-release repository dispatch with the version (W4's certification; that step can't fail the release). Last, it tags that SHA: annotated, with the digests in the message, idempotent, and never moving a tag.
  • Recovery: "Re-run failed jobs" re-tests and publishes the same built files. A release-sha dispatch input re-releases an existing version commit after prepare_release.py --verify checks it.
  • Pull requests that change this machinery run Build and the release checks as a dry run.

One test suite (test-suite.yaml and the test-environment action), called by PR, merge-queue, main and release runs:

  • It runs in the locked environment (uv sync --frozen --extra dev), from the source tree or an installed wheel. uv.lock is refreshed to what CI and production install (policyengine-core 3.33.2, pandas 3.0.6, pytest 9.1.1); the old lock was stale.
  • It has four Python shards, and each shard logs its memory every 60 s.
  • The checks are Test / YAML, Test / Python K/4 and Test / Result.
  • Mandatory data (W3): merge-queue, main and release runs set POLICYENGINE_UK_REQUIRE_TEST_DATA=1. Test / Result fails if the dataset wasn't present. Pull request runs don't require it.

No silent skips:

Required checks become requirable:

  • pr_code_changes.yaml runs on every PR to main and on merge_group. A Changes job (changed_code.py) decides whether code changed. If none did, the tests skip and Test / Result passes without running, so a required check never hangs on a path filter. Lint runs on every PR, with ruff check (the same steps as Run ruff check in Lint, with F811 and F821 on for the package #2355).
  • Release checks are prefixed "Release" so they can't satisfy the PR checks.
  • With the repository variable RELEASE_PUSH_AS_APP=true, the org GitHub App pushes the version commit, so a ruleset can let that App bypass required checks.

Main push (code_changes.yaml):

  • It also runs on pyproject.toml, uv.lock, Makefile, conftest.py, .gitignore and README.md.
  • It has no concurrency group, so every commit on main finishes its run.
  • It skips version commits, which the release tests on the wheel.
  • It skips a commit that a successful merge-queue run already tested. A manual dispatch always tests.

Invariants

  1. Release identity: the files uploaded to PyPI have the sha256 the Build job recorded. Those are the files Release install and Release test installed, and check-dist proved they hold exactly the version commit's tracked package files. That commit is the one tagged. Covered by test_release_artifacts.py: property tests for any single changed byte, plus fault cases.
  2. No overwrite: Publish never uploads when PyPI has a file for the version that isn't ours, byte for byte. classify is checked differentially against an independent statement of the rule.
  3. History only grows: the version commit's single parent is the tip it was made on. A race re-versions on the new tip; any other push failure fails. Covered by test_prepare_release.py, using real git with a bare remote, with both bump_version.py versions.
  4. Every test ran once and passed, or a reviewed entry accepts its outcome. The checker's verdict equals an independent model of the rule on generated runs, and on real pytest runs with and without xdist (test_ci_test_shards.py).
  5. Every PR reports Test / Result: the PR workflow has no path filter, and the code patterns equal the main push filter (test_ci_test_shards.py).
  6. The dispatch to policyengine.py follows a confirmed upload and can't block the tag. It runs after PyPI serves the files, its steps have continue-on-error and a 2-minute timeout, and the token is scoped to policyengine.py with contents: write (test_ci_test_shards.py).

Verified

Reviews

All in ~/reviews/budget-ci-w2/:

  • GPT-6.1 Sol: review-2306-release-out.md and review-2306-suite-out.md; delta review-2306-delta-96e9cecae.md; final review-2306-final-0879f6cf6.md (APPROVE).
  • Opus 5.5: delta 0879f6c..496bfdb in review-2306-delta2-496bfdbec.md (APPROVE). Its non-blocking suggestions are applied in 6379d45.

Status

  • CI at 6379d45.
  • Split the CI Test job into shards and cut its runtime #2269 merges first. This PR then shows only its own commits.
  • Admin follow-up through the coordinating session (~/reviews/budget-ci-w2/admin-request.md, decision d1368):
    • a ruleset with the App as bypass actor, then RELEASE_PUSH_AS_APP;
    • a merge queue;
    • required checks Test / Result, Lint, Fix evidence and Changelog fragments;
    • limit the pypi environment to main and move PYPI into it, or use trusted publishing.
  • The dispatch reaches a workflow only once policyengine.py#574 (W4) merges; until then GitHub accepts it and nothing runs.

axiom: n/a: CI and release infrastructure

🤖 Generated with Claude Code

MaxGhenis and others added 8 commits October 11, 2026 10:59
The Test job took 59 minutes (run 38075384588): 2.6 minutes of YAML tests,
then 55.8 minutes of pytest on four balanced xdist workers, 220
worker-minutes in all. No test dominates: 51 property-test files take 74%
of it and the slowest test takes 3.5%. So the job is split rather than one
test fixed.

- `--shard K/N` (tests/conftest.py) keeps the tests whose node id hashes to
  shard K of N. The shard is a function of the node id alone, so every
  collected test is in exactly one shard, including tests added later.
- The workflows run the YAML tests in one job and the Python tests as a
  matrix of three shards, N taken from the size of the matrix.
- A final `Test` job runs .github/check_test_shards.py over the shards'
  JUnit files and an unsharded `pytest --collect-only -q` listing. It fails
  unless every collected test ran in exactly one shard and none failed.
- Coverage is measured with sys.monitoring (COVERAGE_CORE=sysmon) instead
  of the C trace function. On one property-test file the two report the
  same 12,262 of 18,401 lines, and sys.monitoring adds 6% to the CPU time
  where the trace function adds 33%.

Refs #2099

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…first

On 10 October the 5-second final Test job waited 32 minutes for an
ubuntu-latest runner after the shards finished (run 38085920460). It now
runs on ubuntu-24.04-arm like the shards. The job-result check runs
first, and the shard check still runs after a failure so its report
names the failed tests. Record the measured job times by the timeouts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… it to fail

#1968 (the model's 2023-24 Class 4 LPL was £11,908, not the £12,570 NICA
2022 s2 sets from 6 April 2023) was fixed by #2058 on 10 October. The
non-strict xfail kept for it now passes silently as an XPASS, so the
alignment test protected nothing for that year. Remove the marker so a
regression to £11,908 fails the suite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The release workflow's Publish job needed only Versioning and checked out
moving main, so on 11 October 2.126.6 reached PyPI while the merge's Test
job was still running (runs 38136097710 and 38136097762). Versioning failed
20 times in two weeks when two runs raced to push the version commit.

- versioning.yaml: make the version commit on the latest main (one run at
  a time), build the wheel and sdist once at that exact commit, check them
  against that commit's tree, run Lint, an install check on every
  supported Python and the full test suite on the installed wheel, and
  only then upload those same files, check PyPI serves them, and tag the
  commit. Pull requests that change this machinery run it as a dry run.
- test-suite.yaml: one reusable test suite for pull requests, main and
  releases, in the locked environment (uv sync --frozen), from the source
  tree or an installed wheel.
- check_test_shards.py now reads per-test outcome logs keyed by raw node id
  (pytest_outcome_log.py) instead of JUnit, and fails on any skip,
  expected failure, XPASS or skipped module that no reviewed entry in
  test_outcome_allowlist.toml accepts. xfail markers are strict.
- code_changes.yaml: run on pyproject.toml, uv.lock and Makefile changes.

The scripts the release jobs call (prepare_release.py, release_artifacts.py
and the new publish-git-tag.sh) follow in the next commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e, tag

- prepare_release.py makes the version commit on the latest main and
  pushes it, never forcing. When the push is rejected because main moved,
  it starts again from the new tip; any other rejection fails at once. It
  refuses a commit that changes more than the version line, CHANGELOG.md
  and the fragments it consumed. A main whose tip is already a version
  commit has nothing to release: a queued run used to cut an empty release
  because changelog.d always holds untyped files towncrier never consumes.
- release_artifacts.py check-dist proves the wheel and sdist hold exactly
  the policyengine_uk/ files tracked at the version commit, byte for byte,
  with that version in their metadata. pypi-state classifies what PyPI
  serves for the version as absent, partial, complete or conflict, so
  Publish never uploads over different bytes and can confirm PyPI serves
  exactly ours. A local rebuild of 6d89338 is 'complete' against PyPI's
  2.126.8.
- publish-git-tag.sh tags the given SHA (not HEAD) with an annotated tag,
  is a no-op on a re-run, never moves a tag, and fails on any error instead
  of 'git push --tags || true'.

Tests use real git repositories with a bare remote, a stand-in towncrier
and hand-built wheels and sdists; the classification and artifact checks
are property-tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pr_code_changes.yaml runs only when code, tests, workflows, dependencies
or the Makefile change. GitHub keeps a required check pending forever when
a path filter skips its workflow, so requiring "Test / Result" would block
a documentation-only pull request. pr_code_unchanged.yaml triggers on
exactly the other pull requests and reports "Lint" and "Test / Result" as
passes, through the suite's new skip input. A test keeps the two path lists
complementary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… workflow

The companion workflow on paths-ignore was not the complement of the
pull request workflow's paths: paths-ignore runs when any file falls
outside the list, so a pull request that changed code and documentation
ran both, and the companion's passing "Test / Result" could satisfy a
required check while the real one failed. Remove it.

pr_code_changes.yaml now runs on every pull request to main. A Changes
job lists the changed files (unquoted, renames as both paths) and
.github/changed_code.py says whether any is code, by the main push
filter's patterns, erring towards yes. With no code changed, the other
jobs are skipped and the suite's Result passes without running, so the
required checks still report. Tests keep the patterns equal to
code_changes.yaml's push paths and check the classifier against a
reference statement of a paths filter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts too

The sibling module repeated the 2023-24 lower profits limit as an open
defect (#1968); #2058 fixed it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis and others added 2 commits October 11, 2026 11:56
… bypass

Requiring status checks on main would also block the release workflow's
direct push of the version commit, so the pusher needs to be a ruleset
bypass actor. With the repository variable RELEASE_PUSH_AS_APP set to
true, Versioning checks out with the org GitHub App's token (from the
existing APP_ID and APP_PRIVATE_KEY secrets) and pushes as the App. An
admin can then add a ruleset with the App as bypass actor, set the
variable and activate the ruleset, without another code change. An App
push triggers workflows, so the main push tests skip version commits,
which the release workflow already tests on the wheel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main push runs were cancelled when a newer commit landed, so 83001d5 and
cfa0c55 never finished a Test on 11 October, and a conflict between
#2251 and #2270, each green alone, showed only at 7343f94. Remove the
main push workflow's concurrency group so every merged commit finishes
its run.

pr_code_changes.yaml also runs on merge_group, so a merge queue on main
can require the same checks and test each pull request merged onto the
latest main before it lands; the Changes job compares a group with the
main it merges onto. Main then moves only to a commit the queue tested,
so the main push workflow's new Tested job skips the suite for a commit
that already has a passing "Test / Result" check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…st check

uv.lock pinned policyengine-core 3.32.27, pandas 2.3.1, numpy 2.1.3 and
pytest 8.4.1, while CI installed the newest versions and so tested with
core 3.33.2, pandas 3.0.6, numpy 2.5.4 and pytest 9.1.1 (run 38149483980),
and policyengine.py, which runs the model in production, locks core 3.33.0
and pandas 3.0.5. Testing in the frozen environment from that lock would
have tested a dependency generation nobody runs. Refresh it with
uv lock --upgrade, so the locked environment is what CI tested until now.

On this PR's first run in the old lock's environment, two of the three
test shards lost their runners mid-run ("The runner has received a
shutdown signal", run 38153608009) while other pull requests' runs at the
same time did not. One property file's peak memory was the same in both
environments locally (0.80 and 0.81 GB), so the cause is not confirmed;
each shard now logs its memory every minute so a repeat would show it.

uv build writes a .gitignore into its output directory, which the
release check rightly refused as an extra file; remove it after building.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis added a commit that referenced this pull request Oct 11, 2026
A required check must also report on merge_group, or the queue waits on
it; diff a group against its base, as pr_code_changes.yaml does in #2306.

Refs #2314

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

Coordination note from #2324 (untyped changelog fragments, #2314).

#2324 deletes the 93 untyped files in changelog.d. It also changes .github/bump_version.py, which now:

  • counts only the fragments towncrier consumes;
  • warns about untyped files;
  • exits 1 when there is no typed fragment;
  • requires a [tool.towncrier] table in pyproject.toml.

It does not edit pr_code_changes.yaml or versioning.yaml, and git merge-tree with release-identity is clean.

On a local merge of both branches, six tests in test_prepare_release.py fail:

  • the seed pyproject.toml has no [tool.towncrier];
  • test_a_versioned_tip_means_no_release expects an untyped-only merge to be released;
  • prepare_release.find_fragments counts every file, so an untyped-only tip would call bump_version.py and fail instead of reporting nothing to release.

A patch for this branch is in ~/reviews/uk-changelog-fragment-check-2026-10-11/w2-prepare-release-typed-fragments.patch. With it, all 64 tests in the two files pass on the merge. It:

  • counts fragments with the tip's own bump_version.py;
  • has the fake towncrier consume only typed fragments;
  • adds the towncrier table to the seed pyproject.toml;
  • adds test_untyped_files_alone_mean_no_release.

🤖 Generated with Claude Code

Each pytest-xdist worker's memory grows with the tests it runs (#2330).
With three shards of four workers, the 16 GB ARM runners peaked at
15.6 GB used, 283 MB available, on this PR's second run (run 38156967136,
logged by the per-minute memory lines), and two shards of the first run
lost their runners mid-run. Four shards give each worker about a quarter
fewer tests, which also shortens the job. Replace the stale 'about 9 GB'
comment with the measured peak.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- check-dist checked only the first 50 sdist files outside policyengine_uk/,
  because the listing cap also capped the check (found by a partial GPT-6.1
  Sol review). Check them all; cap only the listing. A regression test
  puts a changed README after 60 other files.
- 'Re-run all jobs' re-ran Versioning, which found main ending in the
  version commit the first attempt pushed and released nothing: a green
  run with no release. On a re-run attempt, an untagged version commit at
  main's tip is now released.
- The release artifacts overwrite an earlier attempt's, so a re-run of
  Build can upload them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis and others added 9 commits October 11, 2026 17:05
The same Lint step as #2355, in the pull request, main and release
workflows this PR rewrites, so whichever of the two lands second keeps it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ruff formats and checks notebooks under docs/ and scripts/ as well as
the package, so a documentation-only pull request can land a violation
that then fails Lint for every later pull request: #1696 landed a
notebook that failed the format check, and #1743 fixed it. Lint takes
seconds, so run it on every pull request instead of only when the
Changes job finds code. Found by the independent review of #2355.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Suite half (request changes):
- A merge queue group always runs the tests. Its base_sha can be the
  group queued ahead rather than main, so diffing against it could call a
  code change 'no code' and pass Test / Result without running anything.
- Main's Tested job counts only a successful merge_group run of the pull
  request workflow, not any passing 'Test / Result' check on the commit,
  which a pull request run on the same head commit (perhaps without the
  dataset) could also have made.
- The suite skips only on an explicit code=false.
- New require-data input: main pushes, merge queue groups and releases
  fail Result instead of accepting microsimulation skips when the dataset
  credentials are missing.
- The outcome log keeps a skipped subtest (pytest 9) that its test's later
  passing call used to hide; make test-ci fails when pytest itself errors.
- conftest.py, .gitignore and README.md count as code.

Release half (approve, non-blocking):
- Build with hatchling and its dependencies pinned
  (.github/build-constraints.txt); with these pins a build of 6d89338
  reproduces PyPI's 2.126.8 wheel and sdist byte for byte, so a rebuild
  after a partial upload matches.
- prepare_release.py --verify: one parent, the version subject, and only
  versioning changes; used for the release-sha input and the re-run path.
  It accepts main's real 6d89338 (2.126.8).
- A push that landed although git reported a failure counts as pushed.
- One Publish per version at a time.
- The tag script fails when it cannot list origin's tags, and accepts a tag
  another run pushed at the same commit.
- A new job installs the sdist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lookup

- Set POLICYENGINE_UK_REQUIRE_TEST_DATA=1 (W3's conftest switch, #2335)
  wherever require-data is on: main pushes, merge queue groups and
  releases. A test that needs the dataset then fails with its own message
  instead of skipping. Unused until #2335 lands.
- A manual dispatch of the main workflow always tests; the merge queue
  lookup applies only to pushes, and a failed lookup parses to zero.
- Document the re-run path's residual and where docs-only pull requests
  build the documentation; the workflow-shape test covers the release
  workflow's require-data and the lookup.

From the GPT-6.1 Sol delta review of 96e9cec (APPROVE, non-blocking).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis and others added 3 commits October 11, 2026 19:21
#2324 makes bump_version.py count only the fragments towncrier consumes
and exit when there are none. prepare_release.py now uses that script's
own classification when it has one (so a commit with only untyped files
releases nothing) and counts every file otherwise, as before. The tests
pass with main's bump_version.py and with #2324's, so the two pull
requests can land in either order. Based on the patch from #2324's
session.

Also merges #2303 (the stale #1968 xfail), which this pull request's
own check rejects as an XPASS until it lands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…olicyengine.py

- CI on 0879f6c found 66 skips without an allowlist entry, all from
  test_projection_continues_from_last_published_rate (new on main from
  #1925): it is parametrised over every DWP rate parameter and skips
  those it does not apply to (not uprated, series ending before 2026-27,
  the two Universal Credit Act 2025 exceptions). One entry accepts exactly
  those three reasons for that test.
- prepare_release.py reads the tip's bump_version.py without writing
  .github/__pycache__ into the checkout; the test's stand-in towncrier,
  which imported it, left a .pyc that its by-hand version commit then
  committed (CI writes bytecode; the local run did not). A test checks
  the checkout stays clean.
- Publish, after PyPI serves exactly the tested files, sends policyengine.py
  the repository dispatch W4's certify-uk-release.yaml expects
  (event_type policyengine-uk-release, client_payload.version), with a
  token from the org App limited to that repository. Both steps continue
  on error: W4 also catches up every two hours, so a lost dispatch must
  never fail a release.
- Publish runs in a 'pypi' environment, where the PyPI credential is to
  move, limited to main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Give the token and dispatch steps a 2-minute timeout, so a hang cannot
  use up the Publish job before the tag step.
- Ask for contents: write only, which is all a repository dispatch needs.
- Say what the pypi environment does today: limiting it to main is an
  admin setting, not set by this workflow.
- Turn bytecode writing on in the clean-checkout test, so it tests
  prepare_release.py rather than the machine's PYTHONDONTWRITEBYTECODE.
- Assert the dispatch condition and token scope in the shape test.
- The DWP allowlist entry names the five parameters the Universal Credit
  Act 2025 ss.1 and 3 set apart, not two.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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