Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
c5d8abf
Allow explicit SPM calculator post-release consumers
MaxGhenis Sep 14, 2026
437394d
Start unified SPM candidate progress log
MaxGhenis Sep 14, 2026
a6f1e8d
Isolate shared policy from later reforms and repair clone routing
MaxGhenis Sep 14, 2026
17cf3f1
Record verified PR heads and unified-candidate progress
MaxGhenis Sep 14, 2026
d1251c4
Keep SPM valuation out of program benefits and CARE/FERA income
MaxGhenis Sep 15, 2026
c420922
Guard dataset inputs, the default build and the batch runner's exit s…
MaxGhenis Sep 15, 2026
28e7ac9
Cite the CPUC income sources and record the FERA basis
MaxGhenis Sep 15, 2026
ee072e3
Record step 2-3 completion and verification exit codes
MaxGhenis Sep 15, 2026
1bc9a83
Keep the copy-on-write barrier on a cloned system
MaxGhenis Sep 15, 2026
4256c3f
Carry the mechanism-independent half of #9448's policy-family tests
MaxGhenis Sep 15, 2026
646bd95
Remove the working progress log
MaxGhenis Sep 15, 2026
e18473f
Start the second-pass review progress log
MaxGhenis Sep 15, 2026
693d651
Probe the housing allocation over the whole default population
MaxGhenis Sep 15, 2026
f378f49
State what the housing changes move, and restore #9448's fragment
MaxGhenis Sep 15, 2026
bd382b4
Record the certified build's digest where it can be checked
MaxGhenis Sep 15, 2026
cd04c2c
Describe the calculator dependency that actually ships
MaxGhenis Sep 15, 2026
bfe9cf9
Name what the retained pre-2014 CPUC inclusion counts
MaxGhenis Sep 15, 2026
4be961e
Record second-pass progress
MaxGhenis Sep 15, 2026
4f73fdd
Record why #9448's two tracer tests are not carried
MaxGhenis Sep 15, 2026
0925187
Record second-pass verification results
MaxGhenis Sep 15, 2026
6c9d4ab
Keep the whole-population housing probe inside the hosted runner's me…
Sep 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 35 additions & 9 deletions .github/release-lock.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,23 +41,49 @@ the same prerequisite, and `Publish` checks its own committed checkout again
before installation or build. Model environments use locked synchronization;
subsequent commands use that environment without synchronizing again.

The PR `CandidateWheel` matrix starts from the reviewed head in two separate
clean checkouts. Both run the actual release version-bump helper; the `rc1`
entry appends `rc1` to that computed next version before both perform the
root-only lock refresh. Each uses the same Ubuntu version, Python, uv, locked
development environment, and `make` build as `Publish`. Each uploads its
unpublished wheel and `release-build/receipt.json`; the job has read-only
repository access and no publication step. The receipt rejects changed or
untracked policy source, checks the wheel's version and backend, and records source, lock, tool, and
wheel identities. Download the `release-wheel-rc1-<head SHA>` and
`release-wheel-final-<head SHA>` artifacts to qualify their actual bytes.
A change to the head or changelog fragments requires a new candidate; the
candidate does not reserve a registry version.
GitHub updates its hosted Ubuntu images even within `ubuntu-24.04`, so receipts
also record `ImageOS` and `ImageVersion` for release-environment comparison.

`pyproject.toml` pins Hatchling and its complete non-optional dependency closure
because `python -m build` creates an isolated backend environment outside the
runtime lock. Both builds use those same pins and the frontend version in
`uv.lock`. `Publish` verifies its own receipt before publishing. Registry wheel
comparison and downstream qualification remain separate release checks.

`ReleaseLockTests` has no dependency on a model job or the country lock check.
It uses only the Python standard library and uv, so it can test the guard while
the country dependency registry is incomplete:

```sh
python -m unittest discover -s .github/tests -p test_release_lock.py -v
RELEASE_LOCK_REAL_UV=1 python -m unittest discover -s .github/tests -p test_release_lock.py -v
python -m unittest discover -s .github/tests -p 'test_release_*.py' -v
RELEASE_LOCK_REAL_UV=1 python -m unittest discover -s .github/tests -p 'test_release_*.py' -v
```

The opt-in probe creates an isolated, minimal project with a standard PyPI
dependency and exercises actual uv locking. CI enables it. These commands do not
install or import the country model.

For the SPM integration, the committed country lock is intentionally still
blocked until `spm-calculator==1.0.0` is available on PyPI and a production
registry lock is regenerated and reviewed separately. A stale root version or
older calculator resolution must fail the full PR and publication gates.
Passing guard tests does not clear that release prerequisite. Do not use local
wheel links or edit lock fields to manufacture a passing lock; the automatic
root-version refresh is not a general dependency update command.
For the SPM integration, `spm-calculator` 1.0.0 reached PyPI on 2026-09-11, and
the committed country lock resolves it from `https://pypi.org/simple` with sdist
and wheel SHA-256 hashes, so `--committed` passes on the committed files.
`pyproject.toml` requires `>=1.0.0,<=1.0.0.post1` and the lock pins 1.0.0;
moving that pin to the post-release is an ordinary reviewed lock change. This
guard validates dependency sources, artifact URLs and hashes, never a version
string, so the calculator requirement is held by `pyproject.toml` and review
rather than by `release_lock.py`. A stale root version or an older calculator
resolution must still fail the full PR and publication gates, and passing guard
tests clears no other release prerequisite. Do not use local wheel links or edit
lock fields to manufacture a passing lock; the automatic root-version refresh is
not a general dependency update command.
161 changes: 161 additions & 0 deletions .github/release_build.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
"""Verify clean policy source and record a locally built release wheel.

The PR matrix can prepare either candidate using the actual release bump helper.
Both candidates and Publish record a receipt after the same make command.
This script never resolves dependencies or publishes.
"""

