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/PROGRESS.md b/PROGRESS.md index c05aa2b6..c70b16f1 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1,3 +1,138 @@ +# 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 + +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. 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 + 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 `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, 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. + +- 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". + +## 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. + +--- + +*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 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/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/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..8aefdd83 --- /dev/null +++ b/scripts/release_build.py @@ -0,0 +1,1516 @@ +"""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 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. + + 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: + 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 + + # 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() + 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 ( + 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} + + +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_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 new file mode 100644 index 00000000..3a980f02 --- /dev/null +++ b/tests/test_release_build.py @@ -0,0 +1,1251 @@ +"""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 os +import subprocess +import sys +import zipfile +from pathlib import Path +from types import ModuleType + +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) + + +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. + + 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) + + +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"] +) +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_bundle_bootstrap.py b/tests/test_spm_bundle_bootstrap.py index bb17c402..8e2b496c 100644 --- a/tests/test_spm_bundle_bootstrap.py +++ b/tests/test_spm_bundle_bootstrap.py @@ -102,19 +102,29 @@ 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 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"]