Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
0ad481a
Integrate canonical SPM provider and immutable population default
MaxGhenis Sep 9, 2026
97f118e
Document country release lock guard work
MaxGhenis Sep 9, 2026
ef23511
Add regressions for the country release lock boundary
MaxGhenis Sep 9, 2026
8009b09
Guard release lock provenance and root-only refreshes
MaxGhenis Sep 9, 2026
198e5e9
Reject ancestor workspaces at the release lock boundary
MaxGhenis Sep 9, 2026
56a7788
Gate automatic country releases on the committed registry lock
MaxGhenis Sep 9, 2026
56f51b0
Record completed release guard verification
MaxGhenis Sep 9, 2026
0fa96ad
Keep release workflow journal outside country source
MaxGhenis Sep 9, 2026
1395bcd
Merge upstream/main into the canonical SPM branch
MaxGhenis Sep 11, 2026
f121882
Regenerate the production registry lock on published calculator 1.0.0
MaxGhenis Sep 11, 2026
892a0c9
Add a progress record for the review-fix pass
MaxGhenis Sep 11, 2026
4ead0dd
Share the shipped policy state on ordinary household simulations
MaxGhenis Sep 11, 2026
b38eeb6
Name the caller's fix for an absent county and an unresolved dataset …
MaxGhenis Sep 11, 2026
26c2cb0
Align the microsimulation tests with the population input contract
MaxGhenis Sep 11, 2026
cdfde93
Repair the remaining YAML cases that reach the SPM housing cap
MaxGhenis Sep 11, 2026
89e5937
Record the review-fix state before the local CI reruns
MaxGhenis Sep 11, 2026
be43028
Drop the unused receipt flag from the shared-policy helper
MaxGhenis Sep 11, 2026
cb6d8e3
Merge upstream/main into the canonical SPM branch
MaxGhenis Sep 11, 2026
9870bef
Refresh the registry lock root to the merged version
MaxGhenis Sep 11, 2026
b43265a
Share the shipped policy for a supplied system and a reform baseline too
MaxGhenis Sep 11, 2026
9793a33
Record the verified state after the CI reruns
MaxGhenis Sep 11, 2026
b4453be
Pack heavy per-subdir batches by reform-combo weight
MaxGhenis Sep 11, 2026
648193c
State the tlaib batch evidence without a memory claim the baseline do…
MaxGhenis Sep 11, 2026
21d0b0d
Consult the SPM housing portion only for units with housing assistanc…
MaxGhenis Sep 11, 2026
bb2fd5f
Cover the mixed population, drop the lane's progress file, and align …
MaxGhenis Sep 11, 2026
3e81597
Let the selective runner pass over changed support modules that colle…
MaxGhenis Sep 11, 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
55 changes: 55 additions & 0 deletions .github/release-lock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Release lock guard

The automatic versioning job checks the committed `pyproject.toml` and `uv.lock`
before changing either file. It runs the existing `bump_version.py` exactly once,
builds the changelog with isolated PyPI tooling, and calls
`python .github/release_lock.py --refresh` before the `Update PolicyEngine US`
commit. That commit stages the version, lock, changelog, and consumed fragments.

The guard validates the root package and every dependency source. Registry
packages must use `https://pypi.org/simple`; every listed sdist and wheel must
have a SHA-256 hash and an HTTPS artifact URL on `files.pythonhosted.org`.
A PyPI source label does not make a file, local path, or custom artifact URL
acceptable. Project source overrides, workspaces, and direct requirements are
also rejected, including ancestor workspaces that could redirect uv to another
lock. uv runs without inherited `UV_*`/`PIP_*` overrides or discovered
configuration and uses the standard PyPI registry.

`--committed` requires both project and lock files to match their tracked HEAD
contents, validates their semantics, and runs an actual `uv lock --check`.
The default check supports inspecting an uncommitted candidate, but release CI
uses `--committed`. Neither check repairs a stale lock.

`--refresh` is only for the automatic root version bump. It runs uv to refresh
and check the lock, preserving the complete reviewed dependency graph and all
lock metadata except the root package version. Any dependency, artifact,
constraint, marker, or other semantic change fails the step and restores the
exact previous lock bytes. A uv failure also restores those bytes. Solver
upgrades that change the graph fail closed and require a separate lock review.
The failed workflow cannot reach the automatic commit.

Pull request model jobs depend on `ReleaseLock`. Sentinel push model jobs have
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.

`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
```

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.
315 changes: 315 additions & 0 deletions .github/release_lock.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,315 @@
"""Validate a release lock, or refresh only its automatic version bump.

Dependency changes belong in a reviewed registry lock before versioning runs.
This helper uses only the standard library and never imports the country model.
"""

from __future__ import annotations

import argparse
import copy
import os
from pathlib import Path
import re
import subprocess
import sys
import tomllib
from urllib.parse import unquote, urlsplit


REPO_ROOT = Path(__file__).resolve().parents[1]
PYPI = "https://pypi.org/simple"