from __future__ import annotations

import hashlib
from importlib.metadata import version
import json
import os
from pathlib import Path
import platform
import re
import subprocess
import sys
import tomllib
from zipfile import ZipFile


ROOT = Path(__file__).resolve().parents[1]


def prepare_candidate(root: Path, kind: str) -> None:
"""Use the actual release helper, optionally preparing its rc1 counterpart."""
if kind not in {"rc1", "final"}:
raise ValueError("Candidate kind must be rc1 or final")
subprocess.run(
[sys.executable, str(root / ".github/bump_version.py")], cwd=root, check=True
)
if kind == "rc1":
path = root / "pyproject.toml"
project = path.read_text()
final_version = tomllib.loads(project)["project"]["version"]
prepared, count = re.subn(
rf'^(version\s*=\s*"){re.escape(final_version)}("\s*)$',
rf"\g<1>{final_version}rc1\g<2>",
project,
flags=re.MULTILINE,
)
if count != 1:
raise ValueError("Expected exactly one release version assignment")
path.write_text(prepared)


def _sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()


def checked_build_requirements(project: dict) -> dict[str, str]:
"""Require exact pins for every declared isolated-build requirement."""
if project["build-system"]["build-backend"] != "hatchling.build":
raise ValueError("Release backend must match the reviewed Hatchling build")
requirements = {}
for requirement in project["build-system"]["requires"]:
match = re.fullmatch(r"([A-Za-z0-9_.-]+)==([0-9][A-Za-z0-9_.]*)", requirement)
if match is None or match[1] in requirements:
raise ValueError("Release build requirements must be unique exact pins")
requirements[match[1]] = match[2]
if "hatchling" not in requirements:
raise ValueError("Release build requires an exact Hatchling pin")
return requirements


def wheel_identity(
path: Path, *, expected_version: str, hatchling_version: str
) -> dict:
"""Bind wheel bytes to the requested release and its declared backend."""
with ZipFile(path) as wheel:
bad_member = wheel.testzip()
if bad_member is not None:
raise ValueError(f"Invalid wheel member: {bad_member}")
infos = [name for name in wheel.namelist() if name.endswith(".dist-info/WHEEL")]
if len(infos) != 1:
raise ValueError("Expected exactly one wheel metadata directory")
prefix = infos[0].removesuffix("WHEEL")
metadata = wheel.read(prefix + "METADATA").decode()
wheel_metadata = wheel.read(infos[0]).decode()
if re.findall(r"^Version: (.+)$", metadata, re.MULTILINE) != [expected_version]:
raise ValueError("Wheel version differs from the prepared release")
if re.findall(r"^Generator: (.+)$", wheel_metadata, re.MULTILINE) != [
f"hatchling {hatchling_version}"
]:
raise ValueError("Wheel generator differs from the pinned backend")
return {
"filename": path.name,
"sha256": _sha256(path),
"size_bytes": path.stat().st_size,
}


def write_build_receipt(root: Path = ROOT) -> Path:
"""Fail if tests changed package inputs, then retain only public build facts."""
subprocess.run(
["git", "diff", "--exit-code", "HEAD", "--", "policyengine_us"],
cwd=root,
check=True,
)
untracked = subprocess.check_output(
["git", "ls-files", "--others", "--exclude-standard", "--", "policyengine_us"],
cwd=root,
)
if untracked.strip():
raise ValueError("Untracked policy source would enter the release wheel")
project = tomllib.loads((root / "pyproject.toml").read_text())
lock = tomllib.loads((root / "uv.lock").read_text())
requirements = checked_build_requirements(project)
frontend = version("build")
locked_frontends = [
item["version"] for item in lock["package"] if item["name"] == "build"
]
if locked_frontends != [frontend]:
raise ValueError("Installed build frontend differs from the registry lock")
wheels = sorted((root / "dist").glob("*.whl"))
if len(wheels) != 1:
raise ValueError("Expected exactly one freshly built release wheel")
receipt = {
"source_head": subprocess.check_output(
["git", "rev-parse", "HEAD"], cwd=root, text=True
).strip(),
"policy_tree": subprocess.check_output(
["git", "rev-parse", "HEAD:policyengine_us"], cwd=root, text=True
).strip(),
"policy_source_matches_head": True,
"prepared_version": project["project"]["version"],
"pyproject_sha256": _sha256(root / "pyproject.toml"),
"uv_lock_sha256": _sha256(root / "uv.lock"),
"python": platform.python_version(),
"platform": platform.system(),
"machine": platform.machine(),
"runner_image": {
"os": os.environ.get("ImageOS"),
"version": os.environ.get("ImageVersion"),
},
"uv": subprocess.check_output(["uv", "--version"], text=True).strip(),
"frontend": {"name": "build", "version": frontend},
"isolated_build_requirements": requirements,
"wheel": wheel_identity(
wheels[0],
expected_version=project["project"]["version"],
hatchling_version=requirements["hatchling"],
),
}
output = root / "release-build" / "receipt.json"
output.parent.mkdir(exist_ok=True)
output.write_text(json.dumps(receipt, indent=2, sort_keys=True) + "\n")
return output


if __name__ == "__main__":
import argparse

parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--prepare", choices=("rc1", "final"))
args = parser.parse_args()
if args.prepare:
prepare_candidate(ROOT, args.prepare)
else:
print(write_build_receipt().read_text(), end="")
Loading
Loading