From 818c894e4458cc499ad2d2821cf903a6e6c1ee3d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 12 Sep 2026 14:50:37 -0400 Subject: [PATCH 01/10] Build and verify isolated wrapper release candidates --- .github/actions/release-toolchain/action.yml | 14 + .github/release-toolchain.json | 177 ++ .github/release-tools.txt | 12 + .github/workflows/pr_code_changes.yaml | 68 + .github/workflows/push.yaml | 114 +- Makefile | 3 +- .../release-candidate-build.changed.md | 1 + .../runbooks/wrapper-release-candidates.md | 109 ++ pyproject.toml | 2 +- scripts/release_build.py | 1484 +++++++++++++++++ tests/test_certify_data_release.py | 50 +- tests/test_release_build.py | 1117 +++++++++++++ tests/test_release_tro_generation.py | 44 +- tests/test_spm_household.py | 83 +- 14 files changed, 3170 insertions(+), 108 deletions(-) create mode 100644 .github/actions/release-toolchain/action.yml create mode 100644 .github/release-toolchain.json create mode 100644 .github/release-tools.txt create mode 100644 changelog.d/release-candidate-build.changed.md create mode 100644 docs/engineering/runbooks/wrapper-release-candidates.md create mode 100644 scripts/release_build.py create mode 100644 tests/test_release_build.py diff --git a/.github/actions/release-toolchain/action.yml b/.github/actions/release-toolchain/action.yml new file mode 100644 index 00000000..53c2fafe --- /dev/null +++ b/.github/actions/release-toolchain/action.yml @@ -0,0 +1,14 @@ +name: Frozen release build scaffold +description: Install the identical hash-locked candidate and publishing toolchain +runs: + using: composite + steps: + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: '3.14.7' + - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + version: '0.12.13' + - name: Install hash-locked preparation and build tools + shell: bash + run: python scripts/release_build.py bootstrap --output "$RUNNER_TEMP/wrapper-release-tools" diff --git a/.github/release-toolchain.json b/.github/release-toolchain.json new file mode 100644 index 00000000..8c70fc1c --- /dev/null +++ b/.github/release-toolchain.json @@ -0,0 +1,177 @@ +{ + "build_backend_requires": [ + "setuptools==84.0.0", + "wheel==0.48.0", + "setuptools-scm==8.3.1", + "packaging==25.0" + ], + "image_source": "https://github.com/actions/runner-images/releases/tag/ubuntu24/20260907.300", + "python": "3.14.7", + "python_source": "https://github.com/actions/python-versions/releases/tag/3.14.7-31064857500", + "runner": { + "arch": "X64", + "image_os": "ubuntu24", + "image_version": "20260907.300.1", + "os": "Linux" + }, + "schema_version": 1, + "setup_actions": { + "actions/setup-python": { + "commit": "ece7cb06caefa5fff74198d8649806c4678c61a1", + "ref": "v6", + "source": "https://api.github.com/repos/actions/setup-python/git/ref/tags/v6" + }, + "astral-sh/setup-uv": { + "commit": "08807647e7069bb48b6ef5acd8ec9567f424441b", + "ref": "v8.1.0", + "source": "https://api.github.com/repos/astral-sh/setup-uv/git/ref/tags/v8.1.0" + } + }, + "tools": { + "build": { + "registry_json": "https://pypi.org/pypi/build/1.6.1/json", + "registry_json_sha256": "064e9dcb80e948b5de7a841c3fd9b5644a980eb27bb644fb7f562b7d773b157b", + "requires": [ + "packaging>=24.0", + "pyproject_hooks" + ], + "version": "1.6.1", + "wheels": [ + { + "filename": "build-1.6.1-py3-none-any.whl", + "sha256": "ecd351a4be9d35a9eaaba244a7687143c9c7d4aea6ac964e7e7ddab20cbcf4e7", + "url": "https://files.pythonhosted.org/packages/ad/9b/9fb3585dabcd73a1b2a6267f63f62649347c9e6d072c9fde365b105abb2c/build-1.6.1-py3-none-any.whl" + } + ] + }, + "click": { + "registry_json": "https://pypi.org/pypi/click/8.5.0/json", + "registry_json_sha256": "4e36adc9b46f837e10ce801b7a478f9146fefdfb287292b7668fc64b1474e41d", + "requires": [], + "version": "8.5.0", + "wheels": [ + { + "filename": "click-8.5.0-py3-none-any.whl", + "sha256": "255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", + "url": "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl" + } + ] + }, + "jinja2": { + "registry_json": "https://pypi.org/pypi/jinja2/3.1.6/json", + "registry_json_sha256": "1bc757d7065f3e67d3a49e72ebc731d0774055575732592eede9820c136329cc", + "requires": [ + "MarkupSafe>=2.0" + ], + "version": "3.1.6", + "wheels": [ + { + "filename": "jinja2-3.1.6-py3-none-any.whl", + "sha256": "85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", + "url": "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl" + } + ] + }, + "markupsafe": { + "registry_json": "https://pypi.org/pypi/markupsafe/3.0.3/json", + "registry_json_sha256": "20faf559f1a150d84e7b0e7567b933fb3d383e6ba82179a1caa32a44cdc96085", + "requires": [], + "version": "3.0.3", + "wheels": [ + { + "filename": "markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", + "sha256": "457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", + "url": "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" + } + ] + }, + "packaging": { + "registry_json": "https://pypi.org/pypi/packaging/25.0/json", + "registry_json_sha256": "6ab300d7b0735a50109912decebb1731ed292f1ed88a3ce5e4bb7876b182cac0", + "requires": [], + "version": "25.0", + "wheels": [ + { + "filename": "packaging-25.0-py3-none-any.whl", + "sha256": "29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", + "url": "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl" + } + ] + }, + "pyproject-hooks": { + "registry_json": "https://pypi.org/pypi/pyproject-hooks/1.2.0/json", + "registry_json_sha256": "e937e86433ebeed6e8c85538b0adbc7d3ce964b011fc141a7f9aec79c4e49d05", + "requires": [], + "version": "1.2.0", + "wheels": [ + { + "filename": "pyproject_hooks-1.2.0-py3-none-any.whl", + "sha256": "9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", + "url": "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl" + } + ] + }, + "setuptools": { + "registry_json": "https://pypi.org/pypi/setuptools/84.0.0/json", + "registry_json_sha256": "24d07932459261bbf9e66e5cfdebfeba23a977234ab24bf9c3a1df053c53ba21", + "requires": [], + "version": "84.0.0", + "wheels": [ + { + "filename": "setuptools-84.0.0-py3-none-any.whl", + "sha256": "51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", + "url": "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl" + } + ] + }, + "setuptools-scm": { + "registry_json": "https://pypi.org/pypi/setuptools-scm/8.3.1/json", + "registry_json_sha256": "f8afaee39a8af1a697a40dc7e80d39cb90756ae3aa4d25c47f107a3f17b48d88", + "requires": [ + "packaging>=20", + "setuptools" + ], + "version": "8.3.1", + "wheels": [ + { + "filename": "setuptools_scm-8.3.1-py3-none-any.whl", + "sha256": "332ca0d43791b818b841213e76b1971b7711a960761c5bea5fc5cdb5196fbce3", + "url": "https://files.pythonhosted.org/packages/ab/ac/8f96ba9b4cfe3e4ea201f23f4f97165862395e9331a424ed325ae37024a8/setuptools_scm-8.3.1-py3-none-any.whl" + } + ] + }, + "towncrier": { + "registry_json": "https://pypi.org/pypi/towncrier/26.9.0/json", + "registry_json_sha256": "20e688a6205b29a19a6de1a3c7f2b404aceb78045a0eac6d359259b82da6b935", + "requires": [ + "click", + "jinja2" + ], + "version": "26.9.0", + "wheels": [ + { + "filename": "towncrier-26.9.0-py3-none-any.whl", + "sha256": "ae4d223e6aadaff98d29b7f09e58d9c9ccf4cd3b455a2d2e0486d432cd8bf660", + "url": "https://files.pythonhosted.org/packages/82/88/f9340b545dafa64de053798e760515a2736fbb60092cf9aa6b146ccf3ede/towncrier-26.9.0-py3-none-any.whl" + } + ] + }, + "wheel": { + "registry_json": "https://pypi.org/pypi/wheel/0.48.0/json", + "registry_json_sha256": "03f655da3a6dd010d9ac41d4771d5d973109cb662a1e10eb9d967535ffd145f8", + "requires": [ + "packaging>=24.0" + ], + "version": "0.48.0", + "wheels": [ + { + "filename": "wheel-0.48.0-py3-none-any.whl", + "sha256": "3217dcc807155e45db462d7ef2431f5ddda0d7273b700d05a67b271ceb1287ab", + "url": "https://files.pythonhosted.org/packages/2e/29/69cfbb602cd91690c55d38ba9fe53e6a7e76a6fa647bf38f19c138d25449/wheel-0.48.0-py3-none-any.whl" + } + ] + } + }, + "uv": "0.12.13", + "verified_at_utc": "2026-09-12T16:29:15.410657+00:00" +} diff --git a/.github/release-tools.txt b/.github/release-tools.txt new file mode 100644 index 00000000..fbde9155 --- /dev/null +++ b/.github/release-tools.txt @@ -0,0 +1,12 @@ +# Exact artifacts for the frozen Linux x86_64 CPython 3.14 build scaffold. +# The separate base scaffold dependencies come from reviewed uv.lock. +build==1.6.1 --hash=sha256:ecd351a4be9d35a9eaaba244a7687143c9c7d4aea6ac964e7e7ddab20cbcf4e7 +click==8.5.0 --hash=sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360 +jinja2==3.1.6 --hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67 +markupsafe==3.0.3 --hash=sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97 +packaging==25.0 --hash=sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 +pyproject-hooks==1.2.0 --hash=sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913 +setuptools==84.0.0 --hash=sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670 +setuptools-scm==8.3.1 --hash=sha256:332ca0d43791b818b841213e76b1971b7711a960761c5bea5fc5cdb5196fbce3 +towncrier==26.9.0 --hash=sha256:ae4d223e6aadaff98d29b7f09e58d9c9ccf4cd3b455a2d2e0486d432cd8bf660 +wheel==0.48.0 --hash=sha256:3217dcc807155e45db462d7ef2431f5ddda0d7273b700d05a67b271ceb1287ab diff --git a/.github/workflows/pr_code_changes.yaml b/.github/workflows/pr_code_changes.yaml index d64d5424..7463e519 100644 --- a/.github/workflows/pr_code_changes.yaml +++ b/.github/workflows/pr_code_changes.yaml @@ -11,10 +11,78 @@ on: - changelog.d/** - pyproject.toml - uv.lock + - Makefile - src/policyengine/data/bundle/manifest.json workflow_dispatch: jobs: + ReleaseCandidate: + name: Nonpublishing wrapper candidate (release) + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-24.04 + permissions: + contents: read + actions: read + env: + GH_TOKEN: ${{ github.token }} + POLICYENGINE_SKIP_COUNTRY_IMPORTS: '1' + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + fetch-depth: 0 + persist-credentials: false + - uses: ./.github/actions/release-toolchain + - name: Prepare and build isolated candidate source + env: + HUGGING_FACE_TOKEN: ${{ secrets.HUGGING_FACE_TOKEN }} + run: python scripts/release_build.py candidate --flavor release --output "$RUNNER_TEMP/wrapper-candidate" + - name: Upload actual candidate wheel and preparation evidence + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: wrapper-release-candidate-${{ github.event.pull_request.head.sha }}-${{ github.event.pull_request.base.sha }}-release + path: ${{ runner.temp }}/wrapper-candidate/ + if-no-files-found: error + retention-days: 90 + + NumericalCandidate: + name: Nonpublishing wrapper candidate (rc1) + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && vars.RELEASE_COUNTRY_CANDIDATE_ARTIFACT_ID != '' + runs-on: ubuntu-24.04 + permissions: + contents: read + actions: read + env: + GH_TOKEN: ${{ github.token }} + POLICYENGINE_SKIP_COUNTRY_IMPORTS: '1' + RELEASE_COUNTRY_CANDIDATE_ARTIFACT_ID: ${{ vars.RELEASE_COUNTRY_CANDIDATE_ARTIFACT_ID }} + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + fetch-depth: 0 + persist-credentials: false + - name: Read selected country artifact with the existing GitHub App + id: country-token + uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + owner: PolicyEngine + repositories: policyengine-us + permission-actions: read + - uses: ./.github/actions/release-toolchain + - name: Prepare and build isolated candidate source + env: + GH_TOKEN: ${{ steps.country-token.outputs.token }} + HUGGING_FACE_TOKEN: ${{ secrets.HUGGING_FACE_TOKEN }} + run: python scripts/release_build.py candidate --flavor rc1 --output "$RUNNER_TEMP/wrapper-candidate" + - name: Upload actual candidate wheel and preparation evidence + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: wrapper-release-candidate-${{ github.event.pull_request.head.sha }}-${{ github.event.pull_request.base.sha }}-rc1 + path: ${{ runner.temp }}/wrapper-candidate/ + if-no-files-found: error + retention-days: 90 + check-changelog: name: Check changelog fragment runs-on: ubuntu-latest diff --git a/.github/workflows/push.yaml b/.github/workflows/push.yaml index fa367e7b..caf84726 100644 --- a/.github/workflows/push.yaml +++ b/.github/workflows/push.yaml @@ -97,116 +97,100 @@ jobs: uses: actions/deploy-pages@v4 Versioning: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 needs: [Lint, Test] if: github.event.head_commit.message != 'Update package version' + permissions: + contents: write + actions: read + env: + GH_TOKEN: ${{ github.token }} + POLICYENGINE_SKIP_COUNTRY_IMPORTS: '1' steps: - name: Generate GitHub App token id: app-token - uses: actions/create-github-app-token@v1 + uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 with: app-id: ${{ secrets.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} - name: Checkout repo - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: token: ${{ steps.app-token.outputs.token }} fetch-depth: 0 - name: Fetch tags run: git fetch --tags --force - - name: Install uv - uses: astral-sh/setup-uv@v8.1.0 - - name: Setup Python - uses: actions/setup-python@v6 - with: - python-version: '3.13' - - name: Install package for TRO verification and regeneration - run: uv pip install -e . h5py --system - - name: Check reviewed release inputs before versioning + - uses: ./.github/actions/release-toolchain + - name: Authenticate candidate and reproduce its exact prepared tree env: HUGGING_FACE_TOKEN: ${{ secrets.HUGGING_FACE_TOKEN }} - run: | - if [[ -z "${HUGGING_FACE_TOKEN:-}" ]]; then - echo "::error::Full private release verification requires HUGGING_FACE_TOKEN" - exit 1 - fi - python scripts/check_release_credentials.py - python scripts/bundle.py check --published-spm --include-tros --strict-tros - python scripts/release_lock.py - - name: Build changelog - run: pip install yaml-changelog towncrier && make changelog - - name: Generate derived bundle metadata - run: python scripts/bundle.py generate - - name: Refresh only the release version in the registry lock - run: python scripts/release_lock.py --refresh + run: python scripts/release_build.py versioning --output "$RUNNER_TEMP/wrapper-versioning-receipt.json" + - name: Preserve authenticated Versioning preparation + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: wrapper-versioning-check-${{ github.sha }} + path: ${{ runner.temp }}/wrapper-versioning-receipt.json + if-no-files-found: error + retention-days: 90 - name: Preview changelog update - run: ".github/get-changelog-diff.sh" - - name: Regenerate bundled TRACE TROs - env: - HUGGING_FACE_TOKEN: ${{ secrets.HUGGING_FACE_TOKEN }} - run: | - python scripts/bundle.py generate --include-tros --strict-tros - python scripts/bundle.py check --published-spm --include-tros --strict-tros + run: .github/get-changelog-diff.sh - name: Update changelog, bundle metadata, and TROs - uses: EndBug/add-and-commit@v9 + uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9 with: - add: "." + add: '-u .' message: Update package version # ── Phase 2: Publish (only on sentinel commit) ──────────── Publish: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 if: github.event.head_commit.message == 'Update package version' + permissions: + contents: write + actions: read env: GH_TOKEN: ${{ github.token }} + POLICYENGINE_SKIP_COUNTRY_IMPORTS: '1' outputs: version: ${{ steps.version.outputs.version }} steps: - name: Checkout repo - uses: actions/checkout@v6 - - name: Install uv - uses: astral-sh/setup-uv@v8.1.0 - - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: - python-version: '3.13' - - name: Install package - run: uv pip install -e .[dev] --system - - name: Install policyengine - run: uv pip install policyengine --system + fetch-depth: 0 + - uses: ./.github/actions/release-toolchain + - name: Authenticate candidate, validate release and compare rebuilt wheel + env: + HUGGING_FACE_TOKEN: ${{ secrets.HUGGING_FACE_TOKEN }} + run: python scripts/release_build.py publish-check --output "$RUNNER_TEMP/wrapper-publish-receipt.json" + - name: Preserve prepublication byte comparison + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: wrapper-publish-byte-check-${{ github.sha }} + path: ${{ runner.temp }}/wrapper-publish-receipt.json + if-no-files-found: error + retention-days: 90 - name: Capture published version id: version run: bash .github/capture-version.sh - - name: Validate complete bundle and registry lock before publication - env: - HUGGING_FACE_TOKEN: ${{ secrets.HUGGING_FACE_TOKEN }} - run: | - if [[ -z "${HUGGING_FACE_TOKEN:-}" ]]; then - echo "::error::Full private release verification requires HUGGING_FACE_TOKEN" - exit 1 - fi - python scripts/check_release_credentials.py - python scripts/bundle.py check --published-spm --include-tros --strict-tros - python scripts/release_lock.py - - name: Publish a git tag - run: ".github/publish-git-tag.sh" - - name: Build package - run: python -m build - name: Export bundle release assets run: python scripts/export_bundle_release_assets.py --dist-dir release-assets - name: Verify bundle package metadata - env: - POLICYENGINE_SKIP_COUNTRY_IMPORTS: "1" run: | VERSION=$(python .github/fetch_version.py) policyengine bundle verify --country us --country uk --packages-only --json \ > "release-assets/policyengine-bundle-$VERSION.verification.json" + - name: Publish a git tag + run: | + python scripts/release_build.py require-unpublished + .github/publish-git-tag.sh + - name: Recheck registry immediately before distribution upload + run: python scripts/release_build.py require-unpublished - name: Publish a Python distribution to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1 with: user: __token__ password: ${{ secrets.PYPI }} - skip-existing: true + skip-existing: false packages-dir: dist - name: Create GitHub Release run: | diff --git a/Makefile b/Makefile index f6f7cd6a..9d7b5525 100644 --- a/Makefile +++ b/Makefile @@ -39,8 +39,9 @@ clean: find . -not -path "./.venv/*" -type f -name "*.h5" -delete changelog: + @test -n "$(RELEASE_DATE)" || (echo "RELEASE_DATE is required by shared release preparation"; exit 1) python .github/bump_version.py - towncrier build --yes --version $$(python -c "import re; print(re.search(r'version = \"(.+?)\"', open('pyproject.toml').read()).group(1))") + towncrier build --yes --version $$(python -c "import re; print(re.search(r'version = \"(.+?)\"', open('pyproject.toml').read()).group(1))") --date "$(RELEASE_DATE)" build-package: python -m build diff --git a/changelog.d/release-candidate-build.changed.md b/changelog.d/release-candidate-build.changed.md new file mode 100644 index 00000000..7773d58e --- /dev/null +++ b/changelog.d/release-candidate-build.changed.md @@ -0,0 +1 @@ +Build unpublished wrapper candidates and publication wheels with a shared frozen toolchain, and verify the reviewed candidate source and wheel before release. diff --git a/docs/engineering/runbooks/wrapper-release-candidates.md b/docs/engineering/runbooks/wrapper-release-candidates.md new file mode 100644 index 00000000..54243890 --- /dev/null +++ b/docs/engineering/runbooks/wrapper-release-candidates.md @@ -0,0 +1,109 @@ +# Wrapper candidate and publication checks + +The nonpublishing `ReleaseCandidate` job produces the stable (`release`) wheel; +`NumericalCandidate` produces the numerical (`rc1`) wheel when an explicit country +artifact is selected. Both use the actual prospective PR merge tree and the +same frozen preparation/build helper as Versioning and Publish. Candidate +artifacts are byte/source evidence; they do not approve numerical results. + +This transition must remain on its reviewed PR until the stable Wf artifact, +ordinary CI and the separately agreed numerical/managed checks are complete. +There is no preliminary merge or bypass flag. Before the merge that starts the +unattended Versioning → sentinel → Publish chain, root and the designated peer +must approve the exact head/base, candidate artifact ID/digest, preparation and +toolchain receipts, and the external numerical/managed receipt. The registry/data +publication sequencing remains governed by the separately approved release plan. + +## Exact source and intentional holds + +- Use a merge commit or squash merge. Rebase merges are unsupported. +- The candidate's recorded PR base must equal the actual current main tip that + becomes the merge's first parent. If main, the PR head, tags, fragments, source, + toolchain, runner image or authenticated remote inputs change, create and + review a new candidate before merging. A rerun of an old event does not refresh + its historical head/base payload. +- No authenticated stable candidate for that exact head/base is an explicit + publication hold. Wf preparation itself remains held until its real published + country/data inputs support the ordinary strict checks. Do not merge while + this hold is active. +- Tag-state and runner-image equality are deliberate gates. Hosted runner image + drift requires a reviewed toolchain update and new candidate evidence, not + a receipt-only exception. The full frozen tool closure and setup-action + commits are in `.github/release-toolchain.json`. +- Only the current successful Actions attempt is accepted. Artifact creation + within that attempt's time interval is the producing-attempt discriminator. + The attempt endpoint describes the same current attempt as the run endpoint, + not a second independent attestation. A newer + successful attempt never makes an earlier failed-attempt artifact acceptable. + Older-attempt artifacts and ambiguous/divergent candidate evidence stop the + chain. Fixed artifact names can require a fresh PR event/run rather than an + old-attempt rerun. Preserve failed evidence and obtain review before replacing + any selected candidate. + +The initial source review recorded PR515 head +`1b6c001c860305d529e91d6f452f3359e29439fa` and main base +`6a56ced4f959ce9a1f3b794389a4b42699de8abc`. These identify the preparation change's +starting point only. Subsequent artifact builds and merge approval must record +their own actual current head/base; these historical values authorize no merge. + +## Country artifact access and source preparation + +The numerical candidate requires a nonempty +`RELEASE_COUNTRY_CANDIDATE_ARTIFACT_ID` naming the actual qualified final-version +country wheel. With no ID, Actions explicitly skips `NumericalCandidate` at job +level. The stable job, Versioning and Publish remain mandatory and never consult +this development input. A supplied invalid, stale or inaccessible ID fails closed; +direct helper invocation without an ID also remains an explicit hold. Clear the +development selection when the numerical qualification window has ended. +The job uses the existing +`APP_ID`/`APP_PRIVATE_KEY` GitHub App with a token restricted to +`PolicyEngine/policyengine-us` and Actions read access; the App installation must +already grant that permission. No token is logged or committed. The stable +candidate ignores this development input and uses ordinary registry evidence. + +Wn extras name the selected final country version even before its publication. +Its separate numerical environment therefore installs the authenticated country +wheel out of band with the frozen common dependencies; a plain registry-only +extras install is not the Wn qualification procedure. No local wheel URI enters +the packaged Wn manifest or either TRO. After every generator, identity verification +requires the ordinary US package descriptor and a name/version-only US model +descriptor, and rejects local file URIs in the final manifest and both records. + +Generation and immediate prebuild verification run in fresh processes that assert +all imported PolicyEngine modules and package resources originate in the prepared +`src` tree. Both TROs must bind the final manifest bytes. Wn's US record is the +limited manifest-only record; its UK record must exactly match a fresh ordinary +UK reconstruction. The receipt records the manifest and both TRO hashes, and +each corresponding built-wheel member must match. Normal stable generation keeps +its strict data/model checks. + +Package and generator inputs must be tracked, including ignored files under +package directories. Untracked source in `src`, `scripts`, `.github` or changelog +inputs fails before preparation/build. Generated Python bytecode and the +backend's `src/policyengine.egg-info` output are excluded from this input check; +they are not candidate policy-source inputs. The sentinel stages tracked updates +and deletions only. It cannot silently add an unauthenticated package input. + +## Evidence retained before publication + +Candidate jobs retain the actual wheel, source archive and preparation receipt. +Versioning retains its authenticated candidate/tree receipt before creating the +sentinel. Publish retains the byte-comparison receipt and requires the actual +sentinel tree and every wheel member to equal the authenticated stable candidate. +Missing evidence or differing bytes stops before the tag or registry upload. + +Bootstrap exports the frozen base dependencies, removes exact-version overlaps +with the validated `.github/release-tools.txt`, and rejects conflicting versions +before installation. That file alone governs overlapping tool hashes. The +toolchain receipt records the actual raw/filtered export hashes, removed pins, +and governing file/hash, freshly derived from the lock for each verification. +Setup, candidate artifact, sentinel and publishing actions are pinned to official +repository commits. Expected API failures report the request path and a bounded +status/access/rate-limit diagnosis without raw stderr, tokens or query strings. + +The helper rechecks PyPI absence after rebuilding, immediately before the tag, +and immediately before upload. Only an authoritative version-endpoint 404 counts +as unpublished; a version record with deleted/empty files remains occupied. +Uploads do not skip existing files. A registry race or API failure stops the +release; no success announcement or automatic fallback is authorized by these +checks. diff --git a/pyproject.toml b/pyproject.toml index 7324d429..04e0facd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=45", "wheel", "setuptools_scm[toml]>=6.2"] +requires = ["setuptools==84.0.0", "wheel==0.48.0", "setuptools-scm==8.3.1", "packaging==25.0"] build-backend = "setuptools.build_meta" [project] diff --git a/scripts/release_build.py b/scripts/release_build.py new file mode 100644 index 00000000..d82d2f0c --- /dev/null +++ b/scripts/release_build.py @@ -0,0 +1,1484 @@ +"""Nonpublishing candidates and the identical, fail-closed release preparation. + +Candidate evidence lives in immutable Actions artifacts, never in the tree it +authenticates. The human/peer merge gate approves numerical qualification; this +helper authenticates CI source/artifact identity and checks release bytes only. +""" + +from __future__ import annotations + +import argparse +import base64 +import csv +import datetime as dt +import hashlib +import importlib.util +import io +import json +import os +import platform +import re +import shutil +import subprocess +import sys +import tempfile +import tomllib +import urllib.error +import urllib.request +import zipfile +from email.parser import BytesParser +from importlib import metadata +from pathlib import Path, PurePosixPath + +ROOT = Path(__file__).resolve().parents[1] +REPOSITORY = "PolicyEngine/policyengine.py" +WORKFLOW = ".github/workflows/pr_code_changes.yaml" +COUNTRY_REPOSITORY = "PolicyEngine/policyengine-us" +COUNTRY_WORKFLOW = ".github/workflows/pr.yaml" +BUNDLE_PATH = Path("src/policyengine/data/bundle/manifest.json") +ARTIFACT_PREFIX = "wrapper-release-candidate-" + + +def sha256(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def canonical(value: object) -> str: + return json.dumps(value, sort_keys=True, separators=(",", ":")) + + +def write_json(path: Path, value: object) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n") + + +def run(root: Path, *command: str, env: dict | None = None) -> None: + subprocess.run(command, cwd=root, env=env, check=True) + + +def git(root: Path, *args: str, env: dict | None = None) -> str: + return subprocess.check_output(["git", *args], cwd=root, env=env, text=True).strip() + + +def load_helper(root: Path, relative: str): + spec = importlib.util.spec_from_file_location( + "_release_" + Path(relative).stem, root / relative + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def predicted_version(root: Path) -> str: + helper = load_helper(root, ".github/bump_version.py") + try: + return helper.bump_version( + helper.get_current_version( + root / "pyproject.toml", root / "CHANGELOG.md", root + ), + helper.infer_bump(root / "changelog.d"), + ) + except SystemExit: + raise ValueError( + "Release build hold: stable version prediction failed" + ) from None + + +def require_unpublished(version: str) -> None: + """Only an authoritative 404 establishes absence, including deleted files.""" + url = f"https://pypi.org/pypi/policyengine/{version}/json" + try: + with urllib.request.urlopen(url, timeout=30) as response: + json.load(response) + except urllib.error.HTTPError as exc: + if exc.code == 404: + return + raise ValueError("Could not verify wrapper registry state") from None + raise ValueError(f"Wrapper version {version} is already published") + + +def reject_untracked_build_inputs(root: Path) -> None: + """Package/generator inputs must belong to the authenticated tracked tree.""" + paths = git( + root, + "ls-files", + "--others", + "-z", + "--", + "src", + "scripts", + ".github", + "changelog.d", + "pyproject.toml", + "uv.lock", + "Makefile", + "CHANGELOG.md", + "README.md", + "MANIFEST.in", + "setup.py", + "setup.cfg", + "LICENSE", + ).split("\0") + unexpected = [ + name + for name in paths + if name + and "__pycache__" not in Path(name).parts + and Path(name).suffix not in {".pyc", ".pyo"} + # Generated editable/backend metadata is not a discovered package. + and Path(name).parts[:2] != ("src", "policyengine.egg-info") + ] + if unexpected: + raise ValueError("Untracked package/build inputs: " + ", ".join(unexpected)) + + +def prepared_tree(root: Path) -> str: + """Snapshot tracked generator outputs, preserving the checkout's index.""" + reject_untracked_build_inputs(root) + with tempfile.TemporaryDirectory() as temporary: + env = {**os.environ, "GIT_INDEX_FILE": str(Path(temporary) / "index")} + git(root, "read-tree", "HEAD", env=env) + git(root, "add", "--update", ".", env=env) + return git(root, "write-tree", env=env) + + +def preparation_inputs(root: Path, head: str) -> dict: + epoch = int(git(root, "show", "-s", "--format=%ct", head)) + return { + "epoch": epoch, + "release_date": dt.datetime.fromtimestamp(epoch, dt.UTC).date().isoformat(), + "tags": git( + root, + "for-each-ref", + "--sort=refname", + "--format=%(refname) %(objectname)", + "refs/tags", + ).splitlines(), + } + + +def controlled_environment(inputs: dict) -> dict[str, str]: + # Prevent inherited installer indexes, user-site packages and locale/time + # defaults from changing either side of the release comparison. + env = { + key: value + for key, value in os.environ.items() + if not key.startswith(("PIP_", "UV_")) and key != "PYTHONPATH" + } + env.update( + { + "PYTHONNOUSERSITE": "1", + "POLICYENGINE_SKIP_COUNTRY_IMPORTS": "1", + "SOURCE_DATE_EPOCH": str(inputs["epoch"]), + "RELEASE_DATE": inputs["release_date"], + "TZ": "UTC", + "LC_ALL": "C.UTF-8", + "UV_NO_CONFIG": "1", + "UV_DEFAULT_INDEX": "https://pypi.org/simple", + } + ) + expected = ( + dt.datetime.fromtimestamp(int(env["SOURCE_DATE_EPOCH"]), dt.UTC) + .date() + .isoformat() + ) + if env["RELEASE_DATE"] != expected: + raise ValueError("Frozen release date differs from SOURCE_DATE_EPOCH") + return env + + +def normalize_requirement_name(name: str) -> str: + return re.sub(r"[-_.]+", "-", name).lower() + + +def validate_tool_requirements(root: Path, config: dict) -> None: + """The installable hash closure and its registry evidence must agree exactly.""" + + expected = {} + for name, item in config["tools"].items(): + key = normalize_requirement_name(name) + hashes = {wheel["sha256"] for wheel in item["wheels"]} + if ( + key in expected + or not hashes + or any( + re.fullmatch(r"[0-9a-f]{64}", digest) is None + for digest in hashes | {item["registry_json_sha256"]} + ) + ): + raise ValueError("Invalid or duplicate frozen tool evidence") + expected[key] = (item["version"], hashes) + actual = {} + for line in (root / ".github/release-tools.txt").read_text().splitlines(): + if not line.strip() or line.lstrip().startswith("#"): + continue + match = re.fullmatch( + r"([A-Za-z0-9][A-Za-z0-9._-]*)==([^\s]+)((?: --hash=sha256:[0-9a-f]{64})+)", + line, + ) + if match is None or normalize_requirement_name(match[1]) in actual: + raise ValueError("Invalid or duplicate frozen tool requirement") + actual[normalize_requirement_name(match[1])] = ( + match[2], + set(re.findall(r"--hash=sha256:([0-9a-f]{64})", match[3])), + ) + if actual != expected: + raise ValueError("Frozen tool requirements differ from registry evidence") + + +def filter_tool_overlaps(exported: str, config: dict) -> tuple[str, list[dict]]: + """Keep base bytes except exact frozen-tool pins, including all their hashes.""" + tools = { + normalize_requirement_name(name): item["version"] + for name, item in config["tools"].items() + } + retained, overlaps, block = [], [], "" + for line in exported.splitlines(keepends=True): + block += line + if line.rstrip().endswith("\\"): + continue + name = re.match(r"([A-Za-z0-9][A-Za-z0-9._-]*)", block) + key = normalize_requirement_name(name[1]) if name else None + if key in tools: + pin = re.match(r"[A-Za-z0-9][A-Za-z0-9._-]*==([^;\s\\]+)", block) + if pin is None or pin[1] != tools[key]: + raise ValueError("Exported base tool pin differs from the frozen tool") + overlaps.append({"name": key, "version": pin[1]}) + else: + retained.append(block) + block = "" + if block: + raise ValueError("Exported base requirement has an unfinished continuation") + return "".join(retained), sorted(overlaps, key=lambda item: item["name"]) + + +def export_base_requirements(root: Path, output: Path, config: dict) -> dict: + """Derive overlap evidence from the actual lock, without operator receipt input.""" + run( + root, + "uv", + "export", + "--frozen", + "--no-dev", + "--no-emit-project", + "--no-header", + "--no-annotate", + "--quiet", + "--format", + "requirements-txt", + "--output-file", + str(output), + env=controlled_environment({"epoch": 0, "release_date": "1970-01-01"}), + ) + exported = output.read_text() + filtered, overlaps = filter_tool_overlaps(exported, config) + output.write_text(filtered) + return { + "exported_base_sha256": sha256(exported.encode()), + "filtered_base_sha256": sha256(filtered.encode()), + "removed_tool_overlaps": overlaps, + "governing_tool_file": ".github/release-tools.txt", + "governing_tool_file_sha256": sha256( + (root / ".github/release-tools.txt").read_bytes() + ), + } + + +def check_toolchain(root: Path) -> dict: + config_path = root / ".github/release-toolchain.json" + config = json.loads(config_path.read_text()) + validate_tool_requirements(root, config) + actions = re.findall( + r"^\s*- uses: (\S+)", + (root / ".github/actions/release-toolchain/action.yml").read_text(), + flags=re.MULTILINE, + ) + if set(actions) != { + name + "@" + item["commit"] for name, item in config["setup_actions"].items() + }: + raise ValueError("Release setup actions differ from the frozen commits") + actual = { + "os": platform.system(), + "arch": os.environ.get("RUNNER_ARCH"), + "image_os": os.environ.get("ImageOS"), + "image_version": os.environ.get("ImageVersion"), + } + if actual != config["runner"] or platform.python_version() != config["python"]: + raise ValueError( + "Release runner image or Python differs from the frozen toolchain" + ) + uv = Path(shutil.which("uv") or "/missing-uv").resolve(strict=True) + uv_version = subprocess.check_output([str(uv), "--version"], text=True).split()[1] + if uv_version != config["uv"]: + raise ValueError("Release uv differs from the frozen toolchain") + installed = {} + for name, item in config["tools"].items(): + installed[name] = metadata.version(name) + if installed[name] != item["version"]: + raise ValueError(f"Release tool {name} differs from the frozen toolchain") + project = tomllib.loads((root / "pyproject.toml").read_text()) + if project["build-system"]["requires"] != config["build_backend_requires"]: + raise ValueError("Build-system requirements differ from the frozen toolchain") + with tempfile.TemporaryDirectory() as temporary: + base_requirements = export_base_requirements( + root, Path(temporary) / "base.txt", config + ) + return { + "config_sha256": sha256(config_path.read_bytes()), + "requirements_sha256": sha256( + (root / ".github/release-tools.txt").read_bytes() + ), + "runner": actual, + "python": platform.python_version(), + "python_binary_sha256": sha256(Path(sys.executable).resolve().read_bytes()), + "uv": uv_version, + "uv_binary_sha256": sha256(uv.read_bytes()), + "tools": installed, + "base_requirements": base_requirements, + } + + +def bootstrap(root: Path, destination: Path) -> None: + """Hash-locked build scaffold; never install model extras or mutate uv.lock.""" + destination = destination.resolve() + config = json.loads((root / ".github/release-toolchain.json").read_text()) + validate_tool_requirements(root, config) + if destination.exists(): + raise ValueError("Build environment destination must be new") + destination.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory() as temporary: + requirements = Path(temporary) / "base.txt" + env = controlled_environment({"epoch": 0, "release_date": "1970-01-01"}) + export_base_requirements(root, requirements, config) + run(root, "uv", "venv", "--python", sys.executable, str(destination), env=env) + python = destination / "bin/python" + run( + root, + "uv", + "pip", + "sync", + "--python", + str(python), + "--require-hashes", + "--only-binary", + ":all:", + str(requirements), + str(root / ".github/release-tools.txt"), + env=env, + ) + run( + root, + "uv", + "pip", + "install", + "--python", + str(python), + "--no-deps", + "--no-build-isolation", + "--editable", + str(root), + env=env, + ) + run( + root, + str(python), + str(root / "scripts/release_build.py"), + "check-toolchain", + env=env, + ) + with Path(os.environ["GITHUB_PATH"]).open("a") as stream: + stream.write(str(destination / "bin") + "\n") + + +def prepare_prerelease_metadata( + root: Path, version: str, country: dict | None = None +) -> None: + if re.fullmatch(r"\d+\.\d+\.\d+rc1", version) is None: + raise ValueError( + "Numerical wrapper candidate must have the predicted rc1 version" + ) + helper = load_helper(root, ".github/bump_version.py") + helper.update_file(root / "pyproject.toml", version) + helper.sync_bundle_versions(root / BUNDLE_PATH, version) + bundle = json.loads((root / BUNDLE_PATH).read_text()) + us = bundle["data_releases"]["us"] + us.pop("certification", None) + us.pop("certified_data_artifact", None) + if country is not None: + descriptor = bundle["packages"]["policyengine-us"] + descriptor["name"] = country["name"] + descriptor["version"] = country["version"] + descriptor.pop("sha256", None) + descriptor.pop("wheel_url", None) + us["model_package"] = {"name": country["name"], "version": country["version"]} + bundle["development"] = { + "purpose": "unpublished numerical wrapper candidate", + "data_certification": "not_certified", + "promotion_status": "pending", + "data_release_metadata": "inherited reference only; no compatibility claim", + } + write_json(root / BUNDLE_PATH, bundle) + + +def candidate_us_tro(root: Path, version: str) -> dict: + """Build a limited US TRACE using the existing graph assembly helpers.""" + sys.path[:0] = [str(root / "scripts"), str(root / "src")] + from policyengine.provenance.trace import ( + POLICYENGINE_ORGANIZATION, + TRACE_CONTEXT, + _assemble_composition_and_arrangement, + _assemble_tro_node, + ) + + manifest = root / BUNDLE_PATH + composition, arrangement = _assemble_composition_and_arrangement( + [ + { + "id": "bundle_manifest", + "hash": sha256(manifest.read_bytes()), + "location": "data/bundle/manifest.json", + "mime_type": "application/json", + "name": "Unpublished candidate manifest; not reviewed for release", + } + ] + ) + node = _assemble_tro_node( + tro_name="PolicyEngine US candidate manifest (unpublished; not reviewed for release)", + tro_description="Record of unpublished candidate bundle-manifest bytes only; not reviewed for release.", + created_at=None, + creator=POLICYENGINE_ORGANIZATION, + software_version=version, + trs_comment="Records unpublished candidate bundle-manifest bytes only; not reviewed for release.", + composition=composition, + arrangement=arrangement, + performance={ + "@id": "trp/1", + "@type": "trov:TransparentResearchPerformance", + "trov:wasConductedBy": {"@id": "trs"}, + "trov:accessedArrangement": {"@id": "arrangement/1"}, + "pe:emittedIn": "repository-bundle", + "rdfs:comment": "Manifest-only byte record for an unpublished candidate; not reviewed for release.", + }, + ) + return {"@context": TRACE_CONTEXT, "@graph": [node]} + + +def candidate_uk_tro() -> dict: + """Use the ordinary UK builder only; never fetch the inherited US release.""" + from policyengine.provenance.manifest import ( + DataReleaseManifestUnavailableError, + get_data_release_manifest, + get_release_manifest, + ) + from policyengine.provenance.trace import build_trace_tro_from_release_bundle + + country = get_release_manifest("uk") + try: + data = get_data_release_manifest("uk") + except DataReleaseManifestUnavailableError: + raise ValueError( + "Release build hold: UK release manifest unavailable" + ) from None + return build_trace_tro_from_release_bundle( + country, + data, + certification=country.certification, + model_wheel_sha256=country.model_package.sha256, + model_wheel_url=country.model_package.wheel_url, + emission_context={"pe:emittedIn": "repository-bundle"}, + ) + + +def assert_source_origin(root: Path) -> dict: + """Reject cached modules or package resources from any other checkout.""" + from importlib.resources import files + + import policyengine + import policyengine.provenance.manifest + import policyengine.provenance.trace + + expected = (root / "src/policyengine").resolve() + resources = Path(str(files("policyengine"))).resolve() + origins = {} + for name, module in tuple(sys.modules.items()): + if name == "policyengine" or name.startswith("policyengine."): + filename = getattr(module, "__file__", None) + if filename is None or not Path(filename).resolve().is_relative_to( + expected + ): + raise ValueError("Prepared package import has the wrong source origin") + origins[name] = str(Path(filename).resolve().relative_to(root.resolve())) + if ( + resources != expected + or Path(policyengine.__file__).resolve().parent != expected + ): + raise ValueError("Prepared package resources have the wrong source origin") + return {"resources": "src/policyengine", "modules": origins} + + +def source_command(root: Path, *args: str, env: dict | None = None) -> dict: + """Each source/resource verification starts in a new interpreter.""" + source_env = { + **(os.environ if env is None else env), + "PYTHONPATH": str(root / "src"), + "PYTHONNOUSERSITE": "1", + "PYTHONDONTWRITEBYTECODE": "1", + "POLICYENGINE_SKIP_COUNTRY_IMPORTS": "1", + } + return json.loads( + subprocess.check_output( + [sys.executable, str(root / "scripts/release_build.py"), *args], + cwd=root, + env=source_env, + text=True, + ) + ) + + +def validate_tro_schema(root: Path, payload: dict, country: str) -> None: + from jsonschema import Draft202012Validator + from jsonschema.exceptions import ValidationError + + validator = Draft202012Validator( + json.loads( + (root / "src/policyengine/data/schemas/trace_tro.schema.json").read_text() + ) + ) + try: + validator.validate(payload) + except ValidationError: + raise ValueError( + f"Release build hold: {country.upper()} TRACE fails schema validation" + ) from None + + +def generate_candidate_tros(root: Path, version: str) -> None: + """Limited US TRACE via existing assembly/schema; ordinary UK generation.""" + assert_source_origin(root) + from policyengine.provenance.trace import serialize_trace_tro + + us, uk = candidate_us_tro(root, version), candidate_uk_tro() + for name, payload in ( + ("us", us), + ("uk", uk), + ): + validate_tro_schema(root, payload, name) + (root / BUNDLE_PATH.parent / f"{name}.trace.tro.jsonld").write_bytes( + serialize_trace_tro(payload) + ) + + +def validate_candidate_descriptors(bundle: dict) -> None: + """Check the final generator output, not just the metadata preparation input.""" + descriptor = bundle["packages"]["policyengine-us"] + version = descriptor["version"] + if descriptor != { + "name": "policyengine-us", + "version": version, + "country": "us", + "import_name": "policyengine_us", + "install_requirement": f"policyengine-us=={version}", + "role": "country_model", + } or bundle["data_releases"]["us"]["model_package"] != { + "name": "policyengine-us", + "version": version, + }: + raise ValueError( + "Candidate country descriptors differ from ordinary packaged shapes" + ) + + +def reject_local_file_uri(payload: dict) -> None: + if "file:" in canonical(payload).lower(): + raise ValueError("Candidate manifest or TRO contains a local file URI") + + +def verify_identity(root: Path, version: str, flavor: str) -> dict: + assert_source_origin(root) + bundle = json.loads((root / BUNDLE_PATH).read_text()) + if flavor == "rc1": + validate_candidate_descriptors(bundle) + reject_local_file_uri(bundle) + manifest_hash = sha256((root / BUNDLE_PATH).read_bytes()) + tro_hashes = {} + values = [ + tomllib.loads((root / "pyproject.toml").read_text())["project"]["version"], + bundle["bundle_version"], + bundle["policyengine_version"], + bundle["packages"]["policyengine"]["version"], + bundle["citation"]["version"], + ] + for country, item in bundle["data_releases"].items(): + values.append(item["policyengine_version"]) + if item["bundle_id"] != f"{country}-{version}": + raise ValueError("Candidate bundle_id differs from its actual version") + payload = json.loads( + (root / BUNDLE_PATH.parent / f"{country}.trace.tro.jsonld").read_text() + ) + if flavor == "rc1": + reject_local_file_uri(payload) + tro = payload["@graph"][0] + values.append(tro["trov:createdWith"]["schema:softwareVersion"]) + manifest_pins = [ + artifact.get("trov:sha256") + for artifact in tro["trov:hasComposition"]["trov:hasArtifact"] + if artifact.get("@id") == "composition/1/artifact/bundle_manifest" + ] + if manifest_pins != [manifest_hash]: + raise ValueError(f"Packaged {country.upper()} TRO manifest hash differs") + tro_hashes[country] = sha256( + (root / BUNDLE_PATH.parent / f"{country}.trace.tro.jsonld").read_bytes() + ) + if any(value != version for value in values): + raise ValueError("Package, manifest, citation or TRO version differs") + if flavor == "rc1": + from policyengine.provenance.trace import serialize_trace_tro + + us = bundle["data_releases"]["us"] + tro = json.loads( + (root / BUNDLE_PATH.parent / "us.trace.tro.jsonld").read_text() + )["@graph"][0] + artifacts = tro["trov:hasComposition"]["trov:hasArtifact"] + if ( + "certification" in us + or "certified_data_artifact" in us + or bundle.get("development", {}).get("data_certification") + != "not_certified" + or len(artifacts) != 1 + or artifacts[0]["@id"] != "composition/1/artifact/bundle_manifest" + or artifacts[0]["trov:sha256"] != sha256((root / BUNDLE_PATH).read_bytes()) + or any( + key.startswith("pe:") and key != "pe:emittedIn" + for key in tro["trov:hasPerformance"] + ) + or tro["trov:hasPerformance"].get("pe:emittedIn") != "repository-bundle" + ): + raise ValueError("Candidate contains an inherited or false certification") + if ( + root / BUNDLE_PATH.parent / "us.trace.tro.jsonld" + ).read_bytes() != serialize_trace_tro(candidate_us_tro(root, version)): + raise ValueError( + "Packaged US candidate TRO differs from the exact limited record" + ) + uk = candidate_uk_tro() + validate_tro_schema(root, uk, "uk") + if ( + root / BUNDLE_PATH.parent / "uk.trace.tro.jsonld" + ).read_bytes() != serialize_trace_tro(uk): + raise ValueError("Packaged UK TRO differs from ordinary UK reconstruction") + elif "development" in bundle: + raise ValueError("Stable release cannot contain development metadata") + return { + "bundle_manifest_sha256": manifest_hash, + "tro_sha256": tro_hashes, + "source_origin": assert_source_origin(root), + } + + +def verify_identity_fresh(root: Path, version: str, flavor: str, env: dict) -> dict: + return source_command( + root, "verify-identity", "--version", version, "--flavor", flavor, env=env + ) + + +def prepare(root: Path, flavor: str, source: dict, country: dict | None = None) -> dict: + inputs = preparation_inputs(root, source["head"]) + env = controlled_environment(inputs) + toolchain = check_toolchain(root) + version = predicted_version(root) + ("rc1" if flavor == "rc1" else "") + require_unpublished(version) + python = sys.executable + if flavor == "release": + run(root, python, "scripts/check_release_credentials.py", env=env) + run( + root, + python, + "scripts/bundle.py", + "check", + "--published-spm", + "--include-tros", + "--strict-tros", + env=env, + ) + run(root, python, "scripts/release_lock.py", env=env) + run( + root, "make", "changelog", f"RELEASE_DATE={inputs['release_date']}", env=env + ) + run(root, python, "scripts/bundle.py", "generate", env=env) + run(root, python, "scripts/release_lock.py", "--refresh", env=env) + run( + root, + python, + "scripts/bundle.py", + "generate", + "--include-tros", + "--strict-tros", + env=env, + ) + run( + root, + python, + "scripts/bundle.py", + "check", + "--published-spm", + "--include-tros", + "--strict-tros", + env=env, + ) + else: + if country is None: + raise ValueError("Unpublished final-country candidate artifact is required") + prepare_prerelease_metadata(root, version, country) + run(root, python, "scripts/bundle.py", "generate", env=env) + run( + root, + python, + "scripts/release_build.py", + "candidate-tros", + "--version", + version, + env=env, + ) + identity = verify_identity_fresh(root, version, flavor, env) + tree = prepared_tree(root) + commit_env = { + **env, + "GIT_AUTHOR_NAME": "PolicyEngine release preparation", + "GIT_AUTHOR_EMAIL": "hello@policyengine.org", + "GIT_COMMITTER_NAME": "PolicyEngine release preparation", + "GIT_COMMITTER_EMAIL": "hello@policyengine.org", + "GIT_AUTHOR_DATE": f"@{inputs['epoch']} +0000", + "GIT_COMMITTER_DATE": f"@{inputs['epoch']} +0000", + } + prepared_commit = git( + root, + "commit-tree", + tree, + "-p", + git(root, "rev-parse", "HEAD"), + "-m", + "Nonpublishing release preparation", + env=commit_env, + ) + receipt = { + "schema_version": 1, + "flavor": flavor, + "version": version, + "source": source, + "preparation_inputs": inputs, + "toolchain": toolchain, + "prepared_tree": tree, + "prepared_local_commit": prepared_commit, + } + receipt["package_identity"] = identity + return receipt + + +def wheel_identity(raw: bytes, version: str, package: str = "policyengine") -> dict: + """Check an immutable byte snapshot, including its complete mechanical RECORD.""" + with zipfile.ZipFile(io.BytesIO(raw)) as archive: + names = archive.namelist() + if len(names) != len(set(names)) or any( + name.startswith("/") + or ".." in PurePosixPath(name).parts + or "\\" in name + or (item.external_attr >> 16) & 0o170000 == 0o120000 + for name, item in zip(names, archive.infolist()) + ): + raise ValueError("Unsafe or duplicate wheel member") + members = {name: archive.read(name) for name in names if not name.endswith("/")} + metadata_names = [name for name in members if name.endswith(".dist-info/METADATA")] + if len(metadata_names) != 1: + raise ValueError("Wheel must have exactly one METADATA") + prefix = metadata_names[0].removesuffix("METADATA") + info = BytesParser().parsebytes(members[metadata_names[0]]) + if info["Name"].lower().replace("_", "-") != package or info["Version"] != version: + raise ValueError("Wheel package or version differs from the selected candidate") + if prefix + "WHEEL" not in members or prefix + "RECORD" not in members: + raise ValueError("Wheel lacks WHEEL or RECORD") + record = list(csv.reader(io.StringIO(members[prefix + "RECORD"].decode()))) + if ( + any(len(row) != 3 for row in record) + or len(record) != len(members) + or {row[0] for row in record} != set(members) + ): + raise ValueError("Wheel RECORD membership differs") + for name, digest, size in record: + if name == prefix + "RECORD": + if digest or size: + raise ValueError("Wheel RECORD self entry must be empty") + elif digest != "sha256=" + base64.urlsafe_b64encode( + hashlib.sha256(members[name]).digest() + ).rstrip(b"=").decode() or size != str(len(members[name])): + raise ValueError(f"Wheel RECORD differs for {name}") + return { + "sha256": sha256(raw), + "size_bytes": len(raw), + "name": package, + "version": version, + "members": { + name: {"sha256": sha256(data), "size": len(data)} + for name, data in sorted(members.items()) + }, + } + + +def build(root: Path, receipt: dict, output: Path) -> dict: + reject_untracked_build_inputs(root) + output.mkdir(parents=True, exist_ok=False) + env = controlled_environment(receipt["preparation_inputs"]) + run( + root, + "uv", + "pip", + "install", + "--python", + sys.executable, + "--no-deps", + "--no-build-isolation", + "--editable", + str(root), + env=env, + ) + actual_version = subprocess.check_output( + [sys.executable, ".github/fetch_version.py"], cwd=root, env=env, text=True + ).strip() + if actual_version != receipt["version"]: + raise ValueError("Installed prepared wrapper version differs") + identity = verify_identity_fresh(root, receipt["version"], receipt["flavor"], env) + if identity != receipt["package_identity"]: + raise ValueError("Prebuild package identity differs from prepared evidence") + if receipt["flavor"] == "release": + install_model_metadata(root, env) + run(root, "bash", ".github/capture-version.sh", env=env) + # No isolated resolver: the frontend/backend closure was hash-installed. + run( + root, + sys.executable, + "-m", + "build", + "--wheel", + "--no-isolation", + "--outdir", + str(output), + env=env, + ) + wheels = list(output.glob("*.whl")) + if len(wheels) != 1: + raise ValueError("Build must produce exactly one candidate wheel") + receipt["wheel"] = { + "filename": wheels[0].name, + **wheel_identity(wheels[0].read_bytes(), receipt["version"]), + } + verify_wheel_package_identity(receipt) + if prepared_tree(root) != receipt["prepared_tree"]: + raise ValueError("Package build mutated tracked prepared inputs") + return receipt + + +def verify_wheel_package_identity(receipt: dict) -> None: + expected = { + "policyengine/data/bundle/manifest.json": receipt["package_identity"][ + "bundle_manifest_sha256" + ], + **{ + f"policyengine/data/bundle/{country}.trace.tro.jsonld": digest + for country, digest in receipt["package_identity"]["tro_sha256"].items() + }, + } + for name, digest in expected.items(): + if receipt["wheel"]["members"].get(name, {}).get("sha256") != digest: + raise ValueError( + f"Built wheel {name} differs from prepared package identity" + ) + + +def install_model_metadata(root: Path, env: dict) -> None: + """Install exact model wheels for the existing packages-only release check. + + This is a build scaffold, not a microsimulation runtime. The complete model + dependency graph remains authenticated by release_lock and is exercised in + the separate numerical qualification environment. + """ + bundle = json.loads((root / BUNDLE_PATH).read_text()) + lock = tomllib.loads((root / "uv.lock").read_text()) + guard = load_helper(root, "scripts/release_lock.py") + guard.validate_registry_lock( + tomllib.loads((root / "pyproject.toml").read_text()), lock + ) + lines = [] + for name in bundle["extras"]["models"]: + component = bundle["packages"][name] + matches = [ + item + for item in lock["package"] + if item["name"] == component["name"] + and item["version"] == component["version"] + ] + if len(matches) != 1 or not matches[0].get("wheels"): + raise ValueError( + "Model metadata must use the exact reviewed registry wheel" + ) + hashes = sorted({wheel["hash"] for wheel in matches[0]["wheels"]}) + lines.append( + f"{component['name']}=={component['version']} " + + " ".join("--hash=" + digest for digest in hashes) + ) + with tempfile.TemporaryDirectory() as temporary: + requirements = Path(temporary) / "models.txt" + requirements.write_text("\n".join(lines) + "\n") + run( + root, + "uv", + "pip", + "install", + "--python", + sys.executable, + "--no-deps", + "--require-hashes", + "--only-binary", + ":all:", + "-r", + str(requirements), + env=env, + ) + + +def github(path: str, *, binary: bool = False): + """gh authenticates GitHub API/redirects; never accept an operator download URL.""" + result = subprocess.run( + ["gh", "api", "--method", "GET", path], + capture_output=True, + ) + if result.returncode: + # Classify known diagnostics without forwarding raw stderr, credentials, + # response bodies or operator query strings into public Actions logs. + diagnostic = result.stderr.decode(errors="replace").lower() + status = re.search(r"http (\d{3})", diagnostic) + category = "request failed" + if "rate limit" in diagnostic or (status and status[1] == "429"): + category = "rate limit; retry after the GitHub limit resets" + elif status and status[1] in {"401", "403"}: + category = ( + "access denied; check the App installation and Actions read permission" + ) + elif status and status[1] == "404": + category = ( + "not found or inaccessible; check artifact ID and repository access" + ) + elif "gh auth login" in diagnostic: + category = "authentication unavailable" + request_path = path.split("?", 1)[0] + if ( + re.fullmatch( + r"repos/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/[A-Za-z0-9_./-]+", request_path + ) + is None + ): + request_path = "GitHub API request" + code = "HTTP " + status[1] if status else f"exit {result.returncode}" + raise ValueError( + f"Authenticated GitHub artifact lookup failed ({code}; {category}): {request_path}" + ) + return result.stdout if binary else json.loads(result.stdout) + + +def pages(path: str, key: str) -> list: + rows = [] + for page in range(1, 101): + data = github( + path + ("&" if "?" in path else "?") + f"per_page=100&page={page}" + ) + batch = data[key] if key else data + rows.extend(batch) + if len(batch) < 100: + return rows + raise ValueError("GitHub pagination exceeded the bounded release lookup") + + +def authenticated_artifact( + repository: str, artifact_id: int, workflow: str +) -> tuple[dict, dict, dict[str, bytes]]: + artifact = github(f"repos/{repository}/actions/artifacts/{artifact_id}") + run_info = github( + f"repos/{repository}/actions/runs/{artifact['workflow_run']['id']}" + ) + if ( + artifact.get("expired") + or artifact.get("id") != artifact_id + or run_info.get("status") != "completed" + or run_info.get("conclusion") != "success" + or run_info.get("event") != "pull_request" + or run_info.get("path", "").split("@")[0] != workflow + or run_info.get("repository", {}).get("full_name") != repository + or run_info.get("head_repository", {}).get("full_name") != repository + or artifact["workflow_run"].get("head_sha") != run_info.get("head_sha") + ): + raise ValueError( + "Artifact is not from the required successful repository PR workflow" + ) + # Conservatively require the artifact to belong to the current successful + # attempt. The artifact creation interval below is the actual discriminator; + # the attempt endpoint describes the same current attempt as run_info, not + # an independent producing-attempt attestation. A later rerun never makes an + # earlier failed attempt acceptable. + attempt_number = run_info.get("run_attempt") + if type(attempt_number) is not int or attempt_number < 1: + raise ValueError("Artifact run has no authenticated current attempt") + attempt = github( + f"repos/{repository}/actions/runs/{run_info['id']}/attempts/{attempt_number}" + ) + for key in ( + "id", + "run_attempt", + "status", + "conclusion", + "event", + "path", + "head_sha", + ): + if attempt.get(key) != run_info.get(key): + raise ValueError("Artifact producing attempt differs from successful run") + if any( + attempt.get(key, {}).get("full_name") != repository + for key in ("repository", "head_repository") + ): + raise ValueError("Artifact producing attempt has a different repository") + try: + started = dt.datetime.fromisoformat(attempt["run_started_at"]) + created = dt.datetime.fromisoformat(artifact["created_at"]) + ended = dt.datetime.fromisoformat(attempt["updated_at"]) + if not started <= created <= ended: + raise ValueError( + "Artifact was not produced in the current successful attempt" + ) + except (KeyError, TypeError) as exc: + raise ValueError("Artifact attempt timestamps are unavailable") from exc + raw = github(f"repos/{repository}/actions/artifacts/{artifact_id}/zip", binary=True) + if artifact.get("digest") != "sha256:" + sha256(raw): + raise ValueError("Artifact bytes differ from the authenticated GitHub digest") + with zipfile.ZipFile(io.BytesIO(raw)) as archive: + names = archive.namelist() + if len(names) != len(set(names)) or any( + name.startswith("/") or ".." in PurePosixPath(name).parts or "\\" in name + for name in names + ): + raise ValueError("Unsafe or duplicate artifact member") + members = {name: archive.read(name) for name in names if not name.endswith("/")} + return artifact, run_info, members + + +def country_component( + root: Path, artifact_id: str, destination: Path +) -> tuple[dict, dict]: + if not artifact_id or not artifact_id.isdecimal(): + raise ValueError( + "Release build hold: final country candidate artifact ID is required" + ) + artifact, run_info, members = authenticated_artifact( + COUNTRY_REPOSITORY, int(artifact_id), COUNTRY_WORKFLOW + ) + receipt = json.loads(members["release-build/receipt.json"]) + selected = json.loads((root / BUNDLE_PATH).read_text())["packages"][ + "policyengine-us" + ]["version"] + if ( + re.fullmatch(r"\d+\.\d+\.\d+", selected) is None + or receipt.get("prepared_version") != selected + ): + raise ValueError( + "Country artifact must be the selected final version, never rc1" + ) + if ( + receipt.get("source_head") != run_info["head_sha"] + or receipt.get("policy_source_matches_head") is not True + ): + raise ValueError("Country CI receipt/source identity differs") + filename = receipt["wheel"]["filename"] + if Path(filename).name != filename: + raise ValueError("Invalid country wheel filename") + raw = members["dist/" + filename] + identity = wheel_identity(raw, selected, "policyengine-us") + if ( + identity["sha256"] != receipt["wheel"]["sha256"] + or identity["size_bytes"] != receipt["wheel"]["size_bytes"] + ): + raise ValueError("Country wheel differs from its authenticated CI receipt") + destination.mkdir(parents=True, exist_ok=False) + path = destination / filename + path.write_bytes(raw) + component = source_command(root, "country-wheel-component", "--wheel", str(path)) + return component, { + "repository": COUNTRY_REPOSITORY, + "artifact_id": artifact["id"], + "artifact_digest": artifact["digest"], + "run_id": run_info["id"], + "run_attempt": run_info["run_attempt"], + "attempt_control": "artifact_creation_within_current_successful_attempt_interval", + "source_head": receipt["source_head"], + "receipt_sha256": sha256(members["release-build/receipt.json"]), + "component": component, + "wheel": identity, + } + + +def source_from_event(root: Path) -> dict: + if os.environ.get("GITHUB_EVENT_NAME") != "pull_request": + raise ValueError("Candidate builds require the actual pull_request event") + event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text()) + pr = event["pull_request"] + if ( + pr["head"]["repo"]["full_name"] != REPOSITORY + or pr["base"]["repo"]["full_name"] != REPOSITORY + ): + raise ValueError("Candidate source must be a same-repository PR") + head, base = pr["head"]["sha"], pr["base"]["sha"] + parents = git(root, "show", "-s", "--format=%P", "HEAD").split() + if ( + parents != [base, head] + or git(root, "rev-parse", "HEAD") != os.environ["GITHUB_SHA"] + ): + raise ValueError("Candidate checkout is not the exact prospective PR merge") + return { + "pr": event["number"], + "head": head, + "base": base, + "merged_tree": git(root, "rev-parse", "HEAD^{tree}"), + } + + +def source_from_merge(root: Path, merge_commit: str) -> dict: + """Accept merge/squash results qualified against their exact first-parent base. + + Rebase merging is unsupported. A changed main base requires a new candidate + and root/peer agreement before merge, never stale-base artifact reuse. + """ + prs = pages(f"repos/{REPOSITORY}/commits/{merge_commit}/pulls", "") + eligible = [ + pr + for pr in prs + if pr.get("merged_at") + and pr.get("merge_commit_sha") == merge_commit + and pr["base"]["ref"] == "main" + ] + if len(eligible) != 1: + raise ValueError("Release build hold: no unique merged PR for this source") + pr = eligible[0] + return { + "pr": pr["number"], + "head": pr["head"]["sha"], + "base": git(root, "rev-parse", merge_commit + "^1"), + "merged_tree": git(root, "rev-parse", merge_commit + "^{tree}"), + } + + +def select_candidate(candidates: list[dict]) -> dict: + if not candidates: + raise ValueError( + "Release build hold: no authenticated qualifying candidate artifact" + ) + fingerprints = { + canonical( + { + key: item["receipt"][key] + for key in ( + "source", + "version", + "flavor", + "prepared_tree", + "preparation_inputs", + "toolchain", + "package_identity", + "wheel", + ) + } + ) + for item in candidates + } + if len(fingerprints) != 1: + raise ValueError( + "Multiple divergent eligible candidate artifacts; re-review required" + ) + # Repeated identical preparations have one deterministic selected identity. + return min(candidates, key=lambda item: item["artifact_id"]) + + +def discover_candidate(source: dict) -> dict: + expected_name = f"{ARTIFACT_PREFIX}{source['head']}-{source['base']}-release" + artifacts = pages( + f"repos/{REPOSITORY}/actions/artifacts?name={expected_name}", "artifacts" + ) + candidates = [] + for item in artifacts: + if item.get("expired"): + continue + artifact, run_info, members = authenticated_artifact( + REPOSITORY, item["id"], WORKFLOW + ) + receipt = json.loads(members["receipt.json"]) + prs = run_info.get("pull_requests", []) + # GitHub's PR-reference head/base SHA fields change when the PR moves. + # run.head_sha is immutable. The reviewed job independently records + # its event's base and actual merge-parent/tree checks in the artifact. + if run_info.get("head_sha") != source["head"] or not any( + pr.get("number") == source["pr"] for pr in prs + ): + raise ValueError( + "Candidate run does not bind the exact reviewed PR head/base" + ) + if ( + artifact["name"] != expected_name + or receipt.get("source") != source + or receipt.get("flavor") != "release" + ): + raise ValueError( + "Candidate receipt/source differs from authenticated workflow identity" + ) + ci = receipt.get("ci", {}) + if ( + ci.get("run_id") != run_info["id"] + or ci.get("run_attempt") != run_info["run_attempt"] + or ci.get("repository") != REPOSITORY + or ci.get("workflow") != WORKFLOW + ): + raise ValueError("Candidate CI receipt run identity differs") + filename = receipt["wheel"]["filename"] + raw = members["dist/" + filename] + if {"filename": filename, **wheel_identity(raw, receipt["version"])} != receipt[ + "wheel" + ]: + raise ValueError( + "Candidate wheel/RECORD differs from authenticated receipt" + ) + candidates.append( + { + "artifact_id": artifact["id"], + "artifact_digest": artifact["digest"], + "run_id": run_info["id"], + "attempt_control": "artifact_creation_within_current_successful_attempt_interval", + "receipt": receipt, + "receipt_sha256": sha256(members["receipt.json"]), + } + ) + if not candidates: + raise ValueError( + "Release publication hold: no authenticated stable candidate for exact " + f"PR {source['pr']} head {source['head']} and base {source['base']}. " + "Finish Wf qualification, rerun against the current base, and obtain " + "root/peer agreement before a merge or squash merge; rebase merges " + "are unsupported." + ) + return select_candidate(candidates) + + +def verify_prepared_receipt(actual: dict, expected: dict) -> None: + for key in ( + "source", + "flavor", + "version", + "preparation_inputs", + "toolchain", + "prepared_tree", + "package_identity", + ): + if actual.get(key) != expected.get(key): + raise ValueError(f"Release {key} differs from the authenticated candidate") + + +def candidate_build(root: Path, flavor: str, output: Path) -> None: + source = source_from_event(root) + if git(root, "status", "--porcelain", "--untracked-files=no"): + raise ValueError("Candidate checkout must be clean") + reject_untracked_build_inputs(root) + if output.exists(): + raise ValueError("Candidate output must be new") + output.mkdir(parents=True) + with tempfile.TemporaryDirectory(prefix="wrapper-preparation-") as temporary: + prepared = Path(temporary) / "source" + run(root, "git", "clone", "--quiet", "--no-hardlinks", str(root), str(prepared)) + git(prepared, "checkout", "--detach", git(root, "rev-parse", "HEAD")) + # Switch editable metadata/import resolution to this isolated source. + env = controlled_environment(preparation_inputs(prepared, source["head"])) + run( + prepared, + "uv", + "pip", + "install", + "--python", + sys.executable, + "--no-deps", + "--no-build-isolation", + "--editable", + str(prepared), + env=env, + ) + country, evidence = None, None + if flavor == "rc1": + country, evidence = country_component( + prepared, + os.environ.get("RELEASE_COUNTRY_CANDIDATE_ARTIFACT_ID", ""), + output / "country", + ) + receipt = prepare(prepared, flavor, source, country) + receipt["ci"] = { + "repository": REPOSITORY, + "workflow": WORKFLOW, + "run_id": int(os.environ["GITHUB_RUN_ID"]), + "run_attempt": int(os.environ["GITHUB_RUN_ATTEMPT"]), + } + if evidence is not None: + receipt["country_candidate"] = evidence + build(prepared, receipt, output / "dist") + write_json(output / "receipt.json", receipt) + # A portable exact tracked-source snapshot is review evidence, never + # loaded as instructions or substituted for a trusted source checkout. + with (output / "prepared-source.tar").open("wb") as stream: + subprocess.run( + ["git", "archive", receipt["prepared_tree"]], + cwd=prepared, + stdout=stream, + check=True, + ) + + +def versioning(root: Path, output: Path) -> None: + if git(root, "status", "--porcelain", "--untracked-files=no"): + raise ValueError("Versioning checkout must be clean") + source = source_from_merge(root, git(root, "rev-parse", "HEAD")) + candidate = discover_candidate(source) + receipt = prepare(root, "release", source) + verify_prepared_receipt(receipt, candidate["receipt"]) + write_json(output, {"candidate": candidate, "actual_preparation": receipt}) + + +def publish_check(root: Path, output: Path) -> None: + sentinel = git(root, "rev-parse", "HEAD") + if git(root, "show", "-s", "--format=%s", sentinel) != "Update package version": + raise ValueError("Publication requires the ordinary Versioning sentinel") + if len(git(root, "show", "-s", "--format=%P", sentinel).split()) != 1: + raise ValueError("Versioning sentinel must have exactly one parent") + source = source_from_merge(root, git(root, "rev-parse", "HEAD^1")) + candidate = discover_candidate(source) + expected = candidate["receipt"] + actual = { + "schema_version": 1, + "flavor": "release", + "source": source, + "version": tomllib.loads((root / "pyproject.toml").read_text())["project"][ + "version" + ], + "preparation_inputs": preparation_inputs(root, source["head"]), + "toolchain": check_toolchain(root), + "prepared_tree": prepared_tree(root), + } + env = controlled_environment(actual["preparation_inputs"]) + actual["package_identity"] = verify_identity_fresh( + root, actual["version"], "release", env + ) + verify_prepared_receipt(actual, expected) + run(root, sys.executable, "scripts/check_release_credentials.py", env=env) + run( + root, + sys.executable, + "scripts/bundle.py", + "check", + "--published-spm", + "--include-tros", + "--strict-tros", + env=env, + ) + run(root, sys.executable, "scripts/release_lock.py", env=env) + build(root, actual, root / "dist") + if actual["wheel"]["members"] != expected["wheel"]["members"]: + raise ValueError("Rebuilt release wheel members differ from qualified Wf") + require_unpublished(actual["version"]) + write_json( + output, + { + "candidate": candidate, + "actual_release": actual, + "actual_sentinel_commit": sentinel, + "member_equality": True, + "container_equal": actual["wheel"]["sha256"] == expected["wheel"]["sha256"], + }, + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "command", + choices=[ + "bootstrap", + "check-toolchain", + "candidate", + "candidate-tros", + "verify-identity", + "country-wheel-component", + "require-unpublished", + "versioning", + "publish-check", + ], + ) + parser.add_argument("--output", type=Path) + parser.add_argument("--flavor", choices=["rc1", "release"]) + parser.add_argument("--version") + parser.add_argument("--wheel", type=Path) + args = parser.parse_args() + try: + if args.command == "check-toolchain": + print(json.dumps(check_toolchain(ROOT), sort_keys=True)) + elif args.command == "candidate-tros": + if not args.version: + raise ValueError("Candidate TRO version is required") + generate_candidate_tros(ROOT, args.version) + elif args.command == "verify-identity": + if not args.version or not args.flavor: + raise ValueError("Identity verification requires version and flavor") + print(json.dumps(verify_identity(ROOT, args.version, args.flavor))) + elif args.command == "country-wheel-component": + if args.wheel is None: + raise ValueError("Country component requires an actual wheel") + assert_source_origin(ROOT) + sys.path.insert(0, str(ROOT / "scripts")) + component = load_helper( + ROOT, "scripts/spm_bundle.py" + ).local_wheel_component(args.wheel, "policyengine-us") + assert_source_origin(ROOT) + print(json.dumps(component)) + elif args.command == "require-unpublished": + require_unpublished( + tomllib.loads((ROOT / "pyproject.toml").read_text())["project"][ + "version" + ] + ) + else: + if args.output is None: + raise ValueError("An explicit external output path is required") + output = args.output.resolve() + if output.is_relative_to(ROOT) and args.command != "publish-check": + raise ValueError( + "Evidence/build environment must stay outside tracked source" + ) + if args.command == "bootstrap": + bootstrap(ROOT, output) + elif args.command == "candidate": + if args.flavor is None: + raise ValueError("Candidate flavor is required") + candidate_build(ROOT, args.flavor, output) + elif args.command == "versioning": + versioning(ROOT, output) + else: + publish_check(ROOT, output) + except ( + ValueError, + KeyError, + OSError, + subprocess.CalledProcessError, + zipfile.BadZipFile, + ) as exc: + parser.error(str(exc)) + + +if __name__ == "__main__": + main() diff --git a/tests/test_certify_data_release.py b/tests/test_certify_data_release.py index 9b25a1db..c9ec9b08 100644 --- a/tests/test_certify_data_release.py +++ b/tests/test_certify_data_release.py @@ -632,10 +632,19 @@ def fake_main(argv: list[str]) -> int: "--skip-artifact-check", ] - def test__given_missing_populace_us_source_coverage__then_raises(self, tmp_path): + @pytest.mark.parametrize("producer_name", ["populace-data", "microcosm-data"]) + @pytest.mark.parametrize("check_artifacts", [True, False]) + def test__given_missing_populace_us_source_coverage__then_raises( + self, tmp_path, producer_name, check_artifacts + ): + payload = ( + _source_enrichment_manifest_payload() + if producer_name == "microcosm-data" + else _release_manifest_payload() + ) response = MagicMock() response.status_code = 200 - response.content = json.dumps(_release_manifest_payload()).encode() + response.content = json.dumps(payload).encode() with ( patch( @@ -645,7 +654,7 @@ def test__given_missing_populace_us_source_coverage__then_raises(self, tmp_path) patch( "policyengine.provenance.certification.head_release_file", return_value=False, - ), + ) as coverage_head, pytest.raises(CertificationError, match="us_source_coverage.json"), ): certify_data_release( @@ -653,8 +662,15 @@ def test__given_missing_populace_us_source_coverage__then_raises(self, tmp_path) data_producer="populace", manifest_uri=MANIFEST_URI, model_version="1.723.0", + bundle_path=tmp_path / "manifest.json", + check_artifacts=check_artifacts, ) + coverage_head.assert_called_once_with( + parse_manifest_uri(MANIFEST_URI), "us_source_coverage.json", token=None + ) + assert not (tmp_path / "manifest.json").exists() + def test__given_unreachable_artifact__then_raises(self, tmp_path): response = MagicMock() response.status_code = 200 @@ -686,10 +702,19 @@ def test__given_unreachable_artifact__then_raises(self, tmp_path): model_version="1.723.0", ) - def test__given_unreachable_vendored_artifact__then_raises(self, tmp_path): + @pytest.mark.parametrize("producer_name", ["populace-data", "microcosm-data"]) + def test__given_unreachable_vendored_artifact__then_raises( + self, tmp_path, producer_name + ): + payload = ( + _source_enrichment_manifest_payload() + if producer_name == "microcosm-data" + else _release_manifest_payload() + ) response = MagicMock() response.status_code = 200 - response.content = json.dumps(_release_manifest_payload()).encode() + response.content = json.dumps(payload).encode() + coverage_path = f"releases/{TAG}/us_source_coverage.json" with ( patch( @@ -710,17 +735,28 @@ def test__given_unreachable_vendored_artifact__then_raises(self, tmp_path): ), patch( "policyengine.provenance.certification.head_artifact_reference", - return_value=False, + side_effect=lambda reference, *_args, **_kwargs: ( + reference["path"] != coverage_path + ), + ) as artifact_head, + pytest.raises( + CertificationError, match="Vendored artifact 'us_source_coverage'" ), - pytest.raises(CertificationError, match="Vendored artifact"), ): certify_data_release( country="us", data_producer="populace", manifest_uri=MANIFEST_URI, model_version="1.723.0", + bundle_path=tmp_path / "manifest.json", ) + checked_paths = [call.args[0]["path"] for call in artifact_head.call_args_list] + assert checked_paths.count(coverage_path) == 1 + assert "populace_us_2024.h5" in checked_paths + assert "populace_us_2024_calibration.npz" in checked_paths + assert not (tmp_path / "manifest.json").exists() + class TestVendoredSidecarBinding: def test__given_vendored_bundle_manifest__then_tro_sidecar_binds_it(self): diff --git a/tests/test_release_build.py b/tests/test_release_build.py new file mode 100644 index 00000000..889c1003 --- /dev/null +++ b/tests/test_release_build.py @@ -0,0 +1,1117 @@ +"""Small release-boundary controls; no country simulation or publication.""" + +import base64 +import copy +import csv +import hashlib +import importlib.util +import io +import json +import subprocess +import zipfile +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location( + "release_build", ROOT / "scripts/release_build.py" +) +release = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(release) + + +def wheel_bytes(version="6.0.0", changed=False, package="policyengine"): + prefix = f"{package.replace('-', '_')}-{version}.dist-info/" + members = { + "policyengine/__init__.py": b"value = 1\n", + prefix + + "METADATA": f"Metadata-Version: 2.4\nName: {package}\nVersion: {version}\n".encode(), + prefix + "WHEEL": b"Wheel-Version: 1.0\nGenerator: frozen\nTag: py3-none-any\n", + } + record = io.StringIO() + writer = csv.writer(record, lineterminator="\n") + for name, data in members.items(): + digest = base64.urlsafe_b64encode(hashlib.sha256(data).digest()).rstrip(b"=") + writer.writerow([name, "sha256=" + digest.decode(), len(data)]) + writer.writerow([prefix + "RECORD", "", ""]) + members[prefix + "RECORD"] = record.getvalue().encode() + if changed: + members["policyengine/__init__.py"] = b"value = 2\n" + output = io.BytesIO() + with zipfile.ZipFile(output, "w") as archive: + for name, data in members.items(): + archive.writestr(name, data) + return output.getvalue() + + +def test_wheel_record_authenticates_every_member(): + actual = release.wheel_identity(wheel_bytes(), "6.0.0") + assert actual["version"] == "6.0.0" + assert len(actual["members"]) == 4 + with pytest.raises(ValueError, match="RECORD"): + release.wheel_identity(wheel_bytes(changed=True), "6.0.0") + with pytest.raises(ValueError, match="version"): + release.wheel_identity(wheel_bytes(), "6.0.0rc1") + + +def test_wheel_rejects_duplicate_and_escaping_members(): + for name in ("../escape", "policyengine/__init__.py"): + output = io.BytesIO(wheel_bytes()) + with zipfile.ZipFile(output, "a") as archive: + archive.writestr(name, b"bad") + with pytest.raises(ValueError): + release.wheel_identity(output.getvalue(), "6.0.0") + + +@pytest.fixture +def source(tmp_path): + root = tmp_path / "source" + root.mkdir() + subprocess.run(["git", "init", "-q", str(root)], check=True) + subprocess.run(["git", "config", "user.name", "Fixture"], cwd=root, check=True) + subprocess.run( + ["git", "config", "user.email", "fixture@example.invalid"], cwd=root, check=True + ) + (root / ".github").mkdir() + (root / ".github/bump_version.py").write_bytes( + (ROOT / ".github/bump_version.py").read_bytes() + ) + (root / "pyproject.toml").write_text( + '[project]\nname="policyengine"\nversion="5.3.1"\n' + ) + (root / "CHANGELOG.md").write_text("## [5.3.1]\n") + (root / "changelog.d").mkdir() + (root / "changelog.d/change.breaking.md").write_text("Change\n") + path = root / release.BUNDLE_PATH + path.parent.mkdir(parents=True) + path.write_text( + json.dumps( + { + "bundle_version": "5.3.1", + "policyengine_version": "5.3.1", + "packages": { + "policyengine": {"name": "policyengine", "version": "5.3.1"}, + "policyengine-us": { + "name": "policyengine-us", + "version": "2.0.2", + "country": "us", + "import_name": "policyengine_us", + "role": "country_model", + }, + }, + "data_releases": { + "us": { + "policyengine_version": "5.3.1", + "bundle_id": "us-5.3.1", + "certification": {"fake": True}, + "certified_data_artifact": {"old": True}, + "model_package": { + "name": "policyengine-us", + "version": "2.0.2", + }, + }, + "uk": { + "policyengine_version": "5.3.1", + "bundle_id": "uk-5.3.1", + "certification": {"real": True}, + }, + }, + } + ) + ) + for country in ("us", "uk"): + (path.parent / f"{country}.trace.tro.jsonld").write_text("{}") + schema = root / "src/policyengine/data/schemas/trace_tro.schema.json" + schema.parent.mkdir(parents=True) + schema.write_bytes( + (ROOT / "src/policyengine/data/schemas/trace_tro.schema.json").read_bytes() + ) + subprocess.run(["git", "add", "."], cwd=root, check=True) + subprocess.run(["git", "commit", "-qm", "Fixture"], cwd=root, check=True) + return root + + +def test_prerelease_uses_real_prediction_and_syncs_every_identity(source): + assert release.predicted_version(source) == "6.0.0" + release.prepare_prerelease_metadata(source, "6.0.0rc1") + bundle = json.loads((source / release.BUNDLE_PATH).read_text()) + assert bundle["bundle_version"] == bundle["policyengine_version"] == "6.0.0rc1" + assert bundle["packages"]["policyengine"]["version"] == "6.0.0rc1" + for country, manifest in bundle["data_releases"].items(): + assert manifest["policyengine_version"] == "6.0.0rc1" + assert manifest["bundle_id"] == f"{country}-6.0.0rc1" + assert "certification" not in bundle["data_releases"]["us"] + assert "certified_data_artifact" not in bundle["data_releases"]["us"] + assert bundle["data_releases"]["uk"]["certification"] == {"real": True} + assert bundle["development"]["data_certification"] == "not_certified" + + +def test_prepared_tree_captures_deletions_without_staging_operator_files(source): + before = release.git(source, "write-tree") + fragment = source / "changelog.d/change.breaking.md" + fragment.unlink() + (source / "CHANGELOG.md").write_text("New release\n") + (source / "operator-receipt.json").write_text("untrusted") + prepared = release.prepared_tree(source) + assert prepared != before + assert release.git(source, "write-tree") == before + names = release.git(source, "ls-tree", "-r", "--name-only", prepared).splitlines() + assert "changelog.d/change.breaking.md" not in names + assert "operator-receipt.json" not in names + + +def candidate(artifact_id=11): + return { + "artifact_id": artifact_id, + "artifact_digest": "sha256:" + "a" * 64, + "receipt": { + "flavor": "release", + "version": "6.0.0", + "source": { + "pr": 515, + "head": "h" * 40, + "base": "b" * 40, + "merged_tree": "m" * 40, + }, + "prepared_tree": "p" * 40, + "preparation_inputs": {"tags": ["5.3.1"], "epoch": 100}, + "toolchain": {"version": "frozen"}, + "package_identity": {}, + "wheel": { + "sha256": "w" * 64, + "members": {"payload": {"sha256": "x" * 64, "size": 1}}, + }, + }, + } + + +def test_candidate_selection_is_deterministic_and_rejects_divergence(): + first, repeated = candidate(), candidate(12) + assert release.select_candidate([repeated, first])["artifact_id"] == 11 + changed = copy.deepcopy(repeated) + changed["receipt"]["wheel"]["sha256"] = "z" * 64 + with pytest.raises(ValueError, match="divergent"): + release.select_candidate([first, changed]) + with pytest.raises(ValueError, match="candidate"): + release.select_candidate([]) + + +@pytest.mark.parametrize("field", ["head", "base", "merged_tree"]) +def test_publication_rejects_wrong_source_even_with_a_matching_wheel(field): + expected = candidate()["receipt"] + actual = copy.deepcopy(expected) + actual["source"][field] = "other" + with pytest.raises(ValueError, match="source"): + release.verify_prepared_receipt(actual, expected) + + +@pytest.mark.parametrize("field", ["prepared_tree", "preparation_inputs", "toolchain"]) +def test_publication_rejects_preparation_or_toolchain_drift(field): + expected = candidate()["receipt"] + actual = copy.deepcopy(expected) + actual[field] = "other" + with pytest.raises(ValueError): + release.verify_prepared_receipt(actual, expected) + + +def test_release_workflow_guards_before_public_side_effects(): + import yaml + + workflow = yaml.safe_load((ROOT / ".github/workflows/push.yaml").read_text()) + steps = workflow["jobs"]["Publish"]["steps"] + guard = next( + i + for i, step in enumerate(steps) + if "release_build.py publish-check" in step.get("run", "") + ) + tag = next( + i for i, step in enumerate(steps) if "publish-git-tag.sh" in step.get("run", "") + ) + upload = next( + i + for i, step in enumerate(steps) + if "gh-action-pypi-publish" in step.get("uses", "") + ) + assert guard < tag < upload + candidate_job = yaml.safe_load( + (ROOT / ".github/workflows/pr_code_changes.yaml").read_text() + )["jobs"]["ReleaseCandidate"] + serialized = json.dumps(candidate_job) + for forbidden in ( + "publish-git-tag", + "add-and-commit", + "gh-action-pypi-publish", + "gh release", + "dispatch-policyengine", + ): + assert forbidden not in serialized + assert "upload-artifact" in serialized + assert candidate_job["permissions"] == {"contents": "read", "actions": "read"} + + +@pytest.fixture +def artifact_service(monkeypatch): + output = io.BytesIO() + with zipfile.ZipFile(output, "w") as archive: + archive.writestr("receipt.json", b"{}") + raw = output.getvalue() + artifact = { + "id": 11, + "expired": False, + "digest": "sha256:" + hashlib.sha256(raw).hexdigest(), + "workflow_run": {"id": 21, "head_sha": "h" * 40}, + "created_at": "2026-09-12T10:01:00Z", + } + run = { + "id": 21, + "status": "completed", + "conclusion": "success", + "event": "pull_request", + "path": release.WORKFLOW, + "repository": {"full_name": release.REPOSITORY}, + "head_repository": {"full_name": release.REPOSITORY}, + "head_sha": "h" * 40, + "run_attempt": 1, + "run_started_at": "2026-09-12T10:00:00Z", + "updated_at": "2026-09-12T10:02:00Z", + } + attempt = copy.deepcopy(run) + + def get(path, *, binary=False): + if binary: + return raw + if "/attempts/" in path: + return attempt + return artifact if "/artifacts/" in path else run + + monkeypatch.setattr(release, "github", get) + return artifact, run, attempt + + +def test_artifact_authentication_accepts_actual_digest_and_origin(artifact_service): + artifact, run, members = release.authenticated_artifact( + release.REPOSITORY, 11, release.WORKFLOW + ) + assert ( + artifact["id"] == 11 and run["id"] == 21 and members == {"receipt.json": b"{}"} + ) + + +@pytest.mark.parametrize( + "key,value", + [ + ("conclusion", "failure"), + ("status", "in_progress"), + ("event", "workflow_dispatch"), + ("path", ".github/workflows/unreviewed.yaml"), + ("head_sha", "other"), + ("repository", {"full_name": "other/repository"}), + ("head_repository", {"full_name": "fork/repository"}), + ], +) +def test_artifact_rejects_wrong_workflow_source_or_origin(artifact_service, key, value): + artifact_service[1][key] = value + with pytest.raises(ValueError, match="workflow"): + release.authenticated_artifact(release.REPOSITORY, 11, release.WORKFLOW) + + +def test_artifact_digest_cannot_be_replaced_by_an_operator_claim(artifact_service): + artifact_service[0]["digest"] = "sha256:" + "0" * 64 + with pytest.raises(ValueError, match="digest"): + release.authenticated_artifact(release.REPOSITORY, 11, release.WORKFLOW) + + +def test_makefile_consumes_frozen_date_instead_of_wall_clock(): + output = subprocess.check_output( + ["make", "--dry-run", "changelog", "RELEASE_DATE=2032-01-02"], + cwd=ROOT, + text=True, + ) + assert "towncrier build --yes --version" in output + assert '--date "2032-01-02"' in output + assert ( + release.controlled_environment({"epoch": 0, "release_date": "1970-01-01"})[ + "RELEASE_DATE" + ] + == "1970-01-01" + ) + with pytest.raises(ValueError, match="date"): + release.controlled_environment({"epoch": 0, "release_date": "2032-01-02"}) + + +@pytest.mark.parametrize("flavor", ["release", "rc1"]) +def test_shared_preparation_sequence_preserves_stable_and_prerelease_boundaries( + source, monkeypatch, flavor +): + calls = [] + monkeypatch.setattr(release, "check_toolchain", lambda root: {"frozen": True}) + monkeypatch.setattr(release, "require_unpublished", lambda version: None) + monkeypatch.setattr(release, "verify_identity_fresh", lambda *args: {}) + + def run(root, *cmd, env=None): + calls.append((cmd, env)) + if "candidate-tros" in cmd: + (root / release.BUNDLE_PATH.parent / "us.trace.tro.jsonld").write_text( + "fixture limited TRO" + ) + + monkeypatch.setattr(release, "run", run) + source_info = {"head": release.git(source, "rev-parse", "HEAD")} + country = { + "name": "policyengine-us", + "version": "2.0.2", + "sha256": "a" * 64, + "wheel_url": "file:///candidate/country.whl", + } + if flavor == "rc1": + path = source / release.BUNDLE_PATH + payload = json.loads(path.read_text()) + payload["packages"]["policyengine-us"] = { + "name": "policyengine-us", + "version": "2.0.0", + } + path.write_text(json.dumps(payload)) + receipt = release.prepare( + source, flavor, source_info, country if flavor == "rc1" else None + ) + commands = [ + list(command[1:]) if command[0] == release.sys.executable else list(command) + for command, _ in calls + ] + if flavor == "release": + assert commands == [ + ["scripts/check_release_credentials.py"], + [ + "scripts/bundle.py", + "check", + "--published-spm", + "--include-tros", + "--strict-tros", + ], + ["scripts/release_lock.py"], + [ + "make", + "changelog", + "RELEASE_DATE=" + receipt["preparation_inputs"]["release_date"], + ], + ["scripts/bundle.py", "generate"], + ["scripts/release_lock.py", "--refresh"], + ["scripts/bundle.py", "generate", "--include-tros", "--strict-tros"], + [ + "scripts/bundle.py", + "check", + "--published-spm", + "--include-tros", + "--strict-tros", + ], + ] + else: + assert commands == [ + ["scripts/bundle.py", "generate"], + ["scripts/release_build.py", "candidate-tros", "--version", "6.0.0rc1"], + ] + payload = json.loads((source / release.BUNDLE_PATH).read_text()) + assert payload["data_releases"]["us"]["model_package"] == { + "name": country["name"], + "version": country["version"], + } + assert payload["packages"]["policyengine-us"] == { + "name": "policyengine-us", + "version": "2.0.2", + } + assert all( + env["SOURCE_DATE_EPOCH"] == str(receipt["preparation_inputs"]["epoch"]) + for _, env in calls + ) + + +def test_limited_candidate_tro_uses_real_schema_without_data_or_certificate( + source, monkeypatch +): + monkeypatch.setenv("POLICYENGINE_SKIP_COUNTRY_IMPORTS", "1") + monkeypatch.syspath_prepend(str(ROOT / "src")) + from jsonschema import Draft202012Validator + + tro = release.candidate_us_tro(source, "6.0.0rc1") + schema = json.loads( + (ROOT / "src/policyengine/data/schemas/trace_tro.schema.json").read_text() + ) + Draft202012Validator(schema).validate(tro) + node = tro["@graph"][0] + assert node["trov:createdWith"]["schema:softwareVersion"] == "6.0.0rc1" + assert "unpublished; not reviewed for release" in node["schema:name"] + assert "certif" not in json.dumps(tro).lower() + artifacts = node["trov:hasComposition"]["trov:hasArtifact"] + assert [a["@id"] for a in artifacts] == ["composition/1/artifact/bundle_manifest"] + assert ( + artifacts[0]["trov:sha256"] + == hashlib.sha256((source / release.BUNDLE_PATH).read_bytes()).hexdigest() + ) + assert {key for key in node["trov:hasPerformance"] if key.startswith("pe:")} == { + "pe:emittedIn" + } + assert node["trov:hasPerformance"]["pe:emittedIn"] == "repository-bundle" + + +@pytest.mark.parametrize( + "tamper", + ["certification", "version", "historical-comment", "uk-manifest", "uk-record"], +) +def test_candidate_identity_rejects_false_certification_or_stale_tro_version( + source, monkeypatch, tamper +): + monkeypatch.setenv("POLICYENGINE_SKIP_COUNTRY_IMPORTS", "1") + monkeypatch.syspath_prepend(str(ROOT / "src")) + release.prepare_prerelease_metadata(source, "6.0.0rc1") + from policyengine.provenance.trace import serialize_trace_tro + + path = source / release.BUNDLE_PATH + bundle = json.loads(path.read_text()) + # Exercise the actual post-preparation manifest generator before verification. + generator = release.load_helper(ROOT, "scripts/generate_bundle_artifacts.py") + bundle = generator.normalized_manifest(bundle) + path.write_text(json.dumps(bundle)) + us = release.candidate_us_tro(source, "6.0.0rc1") + monkeypatch.setattr(release, "assert_source_origin", lambda root: {"fixture": True}) + monkeypatch.setattr(release, "candidate_uk_tro", lambda: copy.deepcopy(us)) + for country in ("us", "uk"): + (path.parent / f"{country}.trace.tro.jsonld").write_bytes( + serialize_trace_tro(us) + ) + release.verify_identity(source, "6.0.0rc1", "rc1") + if tamper == "certification": + us["@graph"][0]["trov:hasPerformance"]["pe:compatibilityBasis"] = ( + "legacy_compatible_model_package" + ) + elif tamper == "version": + us["@graph"][0]["trov:createdWith"]["schema:softwareVersion"] = "5.3.1" + elif tamper == "historical-comment": + us["@graph"][0]["trov:wasAssembledBy"]["rdfs:comment"] = ( + "Historical build was certified" + ) + if tamper.startswith("uk-"): + changed = copy.deepcopy(us) + if tamper == "uk-manifest": + changed["@graph"][0]["trov:hasComposition"]["trov:hasArtifact"][0][ + "trov:sha256" + ] = "f" * 64 + else: + changed["@graph"][0]["schema:description"] = "Stale serialized UK record" + (path.parent / "uk.trace.tro.jsonld").write_bytes(serialize_trace_tro(changed)) + else: + (path.parent / "us.trace.tro.jsonld").write_text(json.dumps(us)) + with pytest.raises( + ValueError, + match="certification|version|limited record|manifest hash|UK reconstruction", + ): + release.verify_identity(source, "6.0.0rc1", "rc1") + + +@pytest.mark.parametrize( + "failure", [None, "rc1", "wrong-package", "wrong-head", "tampered-wheel"] +) +def test_country_artifact_requires_actual_final_wheel_and_authenticated_receipt( + source, tmp_path, monkeypatch, failure +): + monkeypatch.setenv("POLICYENGINE_SKIP_COUNTRY_IMPORTS", "1") + monkeypatch.syspath_prepend(str(ROOT / "scripts")) + path = source / release.BUNDLE_PATH + bundle = json.loads(path.read_text()) + bundle["packages"]["policyengine-us"] = { + "name": "policyengine-us", + "version": "2.0.2", + } + path.write_text(json.dumps(bundle)) + version = "2.0.2rc1" if failure == "rc1" else "2.0.2" + package = "unexpected" if failure == "wrong-package" else "policyengine-us" + raw = wheel_bytes(version, package=package, changed=failure == "tampered-wheel") + filename = "policyengine_us-" + version + "-py3-none-any.whl" + receipt = { + "prepared_version": version, + "source_head": "wrong" if failure == "wrong-head" else "h" * 40, + "policy_source_matches_head": True, + "wheel": { + "filename": filename, + "sha256": hashlib.sha256(raw).hexdigest(), + "size_bytes": len(raw), + }, + } + monkeypatch.setattr( + release, + "authenticated_artifact", + lambda *args: ( + {"id": 11, "digest": "sha256:" + "d" * 64}, + {"id": 21, "head_sha": "h" * 40, "run_attempt": 1}, + { + "release-build/receipt.json": json.dumps(receipt).encode(), + "dist/" + filename: raw, + }, + ), + ) + actual_command = release.source_command + monkeypatch.setattr( + release, "source_command", lambda root, *args: actual_command(ROOT, *args) + ) + if failure: + with pytest.raises(ValueError): + release.country_component(source, "11", tmp_path / "country") + else: + component, evidence = release.country_component( + source, "11", tmp_path / "country" + ) + assert ( + component["name"] == "policyengine-us" and component["version"] == "2.0.2" + ) + assert component["sha256"] == hashlib.sha256(raw).hexdigest() + assert component["wheel_url"] == (tmp_path / "country" / filename).as_uri() + assert evidence["component"] == component and evidence["artifact_id"] == 11 + assert evidence["source_head"] == "h" * 40 + + +def test_no_country_artifact_input_is_an_explicit_hold(source, monkeypatch): + monkeypatch.setattr( + release, + "authenticated_artifact", + lambda *args: pytest.fail("No artifact was selected"), + ) + with pytest.raises(ValueError, match="hold"): + release.country_component(source, "", source / "unused") + + +def test_publication_checks_existing_strict_gates_before_member_comparison( + source, tmp_path, monkeypatch +): + actual_git = release.git + head = actual_git(source, "rev-parse", "HEAD") + source_info = {"pr": 515, "head": head, "base": "b" * 40, "merged_tree": "m" * 40} + receipt = { + "source": source_info, + "flavor": "release", + "version": "5.3.1", + "preparation_inputs": release.preparation_inputs(source, head), + "toolchain": {"frozen": True}, + "prepared_tree": "p" * 40, + "package_identity": {}, + "wheel": {"sha256": "w" * 64, "members": {"payload": "actual"}}, + } + calls = [] + + def get_git(root, *args, **kwargs): + if "--format=%s" in args: + return "Update package version" + if "--format=%P" in args: + return head + if args == ("rev-parse", "HEAD^1"): + return head + return actual_git(root, *args, **kwargs) + + monkeypatch.setattr(release, "git", get_git) + monkeypatch.setattr(release, "source_from_merge", lambda *args: source_info) + monkeypatch.setattr( + release, + "discover_candidate", + lambda *args: {"receipt": receipt, "artifact_id": 11}, + ) + monkeypatch.setattr(release, "check_toolchain", lambda *args: receipt["toolchain"]) + monkeypatch.setattr( + release, "prepared_tree", lambda *args: receipt["prepared_tree"] + ) + monkeypatch.setattr(release, "verify_identity_fresh", lambda *args: {}) + monkeypatch.setattr( + release, + "require_unpublished", + lambda version: calls.append(["registry-unpublished", version]), + ) + monkeypatch.setattr( + release, "run", lambda root, *command, env=None: calls.append(list(command[1:])) + ) + + def build(root, actual, output): + calls.append(["build"]) + actual["wheel"] = copy.deepcopy(receipt["wheel"]) + + monkeypatch.setattr(release, "build", build) + release.publish_check(source, tmp_path / "receipt.json") + assert calls == [ + ["scripts/check_release_credentials.py"], + [ + "scripts/bundle.py", + "check", + "--published-spm", + "--include-tros", + "--strict-tros", + ], + ["scripts/release_lock.py"], + ["build"], + ["registry-unpublished", "5.3.1"], + ] + assert ( + json.loads((tmp_path / "receipt.json").read_text())["member_equality"] is True + ) + + +@pytest.mark.parametrize( + "path", + [ + "src/policyengine/hidden.json", + "src/other_package/__init__.py", + "scripts/hidden.py", + ], +) +def test_untracked_inputs_cannot_enter_a_wheel_outside_the_prepared_tree(source, path): + unexpected = source / path + unexpected.parent.mkdir(parents=True, exist_ok=True) + unexpected.write_text("untracked build input") + # An ignored input can still be packaged by **/*; exclusion from status + # cannot exempt it from source authentication. + (source / ".gitignore").write_text(path + "\n") + with pytest.raises(ValueError, match="Untracked package/build inputs"): + release.prepared_tree(source) + + +@pytest.mark.parametrize( + "tamper", [None, "hash", "version", "missing", "duplicate", "extra"] +) +def test_frozen_requirement_bytes_match_the_recorded_tool_closure(tmp_path, tamper): + root = tmp_path + (root / ".github").mkdir() + config = json.loads((ROOT / ".github/release-toolchain.json").read_text()) + requirements = (ROOT / ".github/release-tools.txt").read_text() + if tamper == "hash": + requirements = requirements.replace( + config["tools"]["build"]["wheels"][0]["sha256"], "f" * 64 + ) + elif tamper == "version": + requirements = requirements.replace("build==1.6.1", "build==0.0.0") + elif tamper == "missing": + requirements = "\n".join( + line for line in requirements.splitlines() if not line.startswith("build==") + ) + elif tamper == "duplicate": + requirements += ( + next( + line for line in requirements.splitlines() if line.startswith("build==") + ) + + "\n" + ) + elif tamper == "extra": + requirements += "unknown==1.0.0 --hash=sha256:" + "a" * 64 + "\n" + (root / ".github/release-tools.txt").write_text(requirements) + if tamper: + with pytest.raises(ValueError, match="Frozen tool|frozen tool"): + release.validate_tool_requirements(root, config) + else: + release.validate_tool_requirements(root, config) + + +@pytest.mark.parametrize("pin", ["25.0", "24.2", ">=25.0"]) +def test_exported_tool_overlap_requires_exact_pin_and_preserves_other_bytes(pin): + config = {"tools": {"packaging": {"version": "25.0"}}} + other = ( + "other==1.0 ; sys_platform == 'win32' \\\n --hash=sha256:" + "b" * 64 + "\n" + ) + operator = ">=" if pin.startswith(">=") else "==" + overlap = ( + "Packaging" + operator + pin.removeprefix(">=") + " \\\n" + " --hash=sha256:" + "a" * 64 + " \\\n" + " --hash=sha256:" + "c" * 64 + "\n" + ) + if pin != "25.0": + with pytest.raises(ValueError, match="pin differs"): + release.filter_tool_overlaps(overlap + other, config) + else: + filtered, removed = release.filter_tool_overlaps(overlap + other, config) + assert filtered == other + assert removed == [{"name": "packaging", "version": "25.0"}] + assert release.filter_tool_overlaps(other, config) == (other, []) + + +def test_bootstrap_filters_before_sync_and_records_the_governing_file( + tmp_path, monkeypatch +): + root = tmp_path / "source" + (root / ".github").mkdir(parents=True) + for name in ("release-toolchain.json", "release-tools.txt"): + (root / ".github" / name).write_bytes((ROOT / ".github" / name).read_bytes()) + base = "packaging==25.0 --hash=sha256:" + "a" * 64 + "\n" + calls = [] + + def fake_run(root, *command, env=None): + calls.append(command) + if command[:2] == ("uv", "export"): + Path(command[command.index("--output-file") + 1]).write_text(base) + assert {"--frozen", "--no-header", "--no-annotate"} <= set(command) + elif command[:3] == ("uv", "pip", "sync"): + assert Path(command[-2]).read_text() == "" + assert Path(command[-1]) == root / ".github/release-tools.txt" + assert "--require-hashes" in command and "--only-binary" in command + + monkeypatch.setattr(release, "run", fake_run) + monkeypatch.setenv("GITHUB_PATH", str(tmp_path / "github-path")) + config = json.loads((root / ".github/release-toolchain.json").read_text()) + evidence = release.export_base_requirements(root, tmp_path / "export.txt", config) + assert evidence == { + "exported_base_sha256": release.sha256(base.encode()), + "filtered_base_sha256": release.sha256(b""), + "removed_tool_overlaps": [{"name": "packaging", "version": "25.0"}], + "governing_tool_file": ".github/release-tools.txt", + "governing_tool_file_sha256": release.sha256( + (root / ".github/release-tools.txt").read_bytes() + ), + } + calls.clear() + release.bootstrap(root, tmp_path / "build-env") + assert [command[:2] for command in calls[:4]] == [ + ("uv", "export"), + ("uv", "venv"), + ("uv", "pip"), + ("uv", "pip"), + ] + assert calls[-1][-1] == "check-toolchain" + + +def test_optional_numerical_job_skips_without_weakening_stable_gates(): + import yaml + + jobs = yaml.safe_load( + (ROOT / ".github/workflows/pr_code_changes.yaml").read_text() + )["jobs"] + stable, numerical = jobs["ReleaseCandidate"], jobs["NumericalCandidate"] + assert "RELEASE_COUNTRY_CANDIDATE_ARTIFACT_ID" not in json.dumps(stable) + assert ( + numerical["if"] + == stable["if"] + " && vars.RELEASE_COUNTRY_CANDIDATE_ARTIFACT_ID != ''" + ) + for job, flavor in ((stable, "release"), (numerical, "rc1")): + assert "strategy" not in job and "matrix" not in job["if"] + assert any( + f"candidate --flavor {flavor}" in step.get("run", "") + for step in job["steps"] + ) + assert job["permissions"] == {"contents": "read", "actions": "read"} + assert not any( + term in json.dumps(job) + for term in ( + "publish-git-tag", + "add-and-commit", + "gh-action-pypi-publish", + "gh release", + ) + ) + # Both release phases retain their unconditional helper gate. The optional + # numerical input cannot be consulted anywhere in either phase. + push = yaml.safe_load((ROOT / ".github/workflows/push.yaml").read_text())["jobs"] + for name, command in (("Versioning", "versioning"), ("Publish", "publish-check")): + assert "RELEASE_COUNTRY_CANDIDATE_ARTIFACT_ID" not in json.dumps(push[name]) + step = next( + step + for step in push[name]["steps"] + if f"release_build.py {command}" in step.get("run", "") + ) + assert "if" not in step + for job in [stable, numerical, push["Versioning"], push["Publish"]]: + for step in job["steps"]: + if "uses" in step and not step["uses"].startswith("./"): + assert release.re.fullmatch(r"[^@]+@[0-9a-f]{40}", step["uses"]) + + +@pytest.mark.parametrize( + "tamper", + [ + None, + "package-hash", + "model-hash", + "package-role", + "model-version", + "manifest-uri", + "us-uri", + "uk-uri", + ], +) +def test_final_generated_candidate_descriptor_and_uri_controls( + source, monkeypatch, tamper +): + from policyengine.provenance.trace import serialize_trace_tro + + path = source / release.BUNDLE_PATH + country = { + "name": "policyengine-us", + "version": "2.0.2", + "sha256": "a" * 64, + "wheel_url": "file:///local/country.whl", + } + release.prepare_prerelease_metadata(source, "6.0.0rc1", country) + generator = release.load_helper(ROOT, "scripts/generate_bundle_artifacts.py") + bundle = generator.normalized_manifest(json.loads(path.read_text())) + if tamper == "package-hash": + bundle["packages"]["policyengine-us"]["sha256"] = country["sha256"] + elif tamper == "model-hash": + bundle["data_releases"]["us"]["model_package"]["sha256"] = country["sha256"] + elif tamper == "package-role": + bundle["packages"]["policyengine-us"]["role"] = "unknown" + elif tamper == "model-version": + bundle["data_releases"]["us"]["model_package"]["version"] = "2.0.2rc1" + elif tamper == "manifest-uri": + bundle["development"]["unexpected_location"] = "FILE:/local/country.whl" + path.write_text(json.dumps(bundle)) + us = release.candidate_us_tro(source, "6.0.0rc1") + monkeypatch.setattr(release, "assert_source_origin", lambda root: {"fixture": True}) + monkeypatch.setattr(release, "candidate_uk_tro", lambda: copy.deepcopy(us)) + for code in ("us", "uk"): + tro = copy.deepcopy(us) + if tamper == code + "-uri": + tro["@graph"][0]["schema:description"] = "file:///local/country.whl" + (path.parent / f"{code}.trace.tro.jsonld").write_bytes(serialize_trace_tro(tro)) + if tamper: + with pytest.raises(ValueError, match="descriptors|local file URI"): + release.verify_identity(source, "6.0.0rc1", "rc1") + else: + identity = release.verify_identity(source, "6.0.0rc1", "rc1") + assert identity["bundle_manifest_sha256"] == release.sha256(path.read_bytes()) + + +def test_expected_preparation_errors_are_clean_holds(source, monkeypatch): + from types import SimpleNamespace + + import policyengine.provenance.manifest as manifests + + def stop(*args): + raise SystemExit(1) + + monkeypatch.setattr( + release, + "load_helper", + lambda *args: SimpleNamespace(get_current_version=stop, bump_version=stop), + ) + with pytest.raises(ValueError, match="hold: stable version prediction failed"): + release.predicted_version(source) + with pytest.raises( + ValueError, match="hold: UK TRACE fails schema validation" + ) as error: + release.validate_tro_schema(source, {"private": "secret-never-echo"}, "uk") + assert "secret-never-echo" not in str(error.value) + + def unavailable(*args): + raise manifests.DataReleaseManifestUnavailableError("private-secret-never-echo") + + monkeypatch.setattr(manifests, "get_data_release_manifest", unavailable) + with pytest.raises( + ValueError, match="hold: UK release manifest unavailable" + ) as error: + release.candidate_uk_tro() + assert "secret-never-echo" not in str(error.value) + + +@pytest.mark.parametrize( + "stderr,expected", + [ + ("gh: Forbidden (HTTP 403)", "access denied"), + ("gh: Not Found (HTTP 404)", "not found or inaccessible"), + ("gh: API rate limit exceeded (HTTP 403)", "rate limit"), + ("Run gh auth login", "authentication unavailable"), + ("transport failed", "request failed"), + ], +) +def test_github_failures_are_actionable_without_echoing_secrets( + monkeypatch, stderr, expected +): + from types import SimpleNamespace + + monkeypatch.setattr( + release.subprocess, + "run", + lambda *args, **kwargs: SimpleNamespace( + returncode=1, + stdout=b"", + stderr=(stderr + "\nAuthorization: Bearer secret-never-echo").encode(), + ), + ) + path = "repos/PolicyEngine/policyengine-us/actions/artifacts/11" + with pytest.raises(ValueError) as error: + release.github(path + "?token=secret-never-echo") + assert expected in str(error.value) and path in str(error.value) + assert "secret-never-echo" not in str(error.value) + + +@pytest.mark.parametrize( + "tamper", ["failed-attempt", "earlier-artifact", "wrong-attempt", "after-attempt"] +) +def test_artifact_requires_the_actual_current_successful_producing_attempt( + artifact_service, tamper +): + artifact, run, attempt = artifact_service + if tamper == "failed-attempt": + attempt["conclusion"] = "failure" + elif tamper == "earlier-artifact": + artifact["created_at"] = "2026-09-12T09:59:00Z" + elif tamper == "after-attempt": + artifact["created_at"] = "2026-09-12T10:03:00Z" + else: + attempt["run_attempt"] = 2 + with pytest.raises(ValueError, match="attempt"): + release.authenticated_artifact(release.REPOSITORY, 11, release.WORKFLOW) + + +def test_candidate_hold_names_the_exact_base_and_supported_merge_modes(monkeypatch): + monkeypatch.setattr(release, "pages", lambda *args: []) + source = candidate()["receipt"]["source"] + with pytest.raises(ValueError, match="publication hold") as error: + release.discover_candidate(source) + assert source["base"] in str(error.value) + assert "merge or squash" in str(error.value) + assert "rebase merges are unsupported" in str(error.value) + + +@pytest.mark.parametrize("registry", ["404", "files", "deleted-files", "403"]) +def test_registry_absence_requires_authoritative_404(monkeypatch, registry): + def fetch(*args, **kwargs): + if registry in {"404", "403"}: + raise release.urllib.error.HTTPError( + "https://pypi.org", int(registry), "status", {}, None + ) + return io.StringIO( + json.dumps({"urls": [] if registry == "deleted-files" else [{}]}) + ) + + monkeypatch.setattr(release.urllib.request, "urlopen", fetch) + if registry == "404": + release.require_unpublished("6.0.0") + else: + with pytest.raises(ValueError): + release.require_unpublished("6.0.0") + + +def test_ci_scopes_country_access_and_preserves_guard_receipts(): + import yaml + + candidate_job = yaml.safe_load( + (ROOT / ".github/workflows/pr_code_changes.yaml").read_text() + )["jobs"]["NumericalCandidate"] + token = next( + step for step in candidate_job["steps"] if step.get("id") == "country-token" + ) + assert token["with"]["repositories"] == "policyengine-us" + assert token["with"]["owner"] == "PolicyEngine" + assert token["with"]["permission-actions"] == "read" + assert "vars.RELEASE_COUNTRY_CANDIDATE_ARTIFACT_ID != ''" in candidate_job["if"] + assert "matrix" not in candidate_job["if"] + assert set(key for key in token["with"] if key.startswith("permission-")) == { + "permission-actions" + } + push = yaml.safe_load((ROOT / ".github/workflows/push.yaml").read_text())["jobs"] + steps = push["Versioning"]["steps"] + upload = next( + i for i, step in enumerate(steps) if "upload-artifact@" in step.get("uses", "") + ) + commit = next( + i for i, step in enumerate(steps) if "add-and-commit@" in step.get("uses", "") + ) + assert upload < commit + assert steps[commit]["with"]["add"] == "-u ." + publishing = push["Publish"]["steps"] + tag = next( + i + for i, step in enumerate(publishing) + if "publish-git-tag.sh" in step.get("run", "") + ) + registry = next( + i + for i, step in enumerate(publishing) + if "gh-action-pypi-publish@" in step.get("uses", "") + ) + assert "require-unpublished" in publishing[tag]["run"] + assert "require-unpublished" in publishing[registry - 1]["run"] + assert publishing[registry]["with"]["skip-existing"] is False + setup = yaml.safe_load( + (ROOT / ".github/actions/release-toolchain/action.yml").read_text() + ) + for step in setup["runs"]["steps"]: + if "uses" in step: + assert release.re.fullmatch(r"[^@]+@[0-9a-f]{40}", step["uses"]) + + +def test_source_origin_rejects_previously_imported_other_checkout( + tmp_path, monkeypatch +): + monkeypatch.setenv("POLICYENGINE_SKIP_COUNTRY_IMPORTS", "1") + monkeypatch.syspath_prepend(str(ROOT / "src")) + actual = release.assert_source_origin(ROOT) + assert actual["resources"] == "src/policyengine" + assert actual["modules"]["policyengine.provenance.trace"].startswith( + "src/policyengine/" + ) + monkeypatch.syspath_prepend(str(tmp_path / "src")) + with pytest.raises(ValueError, match="source origin"): + release.assert_source_origin(tmp_path) + + +@pytest.mark.parametrize( + "member", ["manifest.json", "us.trace.tro.jsonld", "uk.trace.tro.jsonld"] +) +def test_actual_wheel_must_contain_all_three_verified_provenance_members(member): + receipt = { + "package_identity": { + "bundle_manifest_sha256": "a" * 64, + "tro_sha256": {"us": "b" * 64, "uk": "c" * 64}, + }, + "wheel": { + "members": { + "policyengine/data/bundle/" + name: {"sha256": digest * 64} + for name, digest in [ + ("manifest.json", "a"), + ("us.trace.tro.jsonld", "b"), + ("uk.trace.tro.jsonld", "c"), + ] + } + }, + } + release.verify_wheel_package_identity(receipt) + receipt["wheel"]["members"]["policyengine/data/bundle/" + member]["sha256"] = ( + "d" * 64 + ) + with pytest.raises(ValueError, match="Built wheel"): + release.verify_wheel_package_identity(receipt) + + +def test_candidate_uk_uses_ordinary_builder_and_never_requests_us(monkeypatch): + from policyengine.provenance import manifest, trace + from tests.test_certify_data_release import _uk_release_manifest_payload + + bundle_bytes = (ROOT / release.BUNDLE_PATH).read_bytes() + country = manifest.CountryReleaseManifest.model_validate( + json.loads(bundle_bytes)["data_releases"]["uk"] + ) + country.source_sha256 = hashlib.sha256(bundle_bytes).hexdigest() + data = manifest.DataReleaseManifest.model_validate(_uk_release_manifest_payload()) + calls = [] + + def get_country(name): + calls.append(("country", name)) + assert name == "uk" + return country + + def get_data(name): + calls.append(("data", name)) + assert name == "uk" + return data + + monkeypatch.setattr(manifest, "get_release_manifest", get_country) + monkeypatch.setattr(manifest, "get_data_release_manifest", get_data) + actual = release.candidate_uk_tro() + expected = trace.build_trace_tro_from_release_bundle( + country, + data, + certification=country.certification, + model_wheel_sha256=country.model_package.sha256, + model_wheel_url=country.model_package.wheel_url, + emission_context={"pe:emittedIn": "repository-bundle"}, + ) + assert actual == expected + assert calls == [("country", "uk"), ("data", "uk")] + artifacts = actual["@graph"][0]["trov:hasComposition"]["trov:hasArtifact"] + assert [ + item["trov:sha256"] + for item in artifacts + if item["@id"] == "composition/1/artifact/bundle_manifest" + ] == [country.source_sha256] diff --git a/tests/test_release_tro_generation.py b/tests/test_release_tro_generation.py index 05f8277e..6b18e41a 100644 --- a/tests/test_release_tro_generation.py +++ b/tests/test_release_tro_generation.py @@ -289,32 +289,28 @@ def test_release_workflows_gate_complete_inputs_and_lock(): "github.event.pull_request.head.repo.full_name == github.repository" in pr["jobs"]["BundleVerification"]["if"] ) - for job in ( - push["jobs"]["Versioning"], - push["jobs"]["Publish"], - ): - commands = "\n".join(step.get("run", "") for step in job["steps"]) - assert "check --published-spm --include-tros --strict-tros" in commands - assert 'if [[ -z "${HUGGING_FACE_TOKEN:-}" ]]' in commands - assert "python scripts/release_lock.py" in commands - assert commands.index( - "python scripts/check_release_credentials.py" - ) < commands.index("check --published-spm") - # The pull-request job checks only the reviewed registry lock. The - # read-only credential, published-measurement and TRACE sidecar gates - # depend on the release workflow regenerating sidecars and publishing - # wheels first, so a pull request can never satisfy them. + # The shared helper's ordered credential/TRACE/lock checks are exercised by + # test_release_build's preparation and publication controls. Both ordinary + # jobs must invoke it with the read-only private-manifest credential. + for name, command in (("Versioning", "versioning"), ("Publish", "publish-check")): + steps = [ + step + for step in push["jobs"][name]["steps"] + if f"release_build.py {command}" in step.get("run", "") + ] + assert len(steps) == 1 + assert ( + steps[0]["env"]["HUGGING_FACE_TOKEN"] == "${{ secrets.HUGGING_FACE_TOKEN }}" + ) + assert any( + step.get("uses") == "./.github/actions/release-toolchain" + for step in push["jobs"][name]["steps"] + ) + # The lightweight PR metadata check still needs only the reviewed lock. + # The separate nonpublishing stable candidate is intentionally held until + # real country/data publication supplies the strict release prerequisites. pr_commands = "\n".join( step.get("run", "") for step in pr["jobs"]["BundleVerification"]["steps"] ) assert "python scripts/release_lock.py" in pr_commands assert "--published-spm" not in pr_commands - commands = "\n".join( - step.get("run", "") for step in push["jobs"]["Versioning"]["steps"] - ) - assert commands.index("check --published-spm") < commands.index("make changelog") - assert ( - commands.index("make changelog") - < commands.index("release_lock.py --refresh") - < commands.index("generate --include-tros --strict-tros") - ) diff --git a/tests/test_spm_household.py b/tests/test_spm_household.py index 4c731d04..674de8a6 100644 --- a/tests/test_spm_household.py +++ b/tests/test_spm_household.py @@ -162,12 +162,10 @@ def test_state_only_graphs_require_geography_only_where_measurement_is_used( ): """Geography is demanded by exactly the results that use the measurement. - The country owns this contract and the wrapper follows it. Its housing cap - (``spm_unit_capped_housing_subsidy``) consults the canonical SPM housing - portion for units with housing assistance to cap and for no others, so an - unassisted unit's resource graph never reaches the measurement and needs no - geography; an assisted unit's does, and fails closed without one. SPM - measurement itself requires geography either way. + Ordinary benefits and income use the actual housing award independently of + SPM geography. The country's cap consults the canonical housing portion only + for units allocated assistance, so assisted SPM resources require geography. + The threshold and SPM poverty status require geography either way. """ from policyengine.tax_benefit_models.us.model import us_latest @@ -175,9 +173,16 @@ def test_state_only_graphs_require_geography_only_where_measurement_is_used( # include resources and SPM poverty by default. monkeypatch.setattr(us_latest, "entity_variables", {"tax_unit": ["income_tax"]}) inputs = household_inputs(household={"state_code": "CA"}) + # Match the country integration control: computed HUD award and contribution, + # with low enough earnings for a positive national/county SPM housing cap. assisted = household_inputs( - household={"state_code": "CA"}, - spm_unit={"spm_unit_tenure_type": "RENTER", "housing_assistance": 6_000}, + year=2024, + people=[{"age": 40, "employment_income": 24_000, "pre_subsidy_rent": 36_000}], + household={"state_code": "CA", "pha_payment_standard": 36_000}, + spm_unit={ + "spm_unit_tenure_type": "RENTER", + "receives_housing_assistance": True, + }, ) # A genuinely tax-only graph never consults the measurement. @@ -197,8 +202,25 @@ def test_state_only_graphs_require_geography_only_where_measurement_is_used( computed = pe.us.calculate_household(**inputs, extra_variables=[variable]) assert computed.to_dict()["provenance"]["spm"]["years"] == {} - # Assisted: the same graph now has a cap to apply, so it fails closed. + # Assisted ordinary resources still use the actual award, without an SPM cap. + assisted_ordinary = {} for variable in RESOURCE_VARIABLES: + computed = pe.us.calculate_household(**assisted, extra_variables=[variable]) + entity = ( + computed.person[0] + if variable == "marginal_tax_rate" + else computed.household + ) + assisted_ordinary[variable] = entity[variable] + assert math.isfinite(entity[variable]) + assert computed.to_dict()["provenance"]["spm"]["years"] == {} + + # Only the assisted SPM resource graph needs geography for its housing cap. + for variable in ( + "spm_unit_capped_housing_subsidy", + "spm_unit_benefits", + "spm_unit_net_income", + ): with pytest.raises(SPMInputError) as caught: pe.us.calculate_household(**assisted, extra_variables=[variable]) assert caught.value.code == "SPM_GEOGRAPHY_REQUIRED" @@ -210,7 +232,7 @@ def test_state_only_graphs_require_geography_only_where_measurement_is_used( pe.us.calculate_household(**situation, extra_variables=[variable]) assert caught.value.code == "SPM_GEOGRAPHY_REQUIRED" - # An explicit selection computes for both. + # Preserve the unassisted national and county controls. for settings, located_inputs in ( ({"geography_kind": "national"}, inputs), ({"geography_kind": "county"}, household_inputs()), @@ -223,6 +245,47 @@ def test_state_only_graphs_require_geography_only_where_measurement_is_used( assert math.isfinite(located.household.household_net_income) assert math.isfinite(located.person[0].marginal_tax_rate) + # Persist assisted positive controls through the same public wrapper path. + assisted_county = deepcopy(assisted) + assisted_county["household"]["county_fips"] = "06037" + actual_awards = [] + for settings, located_inputs in ( + ({"geography_kind": "national"}, assisted), + ({"geography_kind": "county"}, assisted_county), + ): + located = pe.us.calculate_household( + **located_inputs, + spm=settings, + extra_variables=[ + *RESOURCE_VARIABLES, + "housing_assistance", + "spm_unit_allocated_housing_subsidy", + "spm_unit_allocated_tenant_payment", + "spm_unit_capped_housing_subsidy", + "spm_unit_benefits", + "spm_unit_net_income", + ], + ) + subsidy = located.spm_unit.spm_unit_allocated_housing_subsidy + capped = located.spm_unit.spm_unit_capped_housing_subsidy + assert 0 < capped < subsidy + assert located.spm_unit.spm_unit_allocated_tenant_payment > 0 + assert math.isfinite(located.spm_unit.spm_unit_benefits) + assert math.isfinite(located.spm_unit.spm_unit_net_income) + actual_awards.append(located.spm_unit.housing_assistance) + for variable in RESOURCE_VARIABLES: + entity = ( + located.person[0] + if variable == "marginal_tax_rate" + else located.household + ) + assert entity[variable] == assisted_ordinary[variable] + receipt = located.to_dict()["provenance"]["spm"] + assert receipt["years"][str(assisted["year"])] + assert receipt["geography_kind"] == settings["geography_kind"] + assert receipt["geographies"] + assert actual_awards[0] == actual_awards[1] > 0 + def test_adultless_measurement_has_a_structured_composition_error(): with pytest.raises(SPMInputError) as caught: From 44604506066ee9962cf60525bf9f5aa45bea2318 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 14 Sep 2026 21:34:55 -0400 Subject: [PATCH 02/10] Track the release-candidate fix work in PROGRESS.md Co-Authored-By: Claude Fable 5.1 --- PROGRESS.md | 184 ++++------------------------------------------------ 1 file changed, 12 insertions(+), 172 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index c05aa2b6..7cee89fc 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1,178 +1,18 @@ -# Finalize wrapper PR #515 on policyengine-us 2.0.1 +# Progress: wrapper release candidates (max/wrapper-release-candidates-20260914) ## State - -**Starting.** Prior lane (head `e7bfa2bd`) bound the wrapper to policyengine-us **2.0.0** -and left six tests red, blocked on wheel provenance and on wrapper-owner decisions it had -no standing to make. Both blocks are now lifted: - -- **policyengine-us 2.0.1 is published** (verified this lane, 2026-09-12): PyPI returns 200, - wheel sha256 `20e355823bbc89e6c9f413435a7d0b92095cff65541ea366048ee448da025aee`, - sdist `3183f5b1adf4f17963eec46e04bb51da5cd9b6bbda4b38fdebaea7ffa486f660`, - uploaded 2026-09-12T03:56:48Z. It is 2.0.0 plus the above-the-line-deduction determinism - fix (pe-us #9446) that the accepted candidate wheel carried and the merged 2.0.0 lacked. -- **The root has ruled** on the four wrapper-owner decisions (rulings A-E in the brief). - -This lane repins to 2.0.1, aligns the SPM resource contract with the authoritative country -behaviour, and settles all six red tests under those rulings. - -## Rulings being applied - -| Ruling | Substance | -|---|---| -| A | Country model behaviour is authoritative for the SPM resource contract. Housing cap consults the SPM housing portion only for units with housing assistance to cap. Unassisted units are zero, no geography or composition requirement. Assisted units without county still fail closed (`SPM_GEOGRAPHY_REQUIRED`). Explicit national computes. SPM measurement itself (threshold, poverty) always requires geography. Update the wrapper docstring(s) and `test_spm_household::test_state_only_tax_graph_succeeds_and_resource_graph_requires_geography`. | -| B | Pin policyengine-us==2.0.1, policyengine-core==3.32.5, spm-calculator==1.0.0 exactly; UK unchanged. Regenerate bundle manifest and uv.lock; run the repo's bundle checks. | -| C | `test_us_model_version_surface`: counts are identity facts of the pinned country. Regenerate; report before/after counts. | -| D | `test_us_household_snapshot[us_single_adult_no_income]`: `spm_unit.snap` 3596.04 -> 298.00 is a pre-existing country defect (one month's 2026 allotment reported annual), identical on 1.825.2 and 2.0.x, tracked at PolicyEngine/policyengine-us#9447. Do NOT rebaseline: xfail(strict=True) or exclude the single field, whichever is cleaner. | -| E | `test_us_household_snapshot` x3 (employment income, single parent, married two kids): regenerate, but justify every changed field old -> new against a located country change. Anything unexplained stays red and is reported. | - -## Prerequisites (verified this lane, primary sources) - -| Item | Value | Status | -|---|---|---| -| policyengine-us 2.0.1 wheel sha256 | `20e355823bbc89e6c9f413435a7d0b92095cff65541ea366048ee448da025aee` | verified (PyPI JSON, HTTP 200) | -| policyengine-us 2.0.1 sdist sha256 | `3183f5b1adf4f17963eec46e04bb51da5cd9b6bbda4b38fdebaea7ffa486f660` | verified | -| 2.0.1 declared deps | `policyengine-core>=3.30.1`, `spm-calculator==1.0.0` | verified | -| spm-calculator 1.0.0 wheel sha256 | `e354937a5e1a4045d4966ed594a528d8b02866fabaac9bb5672017004b627305` | carried from prior lane; to re-verify | -| policyengine-core 3.32.5 | pinned exactly by the bundle | to re-verify | -| HF data tag | `populace-us-2024-spm-20260909`, H5 `6496cc43...` | carried; to re-verify | - -## BLOCKER (Ruling B): 2.0.1 is published, but no data release is certified for it - -**The repin to policyengine-us 2.0.1 cannot be completed in this repo.** It is blocked on -an act by the *data publisher*, not by anything in the wrapper. - -The certified US data release `populace-us-2024-spm-20260909` publishes this claim: - -```json -"build": {"built_with_model_package": {"name": "policyengine-us", "version": "2.0.0"}}, -"compatible_model_packages": [{"name": "policyengine-us", "specifier": "==2.0.0"}] -``` - -`certify_data_release_compatibility` accepts exactly three bases — build-time match, -matching data build fingerprint, or a publisher `compatible_model_packages` claim. 2.0.1 -satisfies none (the manifest records no `data_build_fingerprint`), so: - -- `bundle.py certify-data --model-version 2.0.1` **refuses**: - *"policyengine-us 2.0.1 matches neither the build-time model (2.0.0) nor any publisher - compatibility claim ['==2.0.0']; a new data build or a published compatibility claim is - required."* -- With 2.0.1 installed, the gate fires at **import of the US model** (`us_latest = - PolicyEngineUSLatest()`, `model.py:512`), raising *"Data release manifest is not certified - for the runtime model version 2.0.1 in country 'us'."* Not just microsimulation — - `pe.us.calculate_household` and every US test error out too. - -This is the documented, intended behaviour. `docs/engineering/skills/data-certification.md`: -*"Neither basis means certification is refused: a new data build or a published -compatibility claim is required."* There is no override flag, and I did not manufacture one: -hand-writing a claim into the bundle, or mutating the published release manifest at an -immutable release tag, would launder exactly the invariant this gate exists to protect. - -I verified no published release covers 2.0.1: all 25 `policyengine/populace-us` tags and all -3 branches were enumerated; `populace-us-2024-spm-20260909` is the newest, and its manifest -carries `==2.0.0` on both the tag and `main`. - -### The claim-widening is substantively justified (evidence for the publisher) - -2.0.1 is 2.0.0 plus pe-us#9446 and nothing else. Verified by extracting and diffing both -published wheels: - -| Check | Result | -|---|---| -| Files differing, whole wheel | 2 variable `.py` files + 1 added test (`tests/core/test_ald_determinism.py`) + dist-info | -| `variables/` file set | **identical** — 5981 files in both | -| `parameters/` file set | **identical** — 5970 files, and byte-identical content | -| Substance of both diffs | `list(set(all_alds) - ...)` -> `sorted(set(all_alds) - ...)` | -| 2.0.1 wheel sha256 | `20e355823bbc89e6c9f413435a7d0b92095cff65541ea366048ee448da025aee` (PyPI-declared == downloaded == brief) | - -The change alters only the *order* of float summation within above-the-line-deduction -aggregation. No variable, parameter, or input schema changed, so the dataset's compatibility -with the model is unaffected — widening the claim to cover 2.0.1 asserts nothing the bytes -do not already support. - -**Unblocking action (data publisher, one line):** publish a populace-us release whose -manifest claims `policyengine-us` compatibility covering 2.0.1 (a new release tag — -never an in-place edit of the existing immutable tag). The wrapper side is then -`bundle.py update-packages --us 2.0.1` + `certify-data --model-version 2.0.1` + relock. - -**This lane therefore leaves the branch on the certified 2.0.0 tuple** and completes every -other part of the brief in full. A branch pinned to 2.0.1 would fail to import the US model -at all, making PR #515 entirely red and unreviewable — strictly worse than what it replaces. - -### Consequence for Ruling C - -Ruling C asks for the variable/parameter counts "regenerated for 2.0.1". Because the -`variables/` and `parameters/` file sets are provably identical between 2.0.0 and 2.0.1, -those counts are the same number on either pin. Regenerating on 2.0.0 yields exactly the -2.0.1 result. +Branch stacked on the approved #515 head `1b6c001c`. Base commit `818c894e` +("Build and verify isolated wrapper release candidates") is Codex's +release-candidate tooling; it carries two real defects plus an unverified +household-test rewrite. This branch keeps the tooling and fixes the defects. ## Done - -- Read the prior lane's PROGRESS.md and the full branch history (24 commits off `origin/main`). -- Verified the 2.0.1 prerequisite from PyPI directly. Wheel sha256 matches the brief exactly. -- Ran the repin through the tooling, hit the certification gate, and reverted to the certified - 2.0.0 tuple with a clean tree (see BLOCKER above). -- Established the repin procedure the prior lane used (repository tooling only: - `bundle.py update-packages`, `set-spm`, `certify-data`, `generate`; extras are generated - from the bundle manifest, so no pin is hand-edited). - -- **Ruling A done** (`85b1d711`). Read the authoritative source in the 2.0.1 wheel: - `spm_unit_capped_housing_subsidy` computes `assisted = housing_assistance > 0`, returns - zeros without touching the provider when none is assisted, and otherwise calls - `masked_policyengine_amount`, which evaluates only the assisted rows through - `CountyRequiringSPMProvider`. Probed all four clauses against the installed model rather - than assuming: 5/5 resource outputs compute with an empty measurement receipt when housing - assistance is zero; 5/5 raise `SPM_GEOGRAPHY_REQUIRED` when it is positive; threshold and - poverty raise either way; default outputs still require the choice. Updated the public - docstring, `docs/households.md`, and the contract test (renamed, since its old name - asserted the unconditional rule). 42/42 SPM household tests pass. -- **Ruling C done** (`456af5da`). Regenerated `us_model_surface`: - `num_variables_bucketed_100s` 57 -> 61 (raw 6157), `num_parameters_bucketed_100s` - 978 -> 1025 (raw 102525), `data_package_name` `populace-data` -> `microcosm-data`. - Counted the raw surface under both installed wheels: **identical** on 2.0.0 and 2.0.1 - (6157 / 102525 both), so this snapshot is already the 2.0.1 value. -- **Ruling D done** (`74675620`). Excluded the four contaminated fields rather than - xfailing the case, because all four are downstream of the one defect and an xfail would - have discarded the other 42 fields of coverage. Two guards make it honest: - `_check_snapshot` preserves prior values for excluded fields under - `PE_UPDATE_SNAPSHOTS=1` (verified by md5 across a refresh), and - `test_snap_annualization_defect_still_present` fails loudly on fix. Note a strict xfail - would *not* have failed loudly: the corrected annual figure is ~3,576 against a stored - 3,596.04, so the case would have gone on xfailing silently. -- Confirmed policyengine-us#9447 is OPEN and its body records the same values on 1.825.2 - and 2.0.0, independently corroborating Ruling D. - -- **Ruling E done** (`04960e64`), and **Ruling D reversed on a corrected premise** (`2019530f`). - Fanned out the country archaeology across the four drift signatures, each adversarially - verified from three lenses (arithmetic, provenance, alternative-cause): 12 verdicts, 11 - upholding, 1 refuting. Two root causes explain all three Ruling-E cases, both reproduced - from parameter values to the cent: - - `c991cd844a` (PR #9100, 1.779.1) added published BLS CPI-U actuals, moving the - 2026-01-01 index 323.364 -> 326.588; the NSLP/SBP per-meal rates uprate through it, so - the free-tier net school meal subsidy goes 1130.96 -> 1142.24. - - `df3482f4ef` (PR #9059, 1.776.2) moved `uprating: gov.states.ca.cpi` off the CA standard - deduction's file-level metadata, where `uprate_parameters` never read it, onto each - filing status. The deduction un-freezes into 2026: SINGLE 5,706 -> 5,835.31, - JOINT/HOH 11,412 -> 11,670.63. At the 6% and 9.3% marginal brackets that is -7.76 and - -24.05 exactly. -- **The SNAP finding overturned Ruling D's premise, and I verified it myself before acting.** - There is no annualization defect; the x12 is intact. The case is an ABAWD with no hours - supplied, and two country changes decide it: `82745ca239` dropped - `weekly_hours_worked_before_lsr`'s default from 40 to 0, and `74b0a75e5f` added - `waived_states.yaml`, under which California's statewide ABAWD waiver expires 2026-01-31. - One eligible month at 298.00. Measured directly: monthly snap 2026 is `[298, 0 x 11]`; - the same household with hours = 40 returns 3607.571; CA/IL/NV return 298.00 while - TX/NY/FL return 0.00. The year series I had cited as proof of a resolution defect - (584/3522/298/0 for 2024-2027) is the CA waiver schedule read line by line. So the four - fields were rebaselined under Ruling E's standard and the defect guard was replaced with - a test pinning the real mechanism. +- Verified worktree at `818c894e`, clean tree. ## Next - -1. Ruling E: regenerate the three drifting snapshots, justify every field against a located - country change. Root causes already isolated by measurement - the three cases reduce to - exactly two: school meal subsidy 1130.96 -> 1142.24 (+11.28, both child cases) and CA - state income tax (-7.76 at 60k single, -24.05 at 240k joint). Archaeology running. -2. Full `make test` as CI runs it; lint, mypy, changelog; push; `gh pr checks 515`; update - the PR body and mark ready for review. -3. Ruling B stays blocked - see BLOCKER above. Hand the publisher the evidence pack. +1. Commit 1: revert `tests/test_spm_household.py` to the `1b6c001c` version. +2. Commit 2: fix `tests/test_spm_bundle_bootstrap.py` publication-gate assertion. +3. Commit 3: fix `assert_source_origin` namespace-package handling + extractor + sys.modules restoration + regression test. +4. Verify: targeted pytest, full pytest, lint/format. Record exit codes. +5. Push and open a DRAFT PR against `max/spm-canonical-wrapper-release-20260910`. From 3598175604467d164198f04c8755eb208b52ab88 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 14 Sep 2026 21:39:12 -0400 Subject: [PATCH 03/10] Restore the reviewed household test until the country repin lands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release-candidate commit rewrote tests/test_spm_household.py::test_state_only_graphs_require_geography_only_where_measurement_is_used to drive the assisted case through computed HUD semantics — `pha_payment_standard`, `receives_housing_assistance`, `spm_unit_allocated_housing_subsidy` and `spm_unit_allocated_tenant_payment`. Those are country behaviours the unified candidate policyengine-us#9467 carries, not the pinned model, and the rewrite went through the R1–R3 reviews unverified. Return the file to the reviewed content at 1b6c001c. The rewrite comes back with the country repin, once #9467 publishes and the pin in pyproject.toml moves. Co-Authored-By: Claude Fable 5.1 --- tests/test_spm_household.py | 83 +++++-------------------------------- 1 file changed, 10 insertions(+), 73 deletions(-) diff --git a/tests/test_spm_household.py b/tests/test_spm_household.py index 674de8a6..4c731d04 100644 --- a/tests/test_spm_household.py +++ b/tests/test_spm_household.py @@ -162,10 +162,12 @@ def test_state_only_graphs_require_geography_only_where_measurement_is_used( ): """Geography is demanded by exactly the results that use the measurement. - Ordinary benefits and income use the actual housing award independently of - SPM geography. The country's cap consults the canonical housing portion only - for units allocated assistance, so assisted SPM resources require geography. - The threshold and SPM poverty status require geography either way. + The country owns this contract and the wrapper follows it. Its housing cap + (``spm_unit_capped_housing_subsidy``) consults the canonical SPM housing + portion for units with housing assistance to cap and for no others, so an + unassisted unit's resource graph never reaches the measurement and needs no + geography; an assisted unit's does, and fails closed without one. SPM + measurement itself requires geography either way. """ from policyengine.tax_benefit_models.us.model import us_latest @@ -173,16 +175,9 @@ def test_state_only_graphs_require_geography_only_where_measurement_is_used( # include resources and SPM poverty by default. monkeypatch.setattr(us_latest, "entity_variables", {"tax_unit": ["income_tax"]}) inputs = household_inputs(household={"state_code": "CA"}) - # Match the country integration control: computed HUD award and contribution, - # with low enough earnings for a positive national/county SPM housing cap. assisted = household_inputs( - year=2024, - people=[{"age": 40, "employment_income": 24_000, "pre_subsidy_rent": 36_000}], - household={"state_code": "CA", "pha_payment_standard": 36_000}, - spm_unit={ - "spm_unit_tenure_type": "RENTER", - "receives_housing_assistance": True, - }, + household={"state_code": "CA"}, + spm_unit={"spm_unit_tenure_type": "RENTER", "housing_assistance": 6_000}, ) # A genuinely tax-only graph never consults the measurement. @@ -202,25 +197,8 @@ def test_state_only_graphs_require_geography_only_where_measurement_is_used( computed = pe.us.calculate_household(**inputs, extra_variables=[variable]) assert computed.to_dict()["provenance"]["spm"]["years"] == {} - # Assisted ordinary resources still use the actual award, without an SPM cap. - assisted_ordinary = {} + # Assisted: the same graph now has a cap to apply, so it fails closed. for variable in RESOURCE_VARIABLES: - computed = pe.us.calculate_household(**assisted, extra_variables=[variable]) - entity = ( - computed.person[0] - if variable == "marginal_tax_rate" - else computed.household - ) - assisted_ordinary[variable] = entity[variable] - assert math.isfinite(entity[variable]) - assert computed.to_dict()["provenance"]["spm"]["years"] == {} - - # Only the assisted SPM resource graph needs geography for its housing cap. - for variable in ( - "spm_unit_capped_housing_subsidy", - "spm_unit_benefits", - "spm_unit_net_income", - ): with pytest.raises(SPMInputError) as caught: pe.us.calculate_household(**assisted, extra_variables=[variable]) assert caught.value.code == "SPM_GEOGRAPHY_REQUIRED" @@ -232,7 +210,7 @@ def test_state_only_graphs_require_geography_only_where_measurement_is_used( pe.us.calculate_household(**situation, extra_variables=[variable]) assert caught.value.code == "SPM_GEOGRAPHY_REQUIRED" - # Preserve the unassisted national and county controls. + # An explicit selection computes for both. for settings, located_inputs in ( ({"geography_kind": "national"}, inputs), ({"geography_kind": "county"}, household_inputs()), @@ -245,47 +223,6 @@ def test_state_only_graphs_require_geography_only_where_measurement_is_used( assert math.isfinite(located.household.household_net_income) assert math.isfinite(located.person[0].marginal_tax_rate) - # Persist assisted positive controls through the same public wrapper path. - assisted_county = deepcopy(assisted) - assisted_county["household"]["county_fips"] = "06037" - actual_awards = [] - for settings, located_inputs in ( - ({"geography_kind": "national"}, assisted), - ({"geography_kind": "county"}, assisted_county), - ): - located = pe.us.calculate_household( - **located_inputs, - spm=settings, - extra_variables=[ - *RESOURCE_VARIABLES, - "housing_assistance", - "spm_unit_allocated_housing_subsidy", - "spm_unit_allocated_tenant_payment", - "spm_unit_capped_housing_subsidy", - "spm_unit_benefits", - "spm_unit_net_income", - ], - ) - subsidy = located.spm_unit.spm_unit_allocated_housing_subsidy - capped = located.spm_unit.spm_unit_capped_housing_subsidy - assert 0 < capped < subsidy - assert located.spm_unit.spm_unit_allocated_tenant_payment > 0 - assert math.isfinite(located.spm_unit.spm_unit_benefits) - assert math.isfinite(located.spm_unit.spm_unit_net_income) - actual_awards.append(located.spm_unit.housing_assistance) - for variable in RESOURCE_VARIABLES: - entity = ( - located.person[0] - if variable == "marginal_tax_rate" - else located.household - ) - assert entity[variable] == assisted_ordinary[variable] - receipt = located.to_dict()["provenance"]["spm"] - assert receipt["years"][str(assisted["year"])] - assert receipt["geography_kind"] == settings["geography_kind"] - assert receipt["geographies"] - assert actual_awards[0] == actual_awards[1] > 0 - def test_adultless_measurement_has_a_structured_composition_error(): with pytest.raises(SPMInputError) as caught: From 14e5db47d35529580d6a91089f6b516291009e17 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 14 Sep 2026 21:40:58 -0400 Subject: [PATCH 04/10] Follow the publication gate through its new indirection The release-candidate commit replaced the Publish job's inline `bundle.py check --published-spm --include-tros --strict-tros` with `release_build.py publish-check`, which runs that same check itself (scripts/release_build.py:1381). The bootstrap test still searched the Publish steps for the inline command, so its generator expression matched nothing and `next` raised StopIteration instead of asserting anything. Search for the step that now carries the gate. NotifyConsumers is unchanged: it runs after PyPI visibility without the package installed, so it keeps the dependency-light `python -S scripts/bundle.py check --published-spm`. That publish-check still performs the published-spm check is pinned separately by test_publication_checks_existing_strict_gates_before_member_comparison, which asserts its exact call sequence; the comment now points there. Co-Authored-By: Claude Fable 5.1 --- tests/test_spm_bundle_bootstrap.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/test_spm_bundle_bootstrap.py b/tests/test_spm_bundle_bootstrap.py index bb17c402..69af59b7 100644 --- a/tests/test_spm_bundle_bootstrap.py +++ b/tests/test_spm_bundle_bootstrap.py @@ -102,12 +102,16 @@ def test_dependency_free_generation_validates_before_writing( def test_release_checks_published_spm_before_publication_and_after_pypi_visibility(): workflow = yaml.safe_load((REPO_ROOT / ".github/workflows/push.yaml").read_text()) publish_steps = workflow["jobs"]["Publish"]["steps"] - # Publish installs the package first, so its gate is the full bundle check - # rather than the dependency-light one NotifyConsumers has to use. + # Publish runs the full bundle check rather than the dependency-light one + # NotifyConsumers has to use, but it reaches it indirectly: the whole + # prepublication gate is now `release_build.py publish-check`, which runs + # `bundle.py check --published-spm --include-tros --strict-tros` itself. + # That the indirection still carries the published-spm check is pinned by + # tests/test_release_build.py::test_publication_checks_existing_strict_gates_before_member_comparison. prepublication_gate = next( index for index, step in enumerate(publish_steps) - if "bundle.py check --published-spm" in step.get("run", "") + if "scripts/release_build.py publish-check" in step.get("run", "") ) publication = next( index From abf754548035949aaa0f2408552a7c097af961fc Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 14 Sep 2026 21:49:36 -0400 Subject: [PATCH 05/10] Place a namespace package by its portions, not by a file it cannot have MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `assert_source_origin` refused every `policyengine*` module whose `__file__` was None. `policyengine.tax_benefit_models` has no `__init__.py`, so its `__file__` is None while its `__path__` points inside the checkout, and any interpreter that has imported a country model carries it. The test session does, through tests/conftest.py -> tests/fixtures/us_reform_fixtures.py:12, so test_source_origin_rejects_previously_imported_other_checkout failed even when run alone — an instrumented probe over the three import phases found that one module and no other. Locate each module through `module_origin`: by `__file__` when it has one, otherwise by every `__path__` portion, each of which must resolve inside the prepared source. A module with neither is unattributable and still refused, as is an empty `__path__` and any portion outside the checkout — the shape a synthesized stand-in takes. Namespace packages are recorded in the receipt as the list of their portions. tests/test_graph/test_extractor.py was the session's second poisoner: it installs bare stand-ins for `policyengine` and `policyengine.graph` at import time, i.e. during collection, and never took them out. It now restores sys.modules in a `finally`, which leaves nothing behind at all. The loaded module objects stay alive through the references it returns, and extractor.py resolves its own `from policyengine.graph.graph import VariableGraph` while the entries are still installed. Three new cases cover the namespace acceptance and the three refusals. Both directions are pinned: reverting the fix fails the acceptance test, and accepting any file-less module fails all three refusals. Co-Authored-By: Claude Fable 5.1 --- .../release-candidate-source-origin.fixed.md | 1 + scripts/release_build.py | 32 +++++-- tests/test_graph/test_extractor.py | 85 ++++++++++++------- tests/test_release_build.py | 39 +++++++++ 4 files changed, 122 insertions(+), 35 deletions(-) create mode 100644 changelog.d/release-candidate-source-origin.fixed.md diff --git a/changelog.d/release-candidate-source-origin.fixed.md b/changelog.d/release-candidate-source-origin.fixed.md new file mode 100644 index 00000000..b887f8c9 --- /dev/null +++ b/changelog.d/release-candidate-source-origin.fixed.md @@ -0,0 +1 @@ +Authenticate a candidate's namespace packages by their `__path__` rather than refusing them for having no `__file__`, so preparing a release no longer holds when the interpreter has already imported a country model. Modules with neither a file nor a portion inside the prepared checkout are still refused. diff --git a/scripts/release_build.py b/scripts/release_build.py index d82d2f0c..fe93aa5d 100644 --- a/scripts/release_build.py +++ b/scripts/release_build.py @@ -489,6 +489,31 @@ def candidate_uk_tro() -> dict: ) +def module_origin(module: object, root: Path, expected: Path) -> str | list[str]: + """Locate one imported module, by its file or by its namespace portions. + + An ordinary module proves where it came from with ``__file__``. A namespace + package has none — ``policyengine.tax_benefit_models`` carries no + ``__init__.py`` — and proves it with ``__path__`` instead, so every portion + has to resolve inside the prepared checkout. A module with neither is + unattributable, which is exactly what a synthesized stand-in looks like. + """ + + def located(candidate: Path) -> str: + resolved = candidate.resolve() + if not resolved.is_relative_to(expected): + raise ValueError("Prepared package import has the wrong source origin") + return str(resolved.relative_to(root.resolve())) + + filename = getattr(module, "__file__", None) + if filename is not None: + return located(Path(filename)) + portions = list(getattr(module, "__path__", None) or ()) + if not portions: + raise ValueError("Prepared package import has no verifiable source origin") + return [located(Path(portion)) for portion in portions] + + def assert_source_origin(root: Path) -> dict: """Reject cached modules or package resources from any other checkout.""" from importlib.resources import files @@ -502,12 +527,7 @@ def assert_source_origin(root: Path) -> dict: origins = {} for name, module in tuple(sys.modules.items()): if name == "policyengine" or name.startswith("policyengine."): - filename = getattr(module, "__file__", None) - if filename is None or not Path(filename).resolve().is_relative_to( - expected - ): - raise ValueError("Prepared package import has the wrong source origin") - origins[name] = str(Path(filename).resolve().relative_to(root.resolve())) + origins[name] = module_origin(module, root, expected) if ( resources != expected or Path(policyengine.__file__).resolve().parent != expected diff --git a/tests/test_graph/test_extractor.py b/tests/test_graph/test_extractor.py index b555ff98..817a6fc6 100644 --- a/tests/test_graph/test_extractor.py +++ b/tests/test_graph/test_extractor.py @@ -25,6 +25,25 @@ from textwrap import dedent from types import ModuleType +_ABSENT = object() + +# The sys.modules entries the loader below may create or overwrite. Loading +# this way installs stand-ins that carry no ``__file__``, and leaving them +# behind would outlive this file: pytest imports it during collection, before +# any test runs, so every later test in the session would see an +# unattributable ``policyengine`` entry — precisely what +# ``scripts/release_build.py``'s source-origin check exists to refuse. The +# loader restores these as soon as it finishes. The module objects survive +# through the references it returns, and ``extractor.py`` resolves its own +# ``from policyengine.graph.graph import VariableGraph`` while the entries are +# still installed. +_TOUCHED = ( + "policyengine", + "policyengine.graph", + "policyengine.graph.graph", + "policyengine.graph.extractor", +) + # ``policyengine/__init__.py`` eagerly imports the full country-model # stack (policyengine-us, policyengine-uk), which makes a normal @@ -40,35 +59,43 @@ def _load_graph_module() -> ModuleType: return sys.modules["policyengine.graph"] graph_dir = Path(__file__).resolve().parents[2] / "src" / "policyengine" / "graph" - - if "policyengine" not in sys.modules: - fake_pkg = ModuleType("policyengine") - fake_pkg.__path__ = [str(graph_dir.parent)] - sys.modules["policyengine"] = fake_pkg - if "policyengine.graph" not in sys.modules or not hasattr( - sys.modules["policyengine.graph"], "__path__" - ): - fake_subpkg = ModuleType("policyengine.graph") - fake_subpkg.__path__ = [str(graph_dir)] - sys.modules["policyengine.graph"] = fake_subpkg - - for submod, filename in [ - ("policyengine.graph.graph", "graph.py"), - ("policyengine.graph.extractor", "extractor.py"), - ]: - if submod in sys.modules: - continue - spec = importlib.util.spec_from_file_location(submod, graph_dir / filename) - module = importlib.util.module_from_spec(spec) - sys.modules[submod] = module - spec.loader.exec_module(module) # type: ignore[union-attr] - - graph_mod = sys.modules["policyengine.graph"] - graph_mod.extract_from_path = sys.modules[ - "policyengine.graph.extractor" - ].extract_from_path - graph_mod.VariableGraph = sys.modules["policyengine.graph.graph"].VariableGraph - return graph_mod + before = {name: sys.modules.get(name, _ABSENT) for name in _TOUCHED} + + try: + if "policyengine" not in sys.modules: + fake_pkg = ModuleType("policyengine") + fake_pkg.__path__ = [str(graph_dir.parent)] + sys.modules["policyengine"] = fake_pkg + if "policyengine.graph" not in sys.modules or not hasattr( + sys.modules["policyengine.graph"], "__path__" + ): + fake_subpkg = ModuleType("policyengine.graph") + fake_subpkg.__path__ = [str(graph_dir)] + sys.modules["policyengine.graph"] = fake_subpkg + + for submod, filename in [ + ("policyengine.graph.graph", "graph.py"), + ("policyengine.graph.extractor", "extractor.py"), + ]: + if submod in sys.modules: + continue + spec = importlib.util.spec_from_file_location(submod, graph_dir / filename) + module = importlib.util.module_from_spec(spec) + sys.modules[submod] = module + spec.loader.exec_module(module) # type: ignore[union-attr] + + graph_mod = sys.modules["policyengine.graph"] + graph_mod.extract_from_path = sys.modules[ + "policyengine.graph.extractor" + ].extract_from_path + graph_mod.VariableGraph = sys.modules["policyengine.graph.graph"].VariableGraph + return graph_mod + finally: + for name, module in before.items(): + if module is _ABSENT: + sys.modules.pop(name, None) + else: + sys.modules[name] = module _graph = _load_graph_module() diff --git a/tests/test_release_build.py b/tests/test_release_build.py index 889c1003..ad55a226 100644 --- a/tests/test_release_build.py +++ b/tests/test_release_build.py @@ -8,8 +8,10 @@ import io import json import subprocess +import sys import zipfile from pathlib import Path +from types import ModuleType import pytest @@ -1046,6 +1048,43 @@ def test_source_origin_rejects_previously_imported_other_checkout( release.assert_source_origin(tmp_path) +def test_source_origin_places_a_namespace_package_by_its_portions(monkeypatch): + """``policyengine.tax_benefit_models`` has no ``__init__.py``, so no file. + + Rejecting it for that would hold every release run whose interpreter had + already imported a country model — which the test session itself does, + through ``tests/conftest.py``. Its ``__path__`` is the proof instead. + """ + monkeypatch.setenv("POLICYENGINE_SKIP_COUNTRY_IMPORTS", "1") + monkeypatch.syspath_prepend(str(ROOT / "src")) + import policyengine.tax_benefit_models as namespaced + + assert namespaced.__file__ is None + actual = release.assert_source_origin(ROOT) + assert actual["modules"]["policyengine.tax_benefit_models"] == [ + "src/policyengine/tax_benefit_models" + ] + + +@pytest.mark.parametrize("portions", ["none", "empty", "outside"]) +def test_source_origin_still_refuses_a_module_it_cannot_place(monkeypatch, portions): + """No file and no portion inside the checkout is no proof of origin at all. + + A synthesized stand-in looks exactly like this, so accepting namespace + packages must not become accepting anything without a ``__file__``. + """ + monkeypatch.setenv("POLICYENGINE_SKIP_COUNTRY_IMPORTS", "1") + monkeypatch.syspath_prepend(str(ROOT / "src")) + stand_in = ModuleType("policyengine.stand_in") + if portions == "empty": + stand_in.__path__ = [] + elif portions == "outside": + stand_in.__path__ = [str(ROOT.parent / "other/src/policyengine/stand_in")] + monkeypatch.setitem(sys.modules, "policyengine.stand_in", stand_in) + with pytest.raises(ValueError, match="source origin"): + release.assert_source_origin(ROOT) + + @pytest.mark.parametrize( "member", ["manifest.json", "us.trace.tro.jsonld", "uk.trace.tro.jsonld"] ) From b8cf14493f10c98af0cd4262aa95698ffc099dc4 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 14 Sep 2026 21:50:06 -0400 Subject: [PATCH 06/10] Record the three fixes and the probe that located the second one Co-Authored-By: Claude Fable 5.1 --- PROGRESS.md | 40 +++++++++++++++++++++++++++++++--------- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 7cee89fc..218e8f9c 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -3,16 +3,38 @@ ## State Branch stacked on the approved #515 head `1b6c001c`. Base commit `818c894e` ("Build and verify isolated wrapper release candidates") is Codex's -release-candidate tooling; it carries two real defects plus an unverified -household-test rewrite. This branch keeps the tooling and fixes the defects. +release-candidate tooling; it carries two real defects plus a household-test +rewrite the R1-R3 reviews passed through unverified. This branch keeps the +tooling and fixes all three. It cannot stand on `main`: it depends on #515's +`release_lock.py`, `check_release_credentials.py`, `spm_bundle.py`, the +`--published-spm` flag and the TRACE `repository-bundle` schema enum. ## Done -- Verified worktree at `818c894e`, clean tree. +- Verified the worktree at `818c894e`, clean tree. +- Reproduced both defects against the locked environment (`uv sync --frozen`, + Python 3.14.4): baseline `pytest tests/test_release_build.py + tests/test_spm_bundle_bootstrap.py tests/test_graph -q` gave 2 failed, + 98 passed. +- Established defect B's trigger with an instrumented probe over three import + phases rather than assuming it. Bare `import policyengine`: 0 offenders. + After `tests/conftest.py`: exactly one, the namespace package + `policyengine.tax_benefit_models` (`__file__` None, `__path__` inside the + checkout). After `tests/test_graph/test_extractor.py`: two more, the bare + stand-ins it installs for `policyengine` and `policyengine.graph`. The + conftest one is why the test failed even when run alone. +- Commit 1 `35981756`: restored `tests/test_spm_household.py` to its + `1b6c001c` content (byte-identical, sha256 `fe6df0c7...`). +- Commit 2 `14e5db47`: the bootstrap publication-gate assertion now follows + `release_build.py publish-check`. +- Commit 3 `abf75454`: `module_origin` places namespace packages by `__path__`; + the extractor restores `sys.modules`; three regression cases added. +- Mutation-checked the new tests. Pre-fix logic fails the acceptance case; + accepting any file-less module fails all three refusals. +- Targeted suite after the fixes: 104 passed, exit 0. +- `ruff format --check .`: exit 0. `ruff check .`: 11 UP038 findings, all + pre-existing at `818c894e` and none in a file this branch touches; the + files this branch touches pass clean. ## Next -1. Commit 1: revert `tests/test_spm_household.py` to the `1b6c001c` version. -2. Commit 2: fix `tests/test_spm_bundle_bootstrap.py` publication-gate assertion. -3. Commit 3: fix `assert_source_origin` namespace-package handling + extractor - sys.modules restoration + regression test. -4. Verify: targeted pytest, full pytest, lint/format. Record exit codes. -5. Push and open a DRAFT PR against `max/spm-canonical-wrapper-release-20260910`. +1. Full `uv run --no-sync pytest tests -q`; record counts and exit code. +2. Push and open a DRAFT PR against `max/spm-canonical-wrapper-release-20260910`. From d841dae30af1981564f883edf7044e02b7fcabcd Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 14 Sep 2026 21:53:22 -0400 Subject: [PATCH 07/10] Put the prior lane's PROGRESS.md back under this lane's section Commit 44604506 wrote this lane's progress over the file rather than adding to it, dropping the prior lane's 178-line record: rulings A-E, the verified 2.0.1 prerequisites, the data-certification blocker, and the country archaeology behind the rebaselined snapshots. None of that is recoverable from anywhere else in the tree. Restore it verbatim and keep this lane's section above it. Co-Authored-By: Claude Fable 5.1 --- PROGRESS.md | 260 +++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 237 insertions(+), 23 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 218e8f9c..f703e935 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1,18 +1,29 @@ -# Progress: wrapper release candidates (max/wrapper-release-candidates-20260914) +# Fix the release-candidate tooling's two defects (lane of 2026-09-14) + +Branch `max/wrapper-release-candidates-20260914`, stacked on `818c894e` +("Build and verify isolated wrapper release candidates"). That commit is +already the head of PR #515's branch `max/spm-canonical-wrapper-release-20260910` +(verified against origin), so this lane's PR is the four commits on top of it. +The prior lane's record is preserved below, unchanged. + +This branch cannot stand on `main`. Verified against `origin/main`: +`scripts/release_lock.py`, `scripts/check_release_credentials.py` and +`scripts/spm_bundle.py` do not exist there; `scripts/bundle.py` exists but has +no `--published-spm`; and `trace_tro.schema.json` exists but its `pe:emittedIn` +enum is `["local", "github-actions", "policyengine-api"]`, without the +`repository-bundle` value `release_build.py` emits. ## State -Branch stacked on the approved #515 head `1b6c001c`. Base commit `818c894e` -("Build and verify isolated wrapper release candidates") is Codex's -release-candidate tooling; it carries two real defects plus a household-test -rewrite the R1-R3 reviews passed through unverified. This branch keeps the -tooling and fixes all three. It cannot stand on `main`: it depends on #515's -`release_lock.py`, `check_release_credentials.py`, `spm_bundle.py`, the -`--published-spm` flag and the TRACE `repository-bundle` schema enum. + +The release-candidate tooling stays. Three things it carried are fixed: an +unverified household-test rewrite, a stale publication-gate assertion, and a +source-origin check that refused namespace packages. ## Done -- Verified the worktree at `818c894e`, clean tree. -- Reproduced both defects against the locked environment (`uv sync --frozen`, - Python 3.14.4): baseline `pytest tests/test_release_build.py + +- Verified the worktree at `818c894e`, clean tree. Synced the locked + environment (`uv sync --frozen`, Python 3.14.4). +- Reproduced both defects: baseline `pytest tests/test_release_build.py tests/test_spm_bundle_bootstrap.py tests/test_graph -q` gave 2 failed, 98 passed. - Established defect B's trigger with an instrumented probe over three import @@ -22,19 +33,222 @@ tooling and fixes all three. It cannot stand on `main`: it depends on #515's checkout). After `tests/test_graph/test_extractor.py`: two more, the bare stand-ins it installs for `policyengine` and `policyengine.graph`. The conftest one is why the test failed even when run alone. -- Commit 1 `35981756`: restored `tests/test_spm_household.py` to its - `1b6c001c` content (byte-identical, sha256 `fe6df0c7...`). -- Commit 2 `14e5db47`: the bootstrap publication-gate assertion now follows - `release_build.py publish-check`. -- Commit 3 `abf75454`: `module_origin` places namespace packages by `__path__`; - the extractor restores `sys.modules`; three regression cases added. -- Mutation-checked the new tests. Pre-fix logic fails the acceptance case; - accepting any file-less module fails all three refusals. +- **Commit `35981756`** restores `tests/test_spm_household.py` to its + `1b6c001c` content, byte-identical (sha256 `fe6df0c7...`). The rewrite at + `818c894e` (sha256 `061cbc1b...`) drives the assisted case through + `pha_payment_standard`, `receives_housing_assistance`, + `spm_unit_allocated_housing_subsidy` and `spm_unit_allocated_tenant_payment` + — country behaviour the unified candidate policyengine-us#9467 carries, not + the pinned model. It went through the R1–R3 reviews unverified. It comes back + with the country repin, once #9467 publishes and the pin moves. Note this + file is where Ruling A landed (see the prior lane below); `1b6c001c` is the + reviewed post-Ruling-A content, so the revert keeps Ruling A intact. +- **Commit `14e5db47`** fixes the publication-gate assertion. `818c894e` + replaced the Publish job's inline `bundle.py check --published-spm` with + `release_build.py publish-check`, so the test's `next(...)` matched nothing + and raised `StopIteration` instead of asserting. It now finds the step that + carries the gate. `publish_check` still runs that same check + (`scripts/release_build.py:1381`), pinned by + `test_publication_checks_existing_strict_gates_before_member_comparison`. +- **Commit `abf75454`** fixes `assert_source_origin`. `module_origin` places a + module by `__file__` when it has one and otherwise by every `__path__` + portion, each of which must resolve inside the prepared source. Neither, an + empty `__path__`, or any portion outside is still refused. + `tests/test_graph/test_extractor.py` now restores `sys.modules` in a + `finally`, leaving nothing behind. Three regression cases added. +- Mutation-checked the new tests. Reverting to the pre-fix logic fails the + acceptance case; accepting any file-less module fails all three refusals. - Targeted suite after the fixes: 104 passed, exit 0. -- `ruff format --check .`: exit 0. `ruff check .`: 11 UP038 findings, all - pre-existing at `818c894e` and none in a file this branch touches; the - files this branch touches pass clean. +- `ruff format --check .`: exit 0. `ruff check .`: 11 UP038 findings, every one + present at `818c894e` and none in a file this lane touches; the touched files + pass clean. Local ruff is 0.12.11; CI installs latest, where UP038 is gone. ## Next + 1. Full `uv run --no-sync pytest tests -q`; record counts and exit code. -2. Push and open a DRAFT PR against `max/spm-canonical-wrapper-release-20260910`. +2. Push and open a DRAFT PR against + `max/spm-canonical-wrapper-release-20260910`. + +--- + +*Below: the prior lane's record, restored verbatim. An earlier commit in this +lane (`44604506`) overwrote it; `abf75454`'s successor puts it back.* + +# Finalize wrapper PR #515 on policyengine-us 2.0.1 + +## State + +**Starting.** Prior lane (head `e7bfa2bd`) bound the wrapper to policyengine-us **2.0.0** +and left six tests red, blocked on wheel provenance and on wrapper-owner decisions it had +no standing to make. Both blocks are now lifted: + +- **policyengine-us 2.0.1 is published** (verified this lane, 2026-09-12): PyPI returns 200, + wheel sha256 `20e355823bbc89e6c9f413435a7d0b92095cff65541ea366048ee448da025aee`, + sdist `3183f5b1adf4f17963eec46e04bb51da5cd9b6bbda4b38fdebaea7ffa486f660`, + uploaded 2026-09-12T03:56:48Z. It is 2.0.0 plus the above-the-line-deduction determinism + fix (pe-us #9446) that the accepted candidate wheel carried and the merged 2.0.0 lacked. +- **The root has ruled** on the four wrapper-owner decisions (rulings A-E in the brief). + +This lane repins to 2.0.1, aligns the SPM resource contract with the authoritative country +behaviour, and settles all six red tests under those rulings. + +## Rulings being applied + +| Ruling | Substance | +|---|---| +| A | Country model behaviour is authoritative for the SPM resource contract. Housing cap consults the SPM housing portion only for units with housing assistance to cap. Unassisted units are zero, no geography or composition requirement. Assisted units without county still fail closed (`SPM_GEOGRAPHY_REQUIRED`). Explicit national computes. SPM measurement itself (threshold, poverty) always requires geography. Update the wrapper docstring(s) and `test_spm_household::test_state_only_tax_graph_succeeds_and_resource_graph_requires_geography`. | +| B | Pin policyengine-us==2.0.1, policyengine-core==3.32.5, spm-calculator==1.0.0 exactly; UK unchanged. Regenerate bundle manifest and uv.lock; run the repo's bundle checks. | +| C | `test_us_model_version_surface`: counts are identity facts of the pinned country. Regenerate; report before/after counts. | +| D | `test_us_household_snapshot[us_single_adult_no_income]`: `spm_unit.snap` 3596.04 -> 298.00 is a pre-existing country defect (one month's 2026 allotment reported annual), identical on 1.825.2 and 2.0.x, tracked at PolicyEngine/policyengine-us#9447. Do NOT rebaseline: xfail(strict=True) or exclude the single field, whichever is cleaner. | +| E | `test_us_household_snapshot` x3 (employment income, single parent, married two kids): regenerate, but justify every changed field old -> new against a located country change. Anything unexplained stays red and is reported. | + +## Prerequisites (verified this lane, primary sources) + +| Item | Value | Status | +|---|---|---| +| policyengine-us 2.0.1 wheel sha256 | `20e355823bbc89e6c9f413435a7d0b92095cff65541ea366048ee448da025aee` | verified (PyPI JSON, HTTP 200) | +| policyengine-us 2.0.1 sdist sha256 | `3183f5b1adf4f17963eec46e04bb51da5cd9b6bbda4b38fdebaea7ffa486f660` | verified | +| 2.0.1 declared deps | `policyengine-core>=3.30.1`, `spm-calculator==1.0.0` | verified | +| spm-calculator 1.0.0 wheel sha256 | `e354937a5e1a4045d4966ed594a528d8b02866fabaac9bb5672017004b627305` | carried from prior lane; to re-verify | +| policyengine-core 3.32.5 | pinned exactly by the bundle | to re-verify | +| HF data tag | `populace-us-2024-spm-20260909`, H5 `6496cc43...` | carried; to re-verify | + +## BLOCKER (Ruling B): 2.0.1 is published, but no data release is certified for it + +**The repin to policyengine-us 2.0.1 cannot be completed in this repo.** It is blocked on +an act by the *data publisher*, not by anything in the wrapper. + +The certified US data release `populace-us-2024-spm-20260909` publishes this claim: + +```json +"build": {"built_with_model_package": {"name": "policyengine-us", "version": "2.0.0"}}, +"compatible_model_packages": [{"name": "policyengine-us", "specifier": "==2.0.0"}] +``` + +`certify_data_release_compatibility` accepts exactly three bases — build-time match, +matching data build fingerprint, or a publisher `compatible_model_packages` claim. 2.0.1 +satisfies none (the manifest records no `data_build_fingerprint`), so: + +- `bundle.py certify-data --model-version 2.0.1` **refuses**: + *"policyengine-us 2.0.1 matches neither the build-time model (2.0.0) nor any publisher + compatibility claim ['==2.0.0']; a new data build or a published compatibility claim is + required."* +- With 2.0.1 installed, the gate fires at **import of the US model** (`us_latest = + PolicyEngineUSLatest()`, `model.py:512`), raising *"Data release manifest is not certified + for the runtime model version 2.0.1 in country 'us'."* Not just microsimulation — + `pe.us.calculate_household` and every US test error out too. + +This is the documented, intended behaviour. `docs/engineering/skills/data-certification.md`: +*"Neither basis means certification is refused: a new data build or a published +compatibility claim is required."* There is no override flag, and I did not manufacture one: +hand-writing a claim into the bundle, or mutating the published release manifest at an +immutable release tag, would launder exactly the invariant this gate exists to protect. + +I verified no published release covers 2.0.1: all 25 `policyengine/populace-us` tags and all +3 branches were enumerated; `populace-us-2024-spm-20260909` is the newest, and its manifest +carries `==2.0.0` on both the tag and `main`. + +### The claim-widening is substantively justified (evidence for the publisher) + +2.0.1 is 2.0.0 plus pe-us#9446 and nothing else. Verified by extracting and diffing both +published wheels: + +| Check | Result | +|---|---| +| Files differing, whole wheel | 2 variable `.py` files + 1 added test (`tests/core/test_ald_determinism.py`) + dist-info | +| `variables/` file set | **identical** — 5981 files in both | +| `parameters/` file set | **identical** — 5970 files, and byte-identical content | +| Substance of both diffs | `list(set(all_alds) - ...)` -> `sorted(set(all_alds) - ...)` | +| 2.0.1 wheel sha256 | `20e355823bbc89e6c9f413435a7d0b92095cff65541ea366048ee448da025aee` (PyPI-declared == downloaded == brief) | + +The change alters only the *order* of float summation within above-the-line-deduction +aggregation. No variable, parameter, or input schema changed, so the dataset's compatibility +with the model is unaffected — widening the claim to cover 2.0.1 asserts nothing the bytes +do not already support. + +**Unblocking action (data publisher, one line):** publish a populace-us release whose +manifest claims `policyengine-us` compatibility covering 2.0.1 (a new release tag — +never an in-place edit of the existing immutable tag). The wrapper side is then +`bundle.py update-packages --us 2.0.1` + `certify-data --model-version 2.0.1` + relock. + +**This lane therefore leaves the branch on the certified 2.0.0 tuple** and completes every +other part of the brief in full. A branch pinned to 2.0.1 would fail to import the US model +at all, making PR #515 entirely red and unreviewable — strictly worse than what it replaces. + +### Consequence for Ruling C + +Ruling C asks for the variable/parameter counts "regenerated for 2.0.1". Because the +`variables/` and `parameters/` file sets are provably identical between 2.0.0 and 2.0.1, +those counts are the same number on either pin. Regenerating on 2.0.0 yields exactly the +2.0.1 result. + +## Done + +- Read the prior lane's PROGRESS.md and the full branch history (24 commits off `origin/main`). +- Verified the 2.0.1 prerequisite from PyPI directly. Wheel sha256 matches the brief exactly. +- Ran the repin through the tooling, hit the certification gate, and reverted to the certified + 2.0.0 tuple with a clean tree (see BLOCKER above). +- Established the repin procedure the prior lane used (repository tooling only: + `bundle.py update-packages`, `set-spm`, `certify-data`, `generate`; extras are generated + from the bundle manifest, so no pin is hand-edited). + +- **Ruling A done** (`85b1d711`). Read the authoritative source in the 2.0.1 wheel: + `spm_unit_capped_housing_subsidy` computes `assisted = housing_assistance > 0`, returns + zeros without touching the provider when none is assisted, and otherwise calls + `masked_policyengine_amount`, which evaluates only the assisted rows through + `CountyRequiringSPMProvider`. Probed all four clauses against the installed model rather + than assuming: 5/5 resource outputs compute with an empty measurement receipt when housing + assistance is zero; 5/5 raise `SPM_GEOGRAPHY_REQUIRED` when it is positive; threshold and + poverty raise either way; default outputs still require the choice. Updated the public + docstring, `docs/households.md`, and the contract test (renamed, since its old name + asserted the unconditional rule). 42/42 SPM household tests pass. +- **Ruling C done** (`456af5da`). Regenerated `us_model_surface`: + `num_variables_bucketed_100s` 57 -> 61 (raw 6157), `num_parameters_bucketed_100s` + 978 -> 1025 (raw 102525), `data_package_name` `populace-data` -> `microcosm-data`. + Counted the raw surface under both installed wheels: **identical** on 2.0.0 and 2.0.1 + (6157 / 102525 both), so this snapshot is already the 2.0.1 value. +- **Ruling D done** (`74675620`). Excluded the four contaminated fields rather than + xfailing the case, because all four are downstream of the one defect and an xfail would + have discarded the other 42 fields of coverage. Two guards make it honest: + `_check_snapshot` preserves prior values for excluded fields under + `PE_UPDATE_SNAPSHOTS=1` (verified by md5 across a refresh), and + `test_snap_annualization_defect_still_present` fails loudly on fix. Note a strict xfail + would *not* have failed loudly: the corrected annual figure is ~3,576 against a stored + 3,596.04, so the case would have gone on xfailing silently. +- Confirmed policyengine-us#9447 is OPEN and its body records the same values on 1.825.2 + and 2.0.0, independently corroborating Ruling D. + +- **Ruling E done** (`04960e64`), and **Ruling D reversed on a corrected premise** (`2019530f`). + Fanned out the country archaeology across the four drift signatures, each adversarially + verified from three lenses (arithmetic, provenance, alternative-cause): 12 verdicts, 11 + upholding, 1 refuting. Two root causes explain all three Ruling-E cases, both reproduced + from parameter values to the cent: + - `c991cd844a` (PR #9100, 1.779.1) added published BLS CPI-U actuals, moving the + 2026-01-01 index 323.364 -> 326.588; the NSLP/SBP per-meal rates uprate through it, so + the free-tier net school meal subsidy goes 1130.96 -> 1142.24. + - `df3482f4ef` (PR #9059, 1.776.2) moved `uprating: gov.states.ca.cpi` off the CA standard + deduction's file-level metadata, where `uprate_parameters` never read it, onto each + filing status. The deduction un-freezes into 2026: SINGLE 5,706 -> 5,835.31, + JOINT/HOH 11,412 -> 11,670.63. At the 6% and 9.3% marginal brackets that is -7.76 and + -24.05 exactly. +- **The SNAP finding overturned Ruling D's premise, and I verified it myself before acting.** + There is no annualization defect; the x12 is intact. The case is an ABAWD with no hours + supplied, and two country changes decide it: `82745ca239` dropped + `weekly_hours_worked_before_lsr`'s default from 40 to 0, and `74b0a75e5f` added + `waived_states.yaml`, under which California's statewide ABAWD waiver expires 2026-01-31. + One eligible month at 298.00. Measured directly: monthly snap 2026 is `[298, 0 x 11]`; + the same household with hours = 40 returns 3607.571; CA/IL/NV return 298.00 while + TX/NY/FL return 0.00. The year series I had cited as proof of a resolution defect + (584/3522/298/0 for 2024-2027) is the CA waiver schedule read line by line. So the four + fields were rebaselined under Ruling E's standard and the defect guard was replaced with + a test pinning the real mechanism. + +## Next + +1. Ruling E: regenerate the three drifting snapshots, justify every field against a located + country change. Root causes already isolated by measurement - the three cases reduce to + exactly two: school meal subsidy 1130.96 -> 1142.24 (+11.28, both child cases) and CA + state income tax (-7.76 at 60k single, -24.05 at 240k joint). Archaeology running. +2. Full `make test` as CI runs it; lint, mypy, changelog; push; `gh pr checks 515`; update + the PR body and mark ready for review. +3. Ruling B stays blocked - see BLOCKER above. Hand the publisher the evidence pack. From 82721388d954c34a644d58a95942ad437f4dd007 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 14 Sep 2026 22:05:19 -0400 Subject: [PATCH 08/10] Correct the import path and verify the country attribution Two claims made earlier in this lane needed tightening. The namespace package is first imported through tests/conftest.py:6 -> filtering_fixtures.py:7, not through us_reform_fixtures.py:12 as abf75454's message says; both are unrestored module-level imports and the fix covers either. And the rewrite's dependency on policyengine-us#9467 was asserted from the brief rather than checked: #9467's file list does add exactly the two absent variables, so the attribution now rests on the PR itself. Co-Authored-By: Claude Fable 5.1 --- PROGRESS.md | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index f703e935..9952869a 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -63,11 +63,35 @@ source-origin check that refused namespace packages. present at `818c894e` and none in a file this lane touches; the touched files pass clean. Local ruff is 0.12.11; CI installs latest, where UP038 is gone. +- Full suite: 1203 passed, 9 skipped, 280s, exit 0. +- Pushed `d841dae3`; draft PR #520 open against + `max/spm-canonical-wrapper-release-20260910`. + +## Corrections to earlier claims in this lane + +- `abf75454`'s message names `tests/conftest.py` -> + `tests/fixtures/us_reform_fixtures.py:12` as the import path to the namespace + package. That path is real but not the first: `tests/conftest.py:6` -> + `tests/fixtures/filtering_fixtures.py:7` reaches it earlier in every session. + Both are unrestored module-level imports and the fix covers either. +- The rewrite's dependency on #9467 is now verified rather than assumed. Exactly + two of the names it introduces are absent from the pinned policyengine-us + 2.0.0 -- `spm_unit_allocated_housing_subsidy` and + `spm_unit_allocated_tenant_payment`, zero `class (Variable)` definitions + each -- and PolicyEngine/policyengine-us#9467's file list adds exactly those + two variable files. `pre_subsidy_rent`, `pha_payment_standard` and + `receives_housing_assistance` do exist in 2.0.0. +- No review examined either defect. `tests/test_spm_bundle_bootstrap.py` appears + in none of the six `WRAPPER-RELEASE-BUILD*` files, none of the three frozen + inventories and none of the three independent reviews. + `WRAPPER-RELEASE-BUILD-R2-RESPONSE.md:35` carries + `tests/test_spm_household.py` by hash and delegates it elsewhere; the three + reviews contain zero occurrences of "household". + ## Next -1. Full `uv run --no-sync pytest tests -q`; record counts and exit code. -2. Push and open a DRAFT PR against - `max/spm-canonical-wrapper-release-20260910`. +1. Wait for CI on PR #520 (`gh pr checks 520`). +2. The household rewrite returns with the country repin, once #9467 publishes. --- From e0b1f4f2aa19132a443924f3e844ee3bbe97cc03 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 14 Sep 2026 22:10:16 -0400 Subject: [PATCH 09/10] Hold the strict origin property where session state cannot reach it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An adversarial pass over the three fixes found four things worth closing, all in the direction of keeping what the relaxed guard gave up. A root `policyengine` stand-in used to be refused by the file rule before anything else ran. Accepting a `__path__` let it through the walk and into `files("policyengine")`, which raises AttributeError on it — a class main() does not catch, so a hold would have ended in a traceback. Settle the root package's own `__init__.py` first, and short-circuit before `files()`. The relaxed rule newly admits a fileless module whose `__path__` points inside the checkout. Neither `__file__` nor `__path__` is proof — an in-process caller that can write sys.modules can assign either — so the docstring now says what the check does and does not establish, and a test pins the admitted shape instead of leaving it to be discovered. WRAPPER-R3-CI-DIAGNOSIS-20260913.md §1 asks for the origin control to run in a fresh interpreter matching the production `source_command` boundary, and a root-approved patch (wrapper-ci-harness-repair-20260913/ROOT-REVIEW.json, parent 818c894e) does exactly that instead of touching the guard. Take the subprocess control as well: it proves the cross-checkout refusal where no collector has been, so the property survives independently of the allowance. §2 asks that publish-check precede the tag as well as the upload. The git tag is a public side effect between them, so assert the full order. Co-Authored-By: Claude Fable 5.1 --- scripts/release_build.py | 24 ++++++-- tests/test_release_build.py | 95 ++++++++++++++++++++++++++++++ tests/test_spm_bundle_bootstrap.py | 8 ++- 3 files changed, 120 insertions(+), 7 deletions(-) diff --git a/scripts/release_build.py b/scripts/release_build.py index fe93aa5d..8aefdd83 100644 --- a/scripts/release_build.py +++ b/scripts/release_build.py @@ -497,6 +497,13 @@ def module_origin(module: object, root: Path, expected: Path) -> str | list[str] ``__init__.py`` — and proves it with ``__path__`` instead, so every portion has to resolve inside the prepared checkout. A module with neither is unattributable, which is exactly what a synthesized stand-in looks like. + + Both attributes are self-declarations, not proof: an in-process caller that + can write ``sys.modules`` can set either one. This locates a module; it does + not authenticate it. The invariant being kept is that nothing is served out + of another checkout, and for that ``__path__`` is as good as ``__file__`` — + a namespace package spanning a second checkout carries that checkout's + portion and is refused here. """ def located(candidate: Path) -> str: @@ -518,19 +525,24 @@ def assert_source_origin(root: Path) -> dict: """Reject cached modules or package resources from any other checkout.""" from importlib.resources import files - import policyengine - import policyengine.provenance.manifest - import policyengine.provenance.trace + # Loaded so the walk below has them to place, not for their names. + import policyengine # noqa: F401 + import policyengine.provenance.manifest # noqa: F401 + import policyengine.provenance.trace # noqa: F401 expected = (root / "src/policyengine").resolve() - resources = Path(str(files("policyengine"))).resolve() origins = {} for name, module in tuple(sys.modules.items()): if name == "policyengine" or name.startswith("policyengine."): origins[name] = module_origin(module, root, expected) + # The root package has an __init__.py, so unlike its namespace subpackages + # it owes a file. Settle that before asking importlib for the resource + # root: `files()` on a stand-in raises AttributeError, which main() does + # not catch, so the run would end in a traceback instead of the reported + # hold this raises. if ( - resources != expected - or Path(policyengine.__file__).resolve().parent != expected + origins.get("policyengine") != "src/policyengine/__init__.py" + or Path(str(files("policyengine"))).resolve() != expected ): raise ValueError("Prepared package resources have the wrong source origin") return {"resources": "src/policyengine", "modules": origins} diff --git a/tests/test_release_build.py b/tests/test_release_build.py index ad55a226..3a980f02 100644 --- a/tests/test_release_build.py +++ b/tests/test_release_build.py @@ -7,6 +7,7 @@ import importlib.util import io import json +import os import subprocess import sys import zipfile @@ -1048,6 +1049,73 @@ def test_source_origin_rejects_previously_imported_other_checkout( release.assert_source_origin(tmp_path) +def test_source_origin_rejects_another_checkout_from_a_clean_interpreter(tmp_path): + """The same control as above, run where no collector has been. + + The in-process version inherits whatever the pytest session imported. This + one matches the production boundary — `source_command` starts a fresh + interpreter — so it pins the strict property independently of collection + order, and would still hold if the namespace allowance above were removed. + """ + env = { + key: value + for key, value in os.environ.items() + if key not in {"PYTHONHOME", "PYTHONUSERBASE"} + } + env.update( + POLICYENGINE_SKIP_COUNTRY_IMPORTS="1", + PYTHONPATH=str(ROOT / "src"), + PYTHONDONTWRITEBYTECODE="1", + ) + result = subprocess.run( + [ + sys.executable, + "-B", + "-s", + "-c", + """ +import json +import sys +from pathlib import Path + +root, other = map(Path, sys.argv[1:]) +sys.path.insert(0, str(root / "scripts")) +import release_build + +actual = release_build.assert_source_origin(root) +assert not [ + name + for name, module in sys.modules.items() + if (name == "policyengine" or name.startswith("policyengine.")) + and getattr(module, "__file__", None) is None +], "a clean interpreter should hold no fileless policyengine module" +sys.path.insert(0, str(other / "src")) +try: + release_build.assert_source_origin(other) +except ValueError as error: + assert "source origin" in str(error) +else: + raise AssertionError("Cached imports from the original source were accepted") +print(json.dumps(actual)) +""", + str(ROOT), + str(tmp_path), + ], + cwd=ROOT, + env=env, + capture_output=True, + text=True, + timeout=120, + check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr + actual = json.loads(result.stdout) + assert actual["resources"] == "src/policyengine" + assert actual["modules"]["policyengine.provenance.trace"].startswith( + "src/policyengine/" + ) + + def test_source_origin_places_a_namespace_package_by_its_portions(monkeypatch): """``policyengine.tax_benefit_models`` has no ``__init__.py``, so no file. @@ -1085,6 +1153,33 @@ def test_source_origin_still_refuses_a_module_it_cannot_place(monkeypatch, porti release.assert_source_origin(ROOT) +def test_source_origin_takes_an_in_checkout_path_at_its_word(monkeypatch): + """The shape the namespace allowance newly admits, stated rather than left silent. + + A fileless module that declares a ``__path__`` inside the checkout is + accepted and recorded, because nothing here can tell a real namespace + package from a stand-in that assigned the attribute. That is not a step + down from ``__file__``, which is equally assignable, and the strict + cross-checkout property is pinned in a clean interpreter by + ``test_source_origin_rejects_another_checkout_from_a_clean_interpreter``. + A stand-in for the root package is still refused: it owes an + ``__init__.py``. + """ + monkeypatch.setenv("POLICYENGINE_SKIP_COUNTRY_IMPORTS", "1") + monkeypatch.syspath_prepend(str(ROOT / "src")) + stand_in = ModuleType("policyengine.stand_in") + stand_in.__path__ = [str(ROOT / "src/policyengine/provenance")] + monkeypatch.setitem(sys.modules, "policyengine.stand_in", stand_in) + actual = release.assert_source_origin(ROOT) + assert actual["modules"]["policyengine.stand_in"] == ["src/policyengine/provenance"] + + root_stand_in = ModuleType("policyengine") + root_stand_in.__path__ = [str(ROOT / "src/policyengine")] + monkeypatch.setitem(sys.modules, "policyengine", root_stand_in) + with pytest.raises(ValueError, match="source origin"): + release.assert_source_origin(ROOT) + + @pytest.mark.parametrize( "member", ["manifest.json", "us.trace.tro.jsonld", "uk.trace.tro.jsonld"] ) diff --git a/tests/test_spm_bundle_bootstrap.py b/tests/test_spm_bundle_bootstrap.py index 69af59b7..8e2b496c 100644 --- a/tests/test_spm_bundle_bootstrap.py +++ b/tests/test_spm_bundle_bootstrap.py @@ -118,7 +118,13 @@ def test_release_checks_published_spm_before_publication_and_after_pypi_visibili for index, step in enumerate(publish_steps) if step.get("uses", "").startswith("pypa/gh-action-pypi-publish@") ) - assert prepublication_gate < publication + # The git tag is a public side effect too, so the gate precedes it as well. + tag = next( + index + for index, step in enumerate(publish_steps) + if ".github/publish-git-tag.sh" in step.get("run", "") + ) + assert prepublication_gate < tag < publication notify = workflow["jobs"]["NotifyConsumers"] assert "Publish" in notify["needs"] steps = notify["steps"] From e98d280ae0cfbc684db59da6af8cf62d90e86001 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 14 Sep 2026 22:17:45 -0400 Subject: [PATCH 10/10] Record the two conflicts with prior rulings and the fourth blocker Co-Authored-By: Claude Fable 5.1 --- PROGRESS.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/PROGRESS.md b/PROGRESS.md index 9952869a..c70b16f1 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -88,10 +88,45 @@ source-origin check that refused namespace packages. `tests/test_spm_household.py` by hash and delegates it elsewhere; the three reviews contain zero occurrences of "household". +## Conflicts with prior rulings (for the wrapper owner to settle) + +An adversarial pass found two places where this lane's brief and a prior +approved ruling disagree. Both are reported, not resolved here. + +1. **The guard change runs against `WRAPPER-R3-CI-DIAGNOSIS-20260913.md` §1**, + which says: "Keep the production guard strict. Do not delete arbitrary + cached modules or accept missing origins merely to pass the full suite." + A root-approved patch -- + `wrapper-ci-harness-repair-20260913/ROOT-REVIEW.json`, `approved: true`, + parent `818c894e`, patch sha256 `9ad1e023...` -- repairs the same failure in + the test harness with a fresh subprocess and leaves `assert_source_origin` + untouched. `e0b1f4f2` takes that subprocess control as an additional test, + so the strict cross-checkout property is pinned either way and adopting the + approved patch instead would cost only the namespace branch. +2. **§3 says to preserve the household rewrite**, not revert it: "preserve this + assertion and complete the already approved final-country pin transaction". + The brief instructed the revert, recorded as returning with the repin. The + evidence that justifies restoring it is + `household-integration-20260912/REPORT.md`: 84 wrapper controls against the + authenticated country `2.0.2rc1`. + +Also: §1's line-208 reading means the restore is two-part. The rewrite needs +both the two absent variables and the country's assisted ordinary-resource +independence; re-adding only the variables would still fail. + +## A fourth CI blocker these fixes do not touch + +`gh pr checks 515` shows `Nonpublishing wrapper candidate (release)` failing in +12s at `scripts/check_release_credentials.py` -- "Release verification requires +an authenticated Hugging Face access token with role read" -- before any strict +gate. These fixes address the four `Test (3.x)` failures. PR #515 stays red +until the credential is settled. + ## Next 1. Wait for CI on PR #520 (`gh pr checks 520`). 2. The household rewrite returns with the country repin, once #9467 publishes. +3. Wrapper owner to settle the two conflicts above. ---