def load_toml(path: Path) -> dict:
"""Read project or lock metadata without importing its package."""
return tomllib.loads(path.read_text(encoding="utf-8"))


def normalized_name(name: str) -> str:
return re.sub(r"[-_.]+", "-", name).lower()


def validate_registry_project(project: dict) -> None:
"""Reject local, direct-URL, workspace and alternate-index requirements."""
uv = project.get("tool", {}).get("uv", {})
forbidden = {
"sources",
"index",
"workspace",
"find-links",
"index-url",
"extra-index-url",
"default-index",
"no-index",
}
if forbidden.intersection(uv):
raise ValueError(
"Release project must use PyPI without source/index/workspace overrides"
)

def validate_requirements(value):
if isinstance(value, str):
# Registry requirements may have version constraints, extras and
# environment markers, but never a URL or filesystem path.
requirement = value.split(";", 1)[0].strip()
if not re.match(r"^[A-Za-z0-9][A-Za-z0-9._-]*", requirement) or any(
char in requirement for char in "@/:\\"
):
raise ValueError(
"Release requirements must use the registry, not URLs or paths"
)
elif isinstance(value, list):
for item in value:
validate_requirements(item)
elif isinstance(value, dict):
for item in value.values():
validate_requirements(item)
else:
raise ValueError("Invalid release requirement metadata")

metadata = project["project"]
for requirements in (
metadata.get("dependencies", []),
metadata.get("optional-dependencies", {}),
project.get("dependency-groups", {}),
project.get("build-system", {}).get("requires", []),
uv.get("constraint-dependencies", []),
uv.get("override-dependencies", []),
uv.get("build-constraint-dependencies", []),
):
validate_requirements(requirements)


def validate_artifact(artifact: dict, package_name: str) -> None:
"""Require a hashed PyPI-hosted artifact, even under a PyPI source label."""
if not isinstance(artifact, dict) or set(artifact) - {
"url",
"hash",
"size",
"upload-time",
}:
raise ValueError(f"Invalid registry artifact fields for {package_name}")
url = artifact.get("url")
digest = artifact.get("hash")
if not isinstance(url, str) or any(
char.isspace() or ord(char) < 32 for char in url
):
raise ValueError(f"Missing or invalid registry artifact URL for {package_name}")
parsed = urlsplit(url)
path = unquote(parsed.path)
if (
parsed.scheme != "https"
or parsed.netloc != "files.pythonhosted.org"
or parsed.query
or parsed.fragment
or "?" in url
or "#" in url
or not path.startswith("/packages/")
or "\\" in path
or "%" in path
or any(part in {"", ".", ".."} for part in path.split("/")[1:])
or path != parsed.path
):
raise ValueError(
f"Artifact for {package_name} must have a canonical PyPI files URL"
)
if not isinstance(digest, str) or not re.fullmatch(
r"sha256:[0-9a-fA-F]{64}", digest
):
raise ValueError(f"Artifact for {package_name} must have a SHA-256 hash")


def validate_lock_requirements(value) -> None:
"""Reject alternate transports in lock dependency edges and metadata too."""
if isinstance(value, dict):
if {"url", "git", "path", "directory", "editable", "virtual"}.intersection(
value
):
raise ValueError("Release lock requirement contains a non-registry source")
if "registry" in value and value["registry"] != PYPI:
raise ValueError("Release lock requirement must use the PyPI registry")
for item in value.values():
validate_lock_requirements(item)
elif isinstance(value, list):
for item in value:
validate_lock_requirements(item)


def validate_registry_lock(
project: dict, lock: dict, *, allow_previous_version: bool = False
) -> None:
"""The editable root is the only package allowed outside ordinary PyPI."""
metadata = project["project"]
root_name = normalized_name(metadata["name"])
roots = []
for package in lock.get("package", []):
name = package["name"]
if normalized_name(name) == root_name:
roots.append(package)
if package.get("source") != {"editable": "."}:
raise ValueError(
"Release lock root must be the current editable checkout"
)
if "sdist" in package or "wheels" in package:
raise ValueError(
"Editable release root must not contain registry artifacts"
)
else:
if package.get("source") != {"registry": PYPI}:
raise ValueError(
f"Release lock dependency {name} must use the PyPI registry"
)
artifacts = []
if "sdist" in package:
artifacts.append(package["sdist"])
wheels = package.get("wheels", [])
if not isinstance(wheels, list):
raise ValueError(f"Invalid registry wheel list for {name}")
artifacts.extend(wheels)
if not artifacts:
raise ValueError(
f"Release lock dependency {name} has no registry artifacts"
)
for artifact in artifacts:
validate_artifact(artifact, name)
for key in (
"dependencies",
"optional-dependencies",
"dev-dependencies",
"metadata",
):
validate_lock_requirements(package.get(key, {}))
if len(roots) != 1:
raise ValueError("Release lock must contain exactly one root package")
if not allow_previous_version and roots[0].get("version") != metadata["version"]:
raise ValueError("Release lock root version differs from pyproject.toml")
# uv normalizes spacing in specifier lists; --check validates their meaning.
if re.sub(r"\s", "", lock.get("requires-python", "")) != re.sub(
r"\s", "", metadata["requires-python"]
):
raise ValueError("Release lock Python range differs from pyproject.toml")


def without_root_version(lock: dict, root_name: str) -> dict:
"""Retain the entire reviewed lock graph except the editable root version."""
result = copy.deepcopy(lock)
for package in result["package"]:
if normalized_name(package["name"]) == normalized_name(root_name):
package.pop("version", None)
return result


def resolver_environment() -> dict[str, str]:
"""Discard inherited resolver overrides and active project environments."""
env = {
key: value
for key, value in os.environ.items()
if not key.startswith(("UV_", "PIP_"))
and key not in {"VIRTUAL_ENV", "CONDA_PREFIX", "PYTHONPATH", "PYTHONHOME"}
}
env["UV_FROZEN"] = "0"
return env


def require_committed_files(root: Path) -> None:
"""A locally repaired lock or project must not stand in for reviewed HEAD."""
for name in ("pyproject.toml", "uv.lock"):
path = root / name
result = subprocess.run(
["git", "show", f"HEAD:{name}"], cwd=root, check=True, capture_output=True
)
if path.is_symlink() or path.read_bytes() != result.stdout:
raise ValueError(
f"Release check requires committed {name}; working bytes differ from HEAD"
)


def reject_parent_workspaces(root: Path) -> None:
"""Prevent uv from checking or rewriting an ancestor's workspace lock."""
# --no-config and --no-sources do not disable workspace discovery. Refuse
# workspace ancestors even when they claim to exclude this checkout.
for parent in root.resolve().parents:
project_path = parent / "pyproject.toml"
if project_path.is_file() and "workspace" in load_toml(project_path).get(
"tool", {}
).get("uv", {}):
raise ValueError("Release checkout must not be inside a uv workspace")


def check_release_lock(
root: Path = REPO_ROOT, *, refresh: bool = False, committed: bool = False
) -> None:
"""Check with uv, or transactionally refresh only the bumped root version."""
if refresh and committed:
raise ValueError("Use committed checks before the bump and refresh after it")
reject_parent_workspaces(root)
if committed:
require_committed_files(root)
project = load_toml(root / "pyproject.toml")
validate_registry_project(project)
lock_path = root / "uv.lock"
if lock_path.is_symlink():
raise ValueError("Release lock must be a regular checkout file")
before_bytes = lock_path.read_bytes()
before = tomllib.loads(before_bytes.decode("utf-8"))
validate_registry_lock(project, before, allow_previous_version=refresh)
command = [
"uv",
"lock",
"--no-config",
"--no-sources",
"--default-index",
PYPI,
"--python",
sys.executable,
"--no-python-downloads",
"--no-cache",
]
kwargs = {"cwd": root, "env": resolver_environment(), "check": True}
successful = False
try:
if refresh:
subprocess.run(command, **kwargs)
after = load_toml(lock_path)
validate_registry_lock(project, after)
name = project["project"]["name"]
if without_root_version(before, name) != without_root_version(after, name):
raise ValueError(
"Versioning changed the reviewed dependency graph; prepare and review a registry lock first"
)
checked_bytes = lock_path.read_bytes()
subprocess.run([*command, "--check"], **kwargs)
if lock_path.read_bytes() != checked_bytes:
raise ValueError("uv lock --check unexpectedly changed the release lock")
successful = True
finally:
# Includes resolver errors, invalid TOML, drift, failed final checks and
# interruptions. A partial or deleted resolver output is never retained.
if not successful:
lock_path.write_bytes(before_bytes)


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
mode = parser.add_mutually_exclusive_group()
mode.add_argument(
"--committed",
action="store_true",
help="Require lock and project bytes from HEAD",
)
mode.add_argument(
"--refresh",
action="store_true",
help="Refresh only the root version after the automatic bump",
)
args = parser.parse_args()
try:
check_release_lock(refresh=args.refresh, committed=args.committed)
except (ValueError, OSError, KeyError, subprocess.CalledProcessError) as exc:
parser.error(str(exc))
return 0


if __name__ == "__main__":
raise SystemExit(main())
18 changes: 18 additions & 0 deletions .github/tests/fixtures/registry/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
This minimal graph was generated by uv 0.11.7 from the adjacent `pyproject.toml`,
with the standard PyPI index and no source or configuration overrides. The
`idna==3.10` artifact URLs and hashes are real registry metadata; the fixture
project is an editable local root. This fixture is unrelated to the country
package's pending production lock.

Generation command:

```sh
uv lock --no-config --no-sources --default-index https://pypi.org/simple \
--cache-dir /tmp/release-lock-fixture-cache --python 3.13 --offline
```

The offline generation used copied existing PyPI cache metadata for idna, after
live registry access was unavailable on the development lane. The complete graph
was written by uv (two packages resolved); its TOML was not manually assembled.
The opt-in `RELEASE_LOCK_REAL_UV=1` test runs the installed uv with normal registry
access and validates a root-only refresh without depending on country packages.
Loading
Loading