diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index 795fdd2..acbddf7 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -1,13 +1,18 @@ name: Publish to PyPI (trusted publisher) -# Release policy: CI runs on v* tags first. Publish only after the tag CI is green. -# Dispatch from main and provide the immutable annotated release tag, for example: -# gh workflow run pypi-publish.yml --ref main -f release_tag=v0.25.2 +# Drop-in 035 release policy: +# 1. Merge the owner-authorization unit to main and require exact-main CI. +# 2. Create and push the immutable annotated v0.26.0 tag. +# 3. Require tag CI to pass. +# 4. Dispatch this workflow from main with the exact tag: +# gh workflow run pypi-publish.yml --ref main -f release_tag=v0.26.0 +# 5. Verify the public wheel, sdist, embedded companion asset, and clean reader +# route before committing the post-publication receipt. on: workflow_dispatch: inputs: release_tag: - description: "Annotated release tag to build and publish (for example, v0.25.2)" + description: "Annotated release tag to build and publish (exactly v0.26.0 for Drop-in 035)" required: true type: string @@ -16,7 +21,7 @@ jobs: environment: pypi runs-on: ubuntu-latest permissions: - id-token: write # needed for trusted publishing + id-token: write # required for trusted publishing contents: read steps: @@ -29,19 +34,17 @@ jobs: # actions/checkout can materialize a commit-shaped local tag ref when a # workflow is dispatched against a tag. Re-fetch the named ref explicitly - # so the release gate sees the original annotated tag object from origin. + # so every release gate sees the original annotated tag object. - name: Restore and verify exact annotated release tag shell: bash env: RELEASE_TAG: ${{ inputs.release_tag }} run: | - case "$RELEASE_TAG" in - v[0-9]*) ;; - *) - echo "Release tag must start with v followed by a version: $RELEASE_TAG" - exit 1 - ;; - esac + set -euo pipefail + if [ "$RELEASE_TAG" != "v0.26.0" ]; then + echo "Drop-in 035 authorizes exactly v0.26.0, not $RELEASE_TAG" + exit 1 + fi git fetch --force origin "refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}" @@ -67,16 +70,34 @@ jobs: with: python-version: "3.10" - - name: Verify annotated release tag matches package version - run: python tools/check_release_tag.py + - name: Verify release tag, technical proof, and owner authorization + run: | + set -euo pipefail + python tools/check_release_tag.py + python tools/check_psych_design_release_proof.py --phase complete + python tools/check_psych_design_public_release.py --phase authorization - - name: Install build backend + - name: Install deterministic build backend run: | + set -euo pipefail python -m pip install --upgrade pip - python -m pip install build + python -m pip install "build>=1.2,<2" setuptools==80.9.0 wheel==0.45.1 - - name: Build distributions - run: python -m build + - name: Build governed companion asset and distributions + shell: bash + run: | + set -euo pipefail + export SOURCE_DATE_EPOCH="$(git show -s --format=%ct HEAD)" + rm -rf build dist *.egg-info src/*.egg-info + python tools/build_psych_design_companion_asset.py + python -m build --no-isolation + + - name: Verify exact public distribution identities before upload + run: | + set -euo pipefail + python tools/check_psych_design_public_release.py \ + --phase distributions \ + --dist-dir dist - name: Publish to PyPI via trusted publisher uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/Makefile b/Makefile index e19fb92..9ad0d30 100644 --- a/Makefile +++ b/Makefile @@ -45,6 +45,9 @@ help: @echo " psych-design-release-contract-verify - verify the advanced-book companion audit and command contract" @echo " psych-design-release-proof-check - verify the Drop-in 034 candidate freeze and proof contract" @echo " psych-design-release-proof-compare - compare committed Ubuntu and WSL proof receipts" + @echo " psych-design-public-release-check - verify Drop-in 035 owner authorization and source scope" + @echo " psych-design-public-release-distributions-check - verify built v0.26.0 wheel and sdist" + @echo " psych-design-public-release-public-check - verify the committed public PyPI receipt" @echo " local-r-verify - run all governed R parity checks locally (never in GitHub Actions)" @echo " release-verify - require an annotated v tag matching pyproject.toml" @echo " lint - ruff check" @@ -555,6 +558,19 @@ psych-design-release-proof-compare: --readiness $(PSYCH_DESIGN_RELEASE_EVIDENCE)/PSYCH_DESIGN_RELEASE_READINESS.json $(PYTHON) tools/check_psych_design_release_proof.py --phase complete +# --- Psychological Statistics by Design owner-authorized public release --- +.PHONY: psych-design-public-release-check +psych-design-public-release-check: + $(PYTHON) tools/check_psych_design_public_release.py --phase authorization + +.PHONY: psych-design-public-release-distributions-check +psych-design-public-release-distributions-check: + $(PYTHON) tools/check_psych_design_public_release.py --phase distributions --dist-dir dist + +.PHONY: psych-design-public-release-public-check +psych-design-public-release-public-check: + $(PYTHON) tools/check_psych_design_public_release.py --phase public + # --- Quality gates --- .PHONY: lint lint: diff --git a/README.md b/README.md index 1ce87e2..08bc514 100644 --- a/README.md +++ b/README.md @@ -38,31 +38,36 @@ python scripts/psych_ch10_problem_set.py pytest -q ``` -## Psychological Statistics by Design companion — v0.26.0 release candidate +## Psychological Statistics by Design companion — PyStatsV1 v0.26.0 -The source tree now contains a **non-public release candidate** for -*Psychological Statistics by Design — Executable Companion v0.1*. It unifies -ten synthetic Student Well-Being and Learning studies behind one registry, ten -visible Python entry scripts, independent R implementations, verification -receipts, figure specifications, reviewer packets, and synthetic-safe adaptation -templates. PyStatsV1 remains the versioned bridge and launcher; it does not -choose a method, validate an instrument, authorize data, or establish causality. +PyStatsV1 v0.26.0 distributes *Psychological Statistics by Design — Executable +Companion v0.1*. It unifies ten synthetic Student Well-Being and Learning +studies behind one registry, ten visible Python entry scripts, independent R +implementations, verification receipts, figure specifications, reviewer +packets, and synthetic-safe adaptation templates. PyStatsV1 remains the +versioned bridge and launcher; it does not choose a method, validate an +instrument, authorize data, or establish causality. -The candidate interface is implemented for local and CI proof: +The verified public reader route is: ```bash -python -m pip install -e '.[psych-design]' -python tools/build_psych_design_companion_asset.py +python3 -m venv .venv +source .venv/bin/activate +python -m pip install --upgrade pip +python -m pip install "pystatsv1[psych-design]==0.26.0" pystatsv1 psych-design info pystatsv1 psych-design init cd psychological_statistics_by_design_companion_v0_1 pystatsv1 psych-design verify --dest . python -m pip install -r requirements-psych-design-companion.txt +pystatsv1 psych-design doctor --dest . make first-result # complete SWL-S02 Python/R V2 chain; requires Rscript +pystatsv1 psych-design verify --dest . ``` -This is **not yet a PyPI or portal reader route**. Public release, portal -deployment, book binding, and real-data use remain unauthorized. The current +The governed reader proofs cover native Ubuntu and Windows 11 WSL2 with Ubuntu. +The companion remains synthetic-only. Portal deployment, book binding, and +real-data use remain separately governed and unauthorized by this release. The public foundations companion continues to use the separately pinned Book 1 route below. diff --git a/docs/PYSTATSV1_DROPIN_035_OWNER_AUTHORIZED_V0260_PUBLIC_RELEASE.md b/docs/PYSTATSV1_DROPIN_035_OWNER_AUTHORIZED_V0260_PUBLIC_RELEASE.md new file mode 100644 index 0000000..c5e3583 --- /dev/null +++ b/docs/PYSTATSV1_DROPIN_035_OWNER_AUTHORIZED_V0260_PUBLIC_RELEASE.md @@ -0,0 +1,82 @@ +# PyStatsV1 Drop-in 035 — Owner-authorized v0.26.0 public release + +## Purpose + +Drop-in 035 converts the technically proven Psychological Statistics by Design +companion candidate into the exact public PyStatsV1 `0.26.0` distribution. The +unit authorizes only PyPI publication through the repository's existing trusted +publisher, then records and verifies the public wheel and source distribution. + +## Approved baseline + +- Approved parent main: `400a3d688fb3c341fd393aca715de6d941b7f1e6` +- Release-proof tag: `psych-design-companion-v0-1-release-proof` +- Frozen scientific candidate: `58d92f85b865cb657bf49c731fefc381970c8bda` +- Candidate tag: `psych-design-companion-v0-1-candidate` +- Package release: `pystatsv1==0.26.0` +- Companion release: `v0.1` +- Final annotated tag: `v0.26.0` + +The complete Drop-in 034 Ubuntu/WSL proof, all 2,982 governed cross-platform +checks, the deterministic asset identity, and the closed non-PyPI gates remain +unchanged. + +## Authorization boundary + +The repository owner explicitly authorizes: + +1. the exact annotated `v0.26.0` tag; +2. tag CI; +3. manual dispatch of `.github/workflows/pypi-publish.yml`; +4. trusted-publisher upload to PyPI; +5. public wheel and sdist verification; and +6. a post-publication evidence receipt. + +The authorization does **not** authorize portal deployment, book binding, +real-data support, scientific changes, package-runtime changes, companion-asset +changes, or edits to the Drop-in 034 proof contract. + +## Release gates + +Before publication, the source checker requires: + +- the complete Drop-in 034 technical-readiness receipt; +- the release-proof tag at the approved parent commit; +- the exact governed companion asset hash; +- the Drop-in 035 owner-authorization receipt; +- an exact allowlisted diff from the approved parent; +- the manual trusted-publisher workflow; +- an annotated tag matching `pyproject.toml`; and +- all portal, book, and real-data gates closed. + +The trusted-publisher workflow rebuilds the governed asset, builds the wheel and +sdist with the pinned build backend, verifies both distributions, and publishes +only after all source and authorization checks pass. + +## Public verification + +After PyPI reports `0.26.0`, the local verifier: + +- downloads the wheel and sdist from PyPI; +- verifies their PyPI-reported SHA-256 hashes; +- verifies package name and version metadata; +- verifies the exact embedded companion asset in both artifacts; +- creates a fresh Python 3.10 virtual environment; +- installs `pystatsv1[psych-design]==0.26.0` from the public PyPI index; +- runs `info`, `init`, `verify`, and `doctor`; +- runs the complete SWL-S02 `make first-result` Python/R verification chain; +- verifies the regenerated first-result files; and +- writes `PSYCH_DESIGN_PUBLIC_RELEASE.json` without persisting an absolute + reader workspace path. + +## Final state + +A successful unit records: + +- public wheel filename, size, and SHA-256; +- public sdist filename, size, and SHA-256; +- exact embedded companion asset identity; +- release commit and annotated tag; +- exact tag-CI and trusted-publisher workflow run IDs; +- successful fresh-public-install command evidence; and +- `pypi_release_authorized: true` with portal, book, and real-data gates false. diff --git a/docs/source/release_notes.rst b/docs/source/release_notes.rst index 84d6aab..b04f3e3 100644 --- a/docs/source/release_notes.rst +++ b/docs/source/release_notes.rst @@ -1,19 +1,21 @@ Release notes ============= -v0.26.0 release candidate — Psychological Statistics by Design companion v0.1 ---------------------------------------------------------------------------------- +v0.26.0 — Psychological Statistics by Design companion v0.1 +------------------------------------------------------------------ -PyStatsV1 v0.26.0 is prepared as a non-public release candidate for the -synthetic-only *Psychological Statistics by Design* executable companion. -Publication to PyPI, portal deployment, and book binding remain separate, -explicitly unauthorized actions. +PyStatsV1 v0.26.0 publishes the synthetic-only *Psychological Statistics by +Design* executable companion after clean native-Ubuntu and Windows 11 WSL2 +installed-wheel proofs, exact cross-platform scientific comparison, annotated +tag CI, trusted-publisher delivery, and public-artifact verification. Portal +deployment, book binding, and real-data support remain separate, explicitly +unauthorized actions. Highlights ~~~~~~~~~~ * Adds ``pystatsv1 psych-design info``, ``init``, ``verify``, - ``list-studies``, and ``doctor`` behind the release-candidate source tree. + ``list-studies``, and ``doctor`` for the governed companion. * Packages all ten SWL studies in one deterministic companion with a central ``STUDY_INDEX.json``, ten visible Python entry scripts, independent base-R paths, V2/V3/V4 comparison policies, figures, APA source maps, matched @@ -28,9 +30,11 @@ Highlights source bundle. * Includes synthetic-safe adaptation templates and states that adaptation is a new research project rather than replacement of a CSV. -* Retains all public-release, PyPI, portal, book-binding, and real-data - authorization gates as false pending clean Ubuntu and WSL installed-wheel - proofs. +* Authorizes only the PyPI v0.26.0 publication path after the complete release + proof; portal binding, book binding, and real-data authorization remain false. +* Records the public wheel and source-distribution hashes after trusted + publishing and verifies a fresh exact-version install through the SWL-S02 + first-result route. v0.25.2 — Book 1 Companion v0.2.1 identity correction ---------------------------------------------------------------- diff --git a/release_proofs/psych_design_v0_1/PYPI_RELEASE_AUTHORIZATION.json b/release_proofs/psych_design_v0_1/PYPI_RELEASE_AUTHORIZATION.json new file mode 100644 index 0000000..d3fdebe --- /dev/null +++ b/release_proofs/psych_design_v0_1/PYPI_RELEASE_AUTHORIZATION.json @@ -0,0 +1,83 @@ +{ + "allowed_release_commit_paths": [ + ".github/workflows/pypi-publish.yml", + "Makefile", + "README.md", + "docs/PYSTATSV1_DROPIN_035_OWNER_AUTHORIZED_V0260_PUBLIC_RELEASE.md", + "docs/source/release_notes.rst", + "release_proofs/psych_design_v0_1/PYPI_RELEASE_AUTHORIZATION.json", + "release_proofs/psych_design_v0_1/evidence/PSYCH_DESIGN_PUBLIC_RELEASE.json", + "tests/test_psych_design_public_release.py", + "tests/test_psych_design_release_proof.py", + "tests/test_release_025_metadata.py", + "tools/check_psych_design_public_release.py", + "tools/check_psych_design_release_proof.py", + "tools/verify_pystatsv1_public_release.py" + ], + "authorization": { + "authorized_on_local_date": "2026-07-30", + "authorized_scope": "Publish PyStatsV1 0.26.0 to PyPI through the existing trusted-publisher workflow and verify the public artifacts.", + "basis": "Explicit repository-owner instruction for PyStatsV1 Drop-in 035.", + "status": "explicitly_authorized" + }, + "authorization_state": { + "book_binding_authorized": false, + "portal_release_authorized": false, + "pypi_release_authorized": true, + "real_data_authorized": false + }, + "companion_identity": { + "asset_bytes": 136801, + "asset_manifest_sha256": "24f496631758a955a91d53193868b3c02ce9d62bb218fd59d3bb52ea9305e52f", + "asset_path": "src/pystatsv1/assets/psychological_statistics_by_design_companion_v0_1.zip", + "asset_sha256": "bd0932c1d126758f37fab180173d6b95b5e4c31bc654724251aa6eb6296243c1", + "companion_version": "v0.1", + "immutable_source_file_count": 151, + "study_index_sha256": "1c2525fa1ef4b720d758a32f5e3ad987264e8dfeb28f3967201efca575ef6df7" + }, + "dropin": "035", + "protected_release_boundaries": { + "analysis_or_dataset_changes_allowed": false, + "book_or_portal_changes_allowed": false, + "companion_asset_changes_allowed": false, + "package_runtime_changes_allowed": false, + "proof_contract_changes_allowed": false, + "real_data_authorized": false + }, + "public_artifact_policy": { + "expected_sdist_name": "pystatsv1-0.26.0.tar.gz", + "expected_wheel_name": "pystatsv1-0.26.0-py3-none-any.whl", + "fresh_public_install_required": true, + "public_hashes_recorded_after_publication": true, + "required_commands": [ + "pystatsv1 psych-design info", + "pystatsv1 psych-design init", + "pystatsv1 psych-design verify --dest .", + "pystatsv1 psych-design doctor --dest .", + "make first-result", + "pystatsv1 psych-design verify --dest ." + ], + "required_index": "https://pypi.org/simple", + "trusted_publisher_required": true + }, + "release_identity": { + "approved_parent_commit": "400a3d688fb3c341fd393aca715de6d941b7f1e6", + "candidate_commit": "58d92f85b865cb657bf49c731fefc381970c8bda", + "candidate_tag": "psych-design-companion-v0-1-candidate", + "package_name": "pystatsv1", + "package_version": "0.26.0", + "proof_tag": "psych-design-companion-v0-1-release-proof", + "release_commit_policy": "The annotated v0.26.0 tag must resolve to the exact merged Drop-in 035 authorization commit whose complete diff from approved_parent_commit is restricted to allowed_release_commit_paths.", + "release_tag": "v0.26.0" + }, + "required_prepublication_state": { + "candidate_implementation_passed": true, + "clean_ubuntu_proof_passed": true, + "clean_wsl_proof_passed": true, + "cross_platform_comparison_passed": true, + "dropin_034_readiness_gates_remain_closed": true, + "technical_release_ready": true + }, + "schema_version": "pystatsv1-psych-design-pypi-release-authorization-v0.1", + "status": "owner_authorized_pending_publication" +} diff --git a/release_proofs/psych_design_v0_1/evidence/PSYCH_DESIGN_PUBLIC_RELEASE.json b/release_proofs/psych_design_v0_1/evidence/PSYCH_DESIGN_PUBLIC_RELEASE.json new file mode 100644 index 0000000..a09569b --- /dev/null +++ b/release_proofs/psych_design_v0_1/evidence/PSYCH_DESIGN_PUBLIC_RELEASE.json @@ -0,0 +1,17 @@ +{ + "authorization_state": { + "book_binding_authorized": false, + "portal_release_authorized": false, + "pypi_release_authorized": true, + "real_data_authorized": false + }, + "companion_version": "v0.1", + "package_name": "pystatsv1", + "package_version": "0.26.0", + "proof_tag": "psych-design-companion-v0-1-release-proof", + "public_artifacts_verified": false, + "release_commit": "pending_until_v0.26.0_tag_creation", + "release_tag": "v0.26.0", + "schema_version": "pystatsv1-psych-design-public-release-v0.1", + "status": "owner_authorized_pending_publication" +} diff --git a/tests/test_psych_design_public_release.py b/tests/test_psych_design_public_release.py new file mode 100644 index 0000000..09fd13a --- /dev/null +++ b/tests/test_psych_design_public_release.py @@ -0,0 +1,463 @@ +from __future__ import annotations + +import importlib.util +import io +import json +import tarfile +import zipfile +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +PROOF = ROOT / "release_proofs" / "psych_design_v0_1" +EVIDENCE = PROOF / "evidence" +AUTHORIZATION = PROOF / "PYPI_RELEASE_AUTHORIZATION.json" +PUBLIC_RECEIPT = EVIDENCE / "PSYCH_DESIGN_PUBLIC_RELEASE.json" +READINESS = EVIDENCE / "PSYCH_DESIGN_RELEASE_READINESS.json" +ASSET = ( + ROOT + / "src" + / "pystatsv1" + / "assets" + / "psychological_statistics_by_design_companion_v0_1.zip" +) +CHECKER_PATH = ROOT / "tools" / "check_psych_design_public_release.py" +VERIFIER_PATH = ROOT / "tools" / "verify_pystatsv1_public_release.py" +EXPECTED_PATHS = [ + ".github/workflows/pypi-publish.yml", + "Makefile", + "README.md", + "docs/PYSTATSV1_DROPIN_035_OWNER_AUTHORIZED_V0260_PUBLIC_RELEASE.md", + "docs/source/release_notes.rst", + "release_proofs/psych_design_v0_1/PYPI_RELEASE_AUTHORIZATION.json", + "release_proofs/psych_design_v0_1/evidence/PSYCH_DESIGN_PUBLIC_RELEASE.json", + "tests/test_psych_design_public_release.py", + "tests/test_psych_design_release_proof.py", + "tests/test_release_025_metadata.py", + "tools/check_psych_design_public_release.py", + "tools/check_psych_design_release_proof.py", + "tools/verify_pystatsv1_public_release.py", +] + + +def load(path: Path) -> dict: + return json.loads(path.read_text(encoding="utf-8")) + + +def load_module(name: str, path: Path): + spec = importlib.util.spec_from_file_location(name, path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def checker(): + return load_module("public_release_checker_for_tests", CHECKER_PATH) + + +def verifier(): + return load_module("public_release_verifier_for_tests", VERIFIER_PATH) + + +def test_owner_authorization_is_exact_and_bounded() -> None: + auth = load(AUTHORIZATION) + assert auth["schema_version"] == ( + "pystatsv1-psych-design-pypi-release-authorization-v0.1" + ) + assert auth["dropin"] == "035" + assert auth["status"] == "owner_authorized_pending_publication" + assert auth["authorization"]["status"] == "explicitly_authorized" + assert auth["authorization_state"] == { + "pypi_release_authorized": True, + "portal_release_authorized": False, + "book_binding_authorized": False, + "real_data_authorized": False, + } + assert auth["release_identity"]["approved_parent_commit"] == ( + "400a3d688fb3c341fd393aca715de6d941b7f1e6" + ) + assert auth["release_identity"]["release_tag"] == "v0.26.0" + assert auth["release_identity"]["proof_tag"] == ( + "psych-design-companion-v0-1-release-proof" + ) + assert auth["allowed_release_commit_paths"] == EXPECTED_PATHS + + +def test_authorization_protects_package_science_book_and_portal() -> None: + boundaries = load(AUTHORIZATION)["protected_release_boundaries"] + assert boundaries == { + "analysis_or_dataset_changes_allowed": False, + "book_or_portal_changes_allowed": False, + "companion_asset_changes_allowed": False, + "package_runtime_changes_allowed": False, + "proof_contract_changes_allowed": False, + "real_data_authorized": False, + } + assert not any(path.startswith("src/") for path in EXPECTED_PATHS) + assert not any(path.startswith("psych_design_companion/") for path in EXPECTED_PATHS) + + +def test_dropin_034_readiness_remains_immutable_and_closed() -> None: + state = load(READINESS) + assert state["technical_release_ready"] is True + assert state["clean_ubuntu_proof_passed"] is True + assert state["clean_wsl_proof_passed"] is True + assert state["cross_platform_comparison_passed"] is True + assert state["pypi_release_authorized"] is False + assert state["portal_release_authorized"] is False + assert state["book_binding_authorized"] is False + assert state["real_data_authorized"] is False + + +def test_public_receipt_lifecycle_preserves_exact_authorization_boundary() -> None: + receipt = load(PUBLIC_RECEIPT) + assert receipt["status"] in { + "owner_authorized_pending_publication", + "public_release_verified", + } + if receipt["status"] == "owner_authorized_pending_publication": + assert receipt["public_artifacts_verified"] is False + assert receipt["release_commit"] == "pending_until_v0.26.0_tag_creation" + else: + assert receipt["public_artifacts_verified"] is True + assert len(receipt["release_commit"]) == 40 + assert receipt["authorization_state"] == { + "pypi_release_authorized": True, + "portal_release_authorized": False, + "book_binding_authorized": False, + "real_data_authorized": False, + } + + +def test_governed_asset_identity_is_unchanged() -> None: + import hashlib + + assert ASSET.stat().st_size == 136801 + assert hashlib.sha256(ASSET.read_bytes()).hexdigest() == ( + "bd0932c1d126758f37fab180173d6b95b5e4c31bc654724251aa6eb6296243c1" + ) + + +def test_release_notes_and_readme_expose_public_route_and_boundaries() -> None: + notes = (ROOT / "docs/source/release_notes.rst").read_text(encoding="utf-8") + readme = (ROOT / "README.md").read_text(encoding="utf-8") + assert "v0.26.0 — Psychological Statistics by Design companion v0.1" in notes + assert "v0.26.0 release candidate" not in notes + assert "trusted-publisher delivery" in notes + assert 'pystatsv1[psych-design]==0.26.0' in readme + assert "native Ubuntu and Windows 11 WSL2" in readme + assert "synthetic-only" in readme + assert "real-data use remain separately governed" in readme + + +def test_publish_workflow_is_manual_trusted_and_fully_gated() -> None: + workflow = (ROOT / ".github/workflows/pypi-publish.yml").read_text( + encoding="utf-8" + ) + assert "workflow_dispatch:" in workflow + assert "push:" not in workflow + assert "environment: pypi" in workflow + assert "id-token: write" in workflow + assert 'if [ "$RELEASE_TAG" != "v0.26.0" ]' in workflow + assert "python tools/check_release_tag.py" in workflow + assert "python tools/check_psych_design_release_proof.py --phase complete" in workflow + assert "python tools/check_psych_design_public_release.py --phase authorization" in workflow + assert "python tools/build_psych_design_companion_asset.py" in workflow + assert '"build>=1.2,<2" setuptools==80.9.0 wheel==0.45.1' in workflow + assert "python -m build --no-isolation" in workflow + assert "--phase distributions" in workflow + assert "pypa/gh-action-pypi-publish@release/v1" in workflow + assert "psych-design-companion-v0-1-candidate" not in workflow + + +def test_makefile_exposes_dropin_035_checks() -> None: + text = (ROOT / "Makefile").read_text(encoding="utf-8") + assert "psych-design-public-release-check:" in text + assert "psych-design-public-release-distributions-check:" in text + assert "psych-design-public-release-public-check:" in text + + +def test_public_release_source_checker_passes_without_git_metadata() -> None: + import subprocess + import sys + + completed = subprocess.run( + [sys.executable, str(CHECKER_PATH), "--phase", "source"], + cwd=ROOT, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + assert completed.returncode == 0, completed.stdout + completed.stderr + assert "PYSTATSV1_PUBLIC_RELEASE_CHECK_OK" in completed.stdout + + +def test_authorization_document_checker_accepts_frozen_receipt() -> None: + module = checker() + module.verify_authorization_document(load(AUTHORIZATION)) + + +@pytest.mark.parametrize( + "gate", + ("portal_release_authorized", "book_binding_authorized", "real_data_authorized"), +) +def test_authorization_document_rejects_open_non_pypi_gate(gate: str) -> None: + module = checker() + auth = load(AUTHORIZATION) + auth["authorization_state"][gate] = True + with pytest.raises(module.PublicReleaseCheckError, match=gate): + module.verify_authorization_document(auth) + + +def test_authorization_document_requires_pypi_authorization() -> None: + module = checker() + auth = load(AUTHORIZATION) + auth["authorization_state"]["pypi_release_authorized"] = False + with pytest.raises(module.PublicReleaseCheckError, match="not authorized"): + module.verify_authorization_document(auth) + + +def test_technical_readiness_checker_accepts_completed_dropin_034() -> None: + checker().verify_technical_readiness() + + +def _write_wheel(path: Path, module, *, asset: bytes | None = None, version: str = "0.26.0") -> None: + asset = ASSET.read_bytes() if asset is None else asset + with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as archive: + archive.writestr( + f"pystatsv1-{version}.dist-info/METADATA", + f"Metadata-Version: 2.4\nName: pystatsv1\nVersion: {version}\n\n", + ) + archive.writestr(module.ASSET_MEMBER, asset) + archive.writestr(f"pystatsv1-{version}.dist-info/RECORD", "") + + +def _add_tar_bytes(archive: tarfile.TarFile, name: str, payload: bytes) -> None: + info = tarfile.TarInfo(name) + info.size = len(payload) + archive.addfile(info, io.BytesIO(payload)) + + +def _write_sdist(path: Path, module, *, asset: bytes | None = None, version: str = "0.26.0") -> None: + asset = ASSET.read_bytes() if asset is None else asset + root = f"pystatsv1-{version}" + with tarfile.open(path, "w:gz") as archive: + _add_tar_bytes( + archive, + f"{root}/PKG-INFO", + f"Metadata-Version: 2.4\nName: pystatsv1\nVersion: {version}\n\n".encode(), + ) + _add_tar_bytes(archive, f"{root}/pyproject.toml", b"[project]\n") + _add_tar_bytes(archive, f"{root}/{module.SDIST_ASSET_SUFFIX}", asset) + + +def test_wheel_inspector_accepts_exact_public_identity(tmp_path: Path) -> None: + module = verifier() + wheel = tmp_path / module.EXPECTED_WHEEL + _write_wheel(wheel, module) + result = module.inspect_wheel(wheel) + assert result["metadata_name"] == "pystatsv1" + assert result["metadata_version"] == "0.26.0" + assert result["embedded_asset_sha256"] == module.ASSET_SHA256 + assert result["embedded_asset_bytes"] == 136801 + + +def test_wheel_inspector_rejects_changed_asset(tmp_path: Path) -> None: + module = verifier() + wheel = tmp_path / module.EXPECTED_WHEEL + payload = bytearray(ASSET.read_bytes()) + payload[0] ^= 1 + _write_wheel(wheel, module, asset=bytes(payload)) + with pytest.raises(module.PublicReleaseError, match="asset hash changed"): + module.inspect_wheel(wheel) + + +def test_wheel_inspector_rejects_changed_metadata_version(tmp_path: Path) -> None: + module = verifier() + wheel = tmp_path / module.EXPECTED_WHEEL + with zipfile.ZipFile(wheel, "w") as archive: + archive.writestr( + "pystatsv1-0.26.0.dist-info/METADATA", + "Metadata-Version: 2.4\nName: pystatsv1\nVersion: 0.25.2\n\n", + ) + archive.writestr(module.ASSET_MEMBER, ASSET.read_bytes()) + archive.writestr("pystatsv1-0.26.0.dist-info/RECORD", "") + with pytest.raises(module.PublicReleaseError, match="version is not 0.26.0"): + module.inspect_wheel(wheel) + + +def test_sdist_inspector_accepts_exact_public_identity(tmp_path: Path) -> None: + module = verifier() + sdist = tmp_path / module.EXPECTED_SDIST + _write_sdist(sdist, module) + result = module.inspect_sdist(sdist) + assert result["metadata_name"] == "pystatsv1" + assert result["metadata_version"] == "0.26.0" + assert result["embedded_asset_sha256"] == module.ASSET_SHA256 + + +def test_sdist_inspector_rejects_changed_asset(tmp_path: Path) -> None: + module = verifier() + sdist = tmp_path / module.EXPECTED_SDIST + payload = bytearray(ASSET.read_bytes()) + payload[-1] ^= 1 + _write_sdist(sdist, module, asset=bytes(payload)) + with pytest.raises(module.PublicReleaseError, match="asset identity changed"): + module.inspect_sdist(sdist) + + +def test_sdist_inspector_rejects_links(tmp_path: Path) -> None: + module = verifier() + sdist = tmp_path / module.EXPECTED_SDIST + with tarfile.open(sdist, "w:gz") as archive: + info = tarfile.TarInfo("pystatsv1-0.26.0/link") + info.type = tarfile.SYMTYPE + info.linkname = "../../unsafe" + archive.addfile(info) + with pytest.raises(module.PublicReleaseError, match="link member"): + module.inspect_sdist(sdist) + + +def test_distribution_directory_requires_exact_wheel_and_sdist(tmp_path: Path) -> None: + module = verifier() + _write_wheel(tmp_path / module.EXPECTED_WHEEL, module) + _write_sdist(tmp_path / module.EXPECTED_SDIST, module) + result = module.inspect_distribution_directory(tmp_path) + assert result["embedded_asset_identity_exact"] is True + (tmp_path / "unexpected.txt").write_text("x", encoding="utf-8") + with pytest.raises(module.PublicReleaseError, match="exactly"): + module.inspect_distribution_directory(tmp_path) + + +def _smoke() -> dict: + return { + "status": "pass", + "command_status": { + "pip_install_public_exact": True, + "psych_design_info": True, + "psych_design_init": True, + "verify_before": True, + "doctor": True, + "first_result": True, + "verify_after": True, + }, + } + + +def _distributions(module) -> dict: + row = { + "filename": module.EXPECTED_WHEEL, + "sha256": "a" * 64, + "embedded_asset_sha256": module.ASSET_SHA256, + } + sdist = { + "filename": module.EXPECTED_SDIST, + "sha256": "b" * 64, + "embedded_asset_sha256": module.ASSET_SHA256, + } + return {"wheel": row, "sdist": sdist, "embedded_asset_identity_exact": True} + + +def test_public_receipt_builder_accepts_verified_release() -> None: + module = verifier() + receipt = module.build_public_receipt( + release_commit="c" * 40, + tag_ci_run_id="123", + publish_workflow_run_id="456", + distributions=_distributions(module), + smoke=_smoke(), + ) + assert receipt["status"] == "public_release_verified" + assert receipt["public_artifacts_verified"] is True + assert receipt["authorization_state"] == { + "pypi_release_authorized": True, + "portal_release_authorized": False, + "book_binding_authorized": False, + "real_data_authorized": False, + } + + +def test_public_receipt_builder_rejects_invalid_release_commit() -> None: + module = verifier() + with pytest.raises(module.PublicReleaseError, match="40-character"): + module.build_public_receipt( + release_commit="short", + tag_ci_run_id="123", + publish_workflow_run_id="456", + distributions=_distributions(module), + smoke=_smoke(), + ) + + +def test_public_receipt_builder_rejects_failed_command() -> None: + module = verifier() + smoke = _smoke() + smoke["command_status"]["first_result"] = False + with pytest.raises(module.PublicReleaseError, match="every command"): + module.build_public_receipt( + release_commit="c" * 40, + tag_ci_run_id="123", + publish_workflow_run_id="456", + distributions=_distributions(module), + smoke=smoke, + ) + + +def test_public_phase_rejects_pending_receipt( + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = checker() + pending = { + "schema_version": "pystatsv1-psych-design-public-release-v0.1", + "status": "owner_authorized_pending_publication", + "package_version": "0.26.0", + "public_artifacts_verified": False, + "authorization_state": { + "pypi_release_authorized": True, + "portal_release_authorized": False, + "book_binding_authorized": False, + "real_data_authorized": False, + }, + } + monkeypatch.setattr(module, "load", lambda _path: pending) + with pytest.raises(module.PublicReleaseCheckError, match="has not been verified"): + module.verify_public_receipt(load(AUTHORIZATION)) + + +def test_public_receipt_checker_rejects_open_non_pypi_gate( + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = checker() + receipt = { + "schema_version": "pystatsv1-psych-design-public-release-v0.1", + "package_version": "0.26.0", + "status": "public_release_verified", + "public_artifacts_verified": True, + "trusted_publisher": True, + "scientific_candidate_unchanged": True, + "release_commit": "c" * 40, + "tag_ci_run_id": "123", + "publish_workflow_run_id": "456", + "artifacts": _distributions(verifier()), + "reader_smoke": { + "status": "pass", + "installed_version": "0.26.0", + "installed_asset_sha256": module.EXPECTED_ASSET_SHA256, + "command_status": {"x": True}, + "first_result_outputs": {"x": True}, + "absolute_workspace_path_recorded": False, + }, + "authorization_state": { + "pypi_release_authorized": True, + "portal_release_authorized": True, + "book_binding_authorized": False, + "real_data_authorized": False, + }, + } + monkeypatch.setattr(module, "load", lambda _path: receipt) + monkeypatch.setattr(module, "verify_release_tag", lambda *_args, **_kwargs: "c" * 40) + with pytest.raises(module.PublicReleaseCheckError, match="opens closed gate"): + module.verify_pending_public_receipt() diff --git a/tests/test_psych_design_release_proof.py b/tests/test_psych_design_release_proof.py index 2faa4b1..3f1999a 100644 --- a/tests/test_psych_design_release_proof.py +++ b/tests/test_psych_design_release_proof.py @@ -746,3 +746,54 @@ def test_source_phase_routing_remains_independent_of_lifecycle_state( checker.main() assert "phase=source" in capsys.readouterr().out + + +def test_dropin_034_checker_accepts_only_owner_authorized_final_tag_contract() -> None: + checker = _load_checker_module() + authorization = load(PROOF / "PYPI_RELEASE_AUTHORIZATION.json") + assert checker.PYPI_AUTHORIZATION == PROOF / "PYPI_RELEASE_AUTHORIZATION.json" + assert authorization["schema_version"] == ( + "pystatsv1-psych-design-pypi-release-authorization-v0.1" + ) + assert authorization["authorization_state"]["pypi_release_authorized"] is True + assert authorization["authorization_state"]["portal_release_authorized"] is False + assert authorization["authorization_state"]["book_binding_authorized"] is False + assert authorization["authorization_state"]["real_data_authorized"] is False + + +def test_authorized_final_tag_checker_rejects_non_annotated_tag( + monkeypatch: pytest.MonkeyPatch, +) -> None: + checker = _load_checker_module() + authorization = load(PROOF / "PYPI_RELEASE_AUTHORIZATION.json") + monkeypatch.setattr(checker, "load", lambda _path: authorization) + + def fake_git(*args: str) -> str: + if args == ("cat-file", "-t", "refs/tags/v0.26.0"): + return "commit" + raise AssertionError(args) + + monkeypatch.setattr(checker, "_git", fake_git) + with pytest.raises(SystemExit, match="must be an annotated tag"): + checker.verify_authorized_final_tag() + + +def test_authorized_final_tag_checker_requires_exact_allowlisted_diff( + monkeypatch: pytest.MonkeyPatch, +) -> None: + checker = _load_checker_module() + authorization = load(PROOF / "PYPI_RELEASE_AUTHORIZATION.json") + monkeypatch.setattr(checker, "load", lambda _path: authorization) + + def fake_git(*args: str) -> str: + if args == ("cat-file", "-t", "refs/tags/v0.26.0"): + return "tag" + if args == ("rev-parse", "v0.26.0^{commit}"): + return "c" * 40 + if args[:2] == ("diff", "--name-only"): + return "src/pystatsv1/cli.py" + raise AssertionError(args) + + monkeypatch.setattr(checker, "_git", fake_git) + with pytest.raises(SystemExit, match="exceeds the owner-authorized path scope"): + checker.verify_authorized_final_tag() diff --git a/tests/test_release_025_metadata.py b/tests/test_release_025_metadata.py index 9924772..c9c1046 100644 --- a/tests/test_release_025_metadata.py +++ b/tests/test_release_025_metadata.py @@ -9,17 +9,19 @@ ROOT = Path(__file__).resolve().parents[1] -def test_pyproject_version_is_0260_release_candidate(): +def test_pyproject_version_is_0260_release(): data = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) assert data["project"]["version"] == "0.26.0" -def test_release_notes_preserve_history_and_add_v0260_candidate(): +def test_release_notes_preserve_history_and_add_v0260_release(): text = ( ROOT / "docs" / "source" / "release_notes.rst" ).read_text(encoding="utf-8") assert text.startswith("Release notes\n=============\n\n") - assert "v0.26.0 release candidate — Psychological Statistics by Design companion v0.1" in text + assert "v0.26.0 — Psychological Statistics by Design companion v0.1" in text + assert "v0.26.0 release candidate" not in text + assert "trusted-publisher delivery" in text assert "v0.25.2 — Book 1 Companion v0.2.1 identity correction" in text assert "v0.25.1 — Chapter 20 power stability and release guard" in text assert "v0.25.0 — Book 1 Companion v0.2" in text diff --git a/tools/check_psych_design_public_release.py b/tools/check_psych_design_public_release.py new file mode 100755 index 0000000..2daf603 --- /dev/null +++ b/tools/check_psych_design_public_release.py @@ -0,0 +1,427 @@ +#!/usr/bin/env python3 +"""Verify Drop-in 035 PyPI authorization, distributions, and public receipt.""" + +from __future__ import annotations + +import argparse +import hashlib +import importlib.util +import json +import re +import subprocess +import sys +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +PROOF = ROOT / "release_proofs" / "psych_design_v0_1" +EVIDENCE = PROOF / "evidence" +AUTHORIZATION = PROOF / "PYPI_RELEASE_AUTHORIZATION.json" +READINESS = EVIDENCE / "PSYCH_DESIGN_RELEASE_READINESS.json" +PUBLIC_RECEIPT = EVIDENCE / "PSYCH_DESIGN_PUBLIC_RELEASE.json" +ASSET = ( + ROOT + / "src" + / "pystatsv1" + / "assets" + / "psychological_statistics_by_design_companion_v0_1.zip" +) +PYPROJECT = ROOT / "pyproject.toml" +WORKFLOW = ROOT / ".github" / "workflows" / "pypi-publish.yml" +RELEASE_NOTES = ROOT / "docs" / "source" / "release_notes.rst" +VERIFIER = ROOT / "tools" / "verify_pystatsv1_public_release.py" +EXPECTED_PARENT = "400a3d688fb3c341fd393aca715de6d941b7f1e6" +EXPECTED_PROOF_TAG = "psych-design-companion-v0-1-release-proof" +EXPECTED_RELEASE_TAG = "v0.26.0" +EXPECTED_VERSION = "0.26.0" +EXPECTED_ASSET_SHA256 = ( + "bd0932c1d126758f37fab180173d6b95b5e4c31bc654724251aa6eb6296243c1" +) +EXPECTED_ASSET_BYTES = 136801 +EXPECTED_STUDIES = [f"SWL-S{number:02d}" for number in range(1, 11)] +CLOSED_GATES = ( + "portal_release_authorized", + "book_binding_authorized", + "real_data_authorized", +) + + +class PublicReleaseCheckError(RuntimeError): + """Raised when a Drop-in 035 release gate fails.""" + + +def fail(message: str) -> None: + raise PublicReleaseCheckError(message) + + +def load(path: Path) -> dict[str, Any]: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + fail(f"invalid JSON {path.relative_to(ROOT)}: {exc}") + + +def sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _git(*args: str, check: bool = True) -> str: + completed = subprocess.run( + ["git", "-C", str(ROOT), *args], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if check and completed.returncode != 0: + detail = completed.stderr.strip() or completed.stdout.strip() + fail(f"git {' '.join(args)} failed: {detail}") + return completed.stdout.strip() + + +def _has_git() -> bool: + return (ROOT / ".git").exists() + + +def project_version() -> str: + text = PYPROJECT.read_text(encoding="utf-8") + match = re.search(r'^version\s*=\s*"([^"]+)"\s*$', text, re.MULTILINE) + if not match: + fail("could not read project version") + return match.group(1) + + +def load_verifier_module(): + spec = importlib.util.spec_from_file_location( + "pystatsv1_public_release_verifier_for_checker", VERIFIER + ) + if spec is None or spec.loader is None: + fail("could not load public-release verifier") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def verify_authorization_document(auth: dict[str, Any]) -> None: + if auth.get("schema_version") != ( + "pystatsv1-psych-design-pypi-release-authorization-v0.1" + ): + fail("authorization schema changed") + if auth.get("dropin") != "035": + fail("authorization is not Drop-in 035") + if auth.get("status") != "owner_authorized_pending_publication": + fail("authorization status changed") + release = auth.get("release_identity", {}) + expected = { + "approved_parent_commit": EXPECTED_PARENT, + "candidate_commit": "58d92f85b865cb657bf49c731fefc381970c8bda", + "candidate_tag": "psych-design-companion-v0-1-candidate", + "package_name": "pystatsv1", + "package_version": EXPECTED_VERSION, + "proof_tag": EXPECTED_PROOF_TAG, + "release_tag": EXPECTED_RELEASE_TAG, + } + for key, value in expected.items(): + if release.get(key) != value: + fail(f"authorization release identity changed: {key}") + authorization = auth.get("authorization", {}) + if authorization.get("status") != "explicitly_authorized": + fail("repository-owner authorization is not explicit") + state = auth.get("authorization_state", {}) + if state.get("pypi_release_authorized") is not True: + fail("PyPI release is not authorized") + for gate in CLOSED_GATES: + if state.get(gate) is not False: + fail(f"authorization opens closed gate: {gate}") + boundaries = auth.get("protected_release_boundaries", {}) + for key in ( + "analysis_or_dataset_changes_allowed", + "book_or_portal_changes_allowed", + "companion_asset_changes_allowed", + "package_runtime_changes_allowed", + "proof_contract_changes_allowed", + "real_data_authorized", + ): + if boundaries.get(key) is not False: + fail(f"authorization weakens protected boundary: {key}") + companion = auth.get("companion_identity", {}) + if companion.get("asset_sha256") != EXPECTED_ASSET_SHA256: + fail("authorization asset hash changed") + if companion.get("asset_bytes") != EXPECTED_ASSET_BYTES: + fail("authorization asset byte size changed") + if companion.get("immutable_source_file_count") != 151: + fail("authorization source-file count changed") + public = auth.get("public_artifact_policy", {}) + if public.get("trusted_publisher_required") is not True: + fail("trusted publisher is no longer required") + if public.get("fresh_public_install_required") is not True: + fail("fresh public install is no longer required") + + +def verify_technical_readiness() -> None: + readiness = load(READINESS) + expected = { + "candidate_implementation_passed": True, + "clean_ubuntu_proof_passed": True, + "clean_wsl_proof_passed": True, + "cross_platform_comparison_passed": True, + "technical_release_ready": True, + "pypi_release_authorized": False, + "portal_release_authorized": False, + "book_binding_authorized": False, + "real_data_authorized": False, + } + for key, value in expected.items(): + if readiness.get(key) is not value: + fail(f"Drop-in 034 readiness changed: {key}") + if readiness.get("status") != "technical_release_ready_not_authorized": + fail("Drop-in 034 readiness status changed") + if readiness.get("package_version") != EXPECTED_VERSION: + fail("Drop-in 034 package version changed") + + +def verify_frozen_asset() -> None: + if not ASSET.is_file(): + fail("governed companion asset is missing") + if ASSET.stat().st_size != EXPECTED_ASSET_BYTES: + fail("governed companion asset size changed") + if sha256(ASSET) != EXPECTED_ASSET_SHA256: + fail("governed companion asset hash changed") + + +def verify_release_source_paths(auth: dict[str, Any]) -> None: + expected = sorted(auth.get("allowed_release_commit_paths", [])) + if len(expected) != len(set(expected)) or len(expected) != 13: + fail("authorized path list is invalid") + if not _has_git(): + return + parent = auth["release_identity"]["approved_parent_commit"] + _git("cat-file", "-e", f"{parent}^{{commit}}") + committed = _git("diff", "--name-only", f"{parent}..HEAD").splitlines() + working = _git("diff", "--name-only", "HEAD").splitlines() + untracked = _git("ls-files", "--others", "--exclude-standard").splitlines() + actual = sorted(set(committed + working + untracked)) + if actual != expected: + fail( + "release-source path scope differs from authorization; " + f"expected={expected}, actual={actual}" + ) + forbidden_prefixes = ( + "src/", + "psych_design_companion/", + "data/", + "book1_companion/", + "release_proofs/psych_design_v0_1/RELEASE_PROOF_CONTRACT.json", + "release_proofs/psych_design_v0_1/CANDIDATE_FREEZE.json", + "release_proofs/psych_design_v0_1/evidence/PSYCH_DESIGN_UBUNTU_RELEASE_PROOF.json", + "release_proofs/psych_design_v0_1/evidence/PSYCH_DESIGN_WSL_RELEASE_PROOF.json", + "release_proofs/psych_design_v0_1/evidence/PSYCH_DESIGN_CROSS_PLATFORM_COMPARISON.json", + "release_proofs/psych_design_v0_1/evidence/PSYCH_DESIGN_WHEEL_IDENTITY.json", + "release_proofs/psych_design_v0_1/evidence/PSYCH_DESIGN_ASSET_IDENTITY.json", + ) + for path in actual: + if path.startswith(forbidden_prefixes): + fail(f"release source changes protected path: {path}") + + +def verify_proof_tag() -> None: + if not _has_git(): + return + tags = set(_git("tag", "--list").splitlines()) + if EXPECTED_PROOF_TAG not in tags: + fail("release-proof tag is missing") + proof_commit = _git("rev-parse", f"{EXPECTED_PROOF_TAG}^{{commit}}") + if proof_commit != EXPECTED_PARENT: + fail("release-proof tag moved") + + +def verify_release_tag(auth: dict[str, Any], *, required: bool) -> str | None: + if not _has_git(): + return None + tags = set(_git("tag", "--list").splitlines()) + if EXPECTED_RELEASE_TAG not in tags: + if required: + fail("v0.26.0 release tag is missing") + return None + if _git("cat-file", "-t", f"refs/tags/{EXPECTED_RELEASE_TAG}") != "tag": + fail("v0.26.0 must be an annotated tag") + commit = _git("rev-parse", f"{EXPECTED_RELEASE_TAG}^{{commit}}") + parent = auth["release_identity"]["approved_parent_commit"] + tagged_paths = sorted( + path + for path in _git("diff", "--name-only", f"{parent}..{commit}").splitlines() + if path + ) + expected = sorted(auth["allowed_release_commit_paths"]) + if tagged_paths != expected: + fail("v0.26.0 does not resolve to the authorized source diff") + completed = subprocess.run( + ["git", "-C", str(ROOT), "merge-base", "--is-ancestor", commit, "HEAD"], + check=False, + ) + if completed.returncode != 0: + fail("v0.26.0 release commit is not an ancestor of HEAD") + return commit + + +def verify_publication_workflow() -> None: + text = WORKFLOW.read_text(encoding="utf-8") + required = ( + "workflow_dispatch:", + "environment: pypi", + "id-token: write", + "python tools/check_release_tag.py", + "python tools/check_psych_design_release_proof.py --phase complete", + "python tools/check_psych_design_public_release.py --phase authorization", + "python tools/build_psych_design_companion_asset.py", + "python -m build --no-isolation", + "--phase distributions", + "pypa/gh-action-pypi-publish@release/v1", + "print-hash: true", + ) + for token in required: + if token not in text: + fail(f"publication workflow is missing gate: {token}") + if "push:" in text: + fail("publication workflow must remain manual") + if "psych-design-companion-v0-1-candidate" in text: + fail("candidate tag is wired into publication workflow") + + +def verify_release_documentation() -> None: + notes = RELEASE_NOTES.read_text(encoding="utf-8") + if "v0.26.0 — Psychological Statistics by Design companion v0.1" not in notes: + fail("release notes do not declare v0.26.0") + if "v0.26.0 release candidate" in notes: + fail("release notes still label v0.26.0 as a candidate") + readme = (ROOT / "README.md").read_text(encoding="utf-8") + if 'pystatsv1[psych-design]==0.26.0' not in readme: + fail("README does not expose the exact public install route") + if "synthetic-only" not in readme.lower(): + fail("README lost the synthetic-only boundary") + + +def verify_pending_public_receipt() -> None: + receipt = load(PUBLIC_RECEIPT) + if receipt.get("schema_version") != ( + "pystatsv1-psych-design-public-release-v0.1" + ): + fail("public-release receipt schema changed") + if receipt.get("status") not in ( + "owner_authorized_pending_publication", + "public_release_verified", + ): + fail("public-release receipt has an invalid lifecycle status") + if receipt.get("package_version") != EXPECTED_VERSION: + fail("public-release receipt version changed") + state = receipt.get("authorization_state", {}) + if state.get("pypi_release_authorized") is not True: + fail("public-release receipt does not retain PyPI authorization") + for gate in CLOSED_GATES: + if state.get(gate) is not False: + fail(f"public-release receipt opens closed gate: {gate}") + + +def verify_public_receipt(auth: dict[str, Any]) -> None: + receipt = load(PUBLIC_RECEIPT) + if receipt.get("status") != "public_release_verified": + fail("public release has not been verified") + if receipt.get("public_artifacts_verified") is not True: + fail("public artifacts are not verified") + if receipt.get("trusted_publisher") is not True: + fail("public receipt does not record trusted publishing") + if receipt.get("scientific_candidate_unchanged") is not True: + fail("public receipt does not preserve the scientific candidate") + release_commit = receipt.get("release_commit") + if not isinstance(release_commit, str) or len(release_commit) != 40: + fail("public receipt release commit is invalid") + tagged_commit = verify_release_tag(auth, required=True) + if tagged_commit != release_commit: + fail("public receipt release commit differs from v0.26.0") + if not str(receipt.get("tag_ci_run_id", "")).isdigit(): + fail("public receipt tag CI run ID is invalid") + if not str(receipt.get("publish_workflow_run_id", "")).isdigit(): + fail("public receipt publish run ID is invalid") + artifacts = receipt.get("artifacts", {}) + verifier = load_verifier_module() + for key, filename in ( + ("wheel", verifier.EXPECTED_WHEEL), + ("sdist", verifier.EXPECTED_SDIST), + ): + row = artifacts.get(key, {}) + if row.get("filename") != filename: + fail(f"public receipt {key} filename changed") + digest = row.get("sha256") + if not isinstance(digest, str) or len(digest) != 64: + fail(f"public receipt {key} hash is invalid") + if row.get("embedded_asset_sha256") != EXPECTED_ASSET_SHA256: + fail(f"public receipt {key} asset hash changed") + if artifacts.get("embedded_asset_identity_exact") is not True: + fail("public wheel and sdist asset identities differ") + smoke = receipt.get("reader_smoke", {}) + if smoke.get("status") != "pass": + fail("public reader smoke did not pass") + if smoke.get("installed_version") != EXPECTED_VERSION: + fail("public reader smoke installed the wrong version") + if smoke.get("installed_asset_sha256") != EXPECTED_ASSET_SHA256: + fail("public reader smoke installed the wrong asset") + if not all(smoke.get("command_status", {}).values()): + fail("public reader smoke missed a command") + if not all(smoke.get("first_result_outputs", {}).values()): + fail("public reader smoke missed first-result output") + if smoke.get("absolute_workspace_path_recorded") is not False: + fail("public receipt records an absolute smoke workspace") + + +def verify_distribution_phase(dist_dir: Path) -> None: + verifier = load_verifier_module() + summary = verifier.inspect_distribution_directory(dist_dir) + if summary.get("embedded_asset_identity_exact") is not True: + fail("built wheel and sdist do not share the governed asset") + + +def verify_common(auth: dict[str, Any]) -> None: + if project_version() != EXPECTED_VERSION: + fail("pyproject version is not 0.26.0") + verify_authorization_document(auth) + verify_technical_readiness() + verify_frozen_asset() + verify_release_source_paths(auth) + verify_proof_tag() + verify_publication_workflow() + verify_release_documentation() + verify_pending_public_receipt() + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--phase", + choices=("source", "authorization", "distributions", "public"), + default="source", + ) + parser.add_argument("--dist-dir", type=Path, default=ROOT / "dist") + args = parser.parse_args() + try: + auth = load(AUTHORIZATION) + verify_common(auth) + if args.phase == "authorization": + verify_release_tag(auth, required=False) + elif args.phase == "distributions": + verify_release_tag(auth, required=True) + verify_distribution_phase(args.dist_dir) + elif args.phase == "public": + verify_public_receipt(auth) + except (OSError, PublicReleaseCheckError) as exc: + print(f"PYSTATSV1_PUBLIC_RELEASE_CHECK_ERROR: {exc}", file=sys.stderr) + return 1 + print( + "PYSTATSV1_PUBLIC_RELEASE_CHECK_OK " + f"phase={args.phase} package=pystatsv1 version=0.26.0 " + "pypi_authorized=true portal=false book=false real_data=false" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/check_psych_design_release_proof.py b/tools/check_psych_design_release_proof.py index 731c44b..250093d 100755 --- a/tools/check_psych_design_release_proof.py +++ b/tools/check_psych_design_release_proof.py @@ -24,6 +24,7 @@ / "psychological_statistics_by_design_companion_v0_1.zip" ) READINESS = EVIDENCE / "PSYCH_DESIGN_RELEASE_READINESS.json" +PYPI_AUTHORIZATION = PROOF / "PYPI_RELEASE_AUTHORIZATION.json" EXPECTED_STUDIES = [f"SWL-S{number:02d}" for number in range(1, 11)] CLOSED_AUTHORIZATION_GATES = ( "pypi_release_authorized", @@ -129,21 +130,84 @@ def verify_publication_workflow() -> None: fail("candidate tag is wired into the publication workflow") +def _git(*args: str) -> str: + completed = subprocess.run( + ["git", *args], + cwd=ROOT, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if completed.returncode != 0: + detail = completed.stderr.strip() or completed.stdout.strip() + fail(f"git {' '.join(args)} failed: {detail}") + return completed.stdout.strip() + + +def verify_authorized_final_tag() -> None: + if not PYPI_AUTHORIZATION.is_file(): + fail("v0.26.0 exists without Drop-in 035 owner authorization") + authorization = load(PYPI_AUTHORIZATION) + if authorization.get("schema_version") != ( + "pystatsv1-psych-design-pypi-release-authorization-v0.1" + ): + fail("v0.26.0 authorization schema changed") + if authorization.get("status") != "owner_authorized_pending_publication": + fail("v0.26.0 authorization status changed") + state = authorization.get("authorization_state", {}) + if state.get("pypi_release_authorized") is not True: + fail("v0.26.0 is not owner-authorized for PyPI") + for key in ( + "portal_release_authorized", + "book_binding_authorized", + "real_data_authorized", + ): + if state.get(key) is not False: + fail(f"v0.26.0 authorization opens closed gate: {key}") + release = authorization.get("release_identity", {}) + if release.get("release_tag") != "v0.26.0": + fail("authorized release tag changed") + if release.get("package_version") != "0.26.0": + fail("authorized package version changed") + if release.get("proof_tag") != "psych-design-companion-v0-1-release-proof": + fail("authorized proof tag changed") + parent = release.get("approved_parent_commit") + if parent != "400a3d688fb3c341fd393aca715de6d941b7f1e6": + fail("authorized release parent changed") + if _git("cat-file", "-t", "refs/tags/v0.26.0") != "tag": + fail("v0.26.0 must be an annotated tag") + tagged_commit = _git("rev-parse", "v0.26.0^{commit}") + expected_paths = sorted(authorization.get("allowed_release_commit_paths", [])) + actual_paths = sorted( + path + for path in _git("diff", "--name-only", f"{parent}..{tagged_commit}").splitlines() + if path + ) + if actual_paths != expected_paths: + fail("v0.26.0 source diff exceeds the owner-authorized path scope") + completed = subprocess.run( + ["git", "merge-base", "--is-ancestor", tagged_commit, "HEAD"], + cwd=ROOT, + check=False, + ) + if completed.returncode != 0: + fail("v0.26.0 release commit is not an ancestor of HEAD") + + def verify_git_state() -> None: if not (ROOT / ".git").exists(): return - tags = subprocess.check_output(["git", "tag", "--list"], cwd=ROOT, text=True).splitlines() - if "v0.26.0" in tags: - fail("v0.26.0 must remain absent until explicit release authorization") + tags = _git("tag", "--list").splitlines() if "psych-design-companion-v0-1-candidate" not in tags: fail("candidate tag is missing") - candidate = subprocess.check_output( - ["git", "rev-parse", "psych-design-companion-v0-1-candidate^{commit}"], - cwd=ROOT, - text=True, - ).strip() + candidate = _git( + "rev-parse", "psych-design-companion-v0-1-candidate^{commit}" + ) if candidate != "58d92f85b865cb657bf49c731fefc381970c8bda": fail("candidate tag does not resolve to the frozen candidate commit") + if "v0.26.0" in tags: + verify_authorized_final_tag() def verify_receipt(path: Path, role: str, contract: dict[str, Any]) -> dict[str, Any]: diff --git a/tools/verify_pystatsv1_public_release.py b/tools/verify_pystatsv1_public_release.py new file mode 100755 index 0000000..fe113f7 --- /dev/null +++ b/tools/verify_pystatsv1_public_release.py @@ -0,0 +1,571 @@ +#!/usr/bin/env python3 +"""Verify public PyPI artifacts and a fresh PyStatsV1 0.26.0 reader route.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import platform +import shutil +import subprocess +import sys +import tarfile +import tempfile +import time +import urllib.error +import urllib.request +import zipfile +from datetime import datetime, timezone +from email.parser import Parser +from pathlib import Path, PurePosixPath +from typing import Any + +PACKAGE_NAME = "pystatsv1" +PACKAGE_VERSION = "0.26.0" +RELEASE_TAG = "v0.26.0" +PROOF_TAG = "psych-design-companion-v0-1-release-proof" +EXPECTED_WHEEL = "pystatsv1-0.26.0-py3-none-any.whl" +EXPECTED_SDIST = "pystatsv1-0.26.0.tar.gz" +ASSET_MEMBER = "pystatsv1/assets/psychological_statistics_by_design_companion_v0_1.zip" +SDIST_ASSET_SUFFIX = "src/pystatsv1/assets/psychological_statistics_by_design_companion_v0_1.zip" +ASSET_SHA256 = "bd0932c1d126758f37fab180173d6b95b5e4c31bc654724251aa6eb6296243c1" +ASSET_BYTES = 136801 +PYPI_JSON_URL = f"https://pypi.org/pypi/{PACKAGE_NAME}/{PACKAGE_VERSION}/json" +PYPI_INDEX_URL = "https://pypi.org/simple" +PUBLIC_RECEIPT_SCHEMA = "pystatsv1-psych-design-public-release-v0.1" + + +class PublicReleaseError(RuntimeError): + """Raised when a public release artifact or smoke check is invalid.""" + + +def sha256_bytes(payload: bytes) -> str: + return hashlib.sha256(payload).hexdigest() + + +def sha256_path(path: Path) -> str: + return sha256_bytes(path.read_bytes()) + + +def _safe_archive_name(name: str) -> None: + path = PurePosixPath(name) + if path.is_absolute() or ".." in path.parts or "\\" in name: + raise PublicReleaseError(f"unsafe archive member path: {name!r}") + + +def _metadata_fields(text: str) -> dict[str, str]: + message = Parser().parsestr(text) + return {key: value for key, value in message.items()} + + +def inspect_wheel(path: Path) -> dict[str, Any]: + """Return a strict identity summary for the public wheel.""" + if path.name != EXPECTED_WHEEL: + raise PublicReleaseError(f"unexpected wheel filename: {path.name}") + try: + with zipfile.ZipFile(path) as archive: + names = archive.namelist() + for name in names: + _safe_archive_name(name) + metadata_names = [ + name + for name in names + if name.endswith(".dist-info/METADATA") + ] + if metadata_names != ["pystatsv1-0.26.0.dist-info/METADATA"]: + raise PublicReleaseError( + f"unexpected wheel metadata members: {metadata_names}" + ) + metadata = _metadata_fields( + archive.read(metadata_names[0]).decode("utf-8") + ) + if metadata.get("Name") != PACKAGE_NAME: + raise PublicReleaseError("wheel package name is not pystatsv1") + if metadata.get("Version") != PACKAGE_VERSION: + raise PublicReleaseError("wheel package version is not 0.26.0") + if ASSET_MEMBER not in names: + raise PublicReleaseError("wheel is missing the governed companion asset") + asset = archive.read(ASSET_MEMBER) + if len(asset) != ASSET_BYTES: + raise PublicReleaseError("wheel companion asset byte size changed") + if sha256_bytes(asset) != ASSET_SHA256: + raise PublicReleaseError("wheel companion asset hash changed") + if "pystatsv1-0.26.0.dist-info/RECORD" not in names: + raise PublicReleaseError("wheel RECORD is missing") + except (OSError, zipfile.BadZipFile, UnicodeDecodeError) as exc: + raise PublicReleaseError(f"invalid wheel: {exc}") from exc + return { + "filename": path.name, + "packagetype": "bdist_wheel", + "python_tag": "py3", + "abi_tag": "none", + "platform_tag": "any", + "bytes": path.stat().st_size, + "sha256": sha256_path(path), + "member_count": len(names), + "metadata_name": metadata["Name"], + "metadata_version": metadata["Version"], + "embedded_asset_member": ASSET_MEMBER, + "embedded_asset_bytes": len(asset), + "embedded_asset_sha256": sha256_bytes(asset), + } + + +def inspect_sdist(path: Path) -> dict[str, Any]: + """Return a strict identity summary for the public source distribution.""" + if path.name != EXPECTED_SDIST: + raise PublicReleaseError(f"unexpected sdist filename: {path.name}") + try: + with tarfile.open(path, mode="r:gz") as archive: + members = archive.getmembers() + names = [member.name for member in members] + for member in members: + _safe_archive_name(member.name) + if member.issym() or member.islnk(): + raise PublicReleaseError( + f"sdist contains a link member: {member.name}" + ) + roots = {PurePosixPath(name).parts[0] for name in names if name} + if roots != {"pystatsv1-0.26.0"}: + raise PublicReleaseError(f"unexpected sdist root: {sorted(roots)}") + pyproject_name = "pystatsv1-0.26.0/pyproject.toml" + pkg_info_name = "pystatsv1-0.26.0/PKG-INFO" + asset_name = f"pystatsv1-0.26.0/{SDIST_ASSET_SUFFIX}" + for required in (pyproject_name, pkg_info_name, asset_name): + if required not in names: + raise PublicReleaseError(f"sdist is missing {required}") + pkg_file = archive.extractfile(pkg_info_name) + asset_file = archive.extractfile(asset_name) + if pkg_file is None or asset_file is None: + raise PublicReleaseError("sdist required member could not be read") + metadata = _metadata_fields(pkg_file.read().decode("utf-8")) + asset = asset_file.read() + if metadata.get("Name") != PACKAGE_NAME: + raise PublicReleaseError("sdist package name is not pystatsv1") + if metadata.get("Version") != PACKAGE_VERSION: + raise PublicReleaseError("sdist package version is not 0.26.0") + if len(asset) != ASSET_BYTES or sha256_bytes(asset) != ASSET_SHA256: + raise PublicReleaseError("sdist companion asset identity changed") + except (OSError, tarfile.TarError, UnicodeDecodeError) as exc: + raise PublicReleaseError(f"invalid sdist: {exc}") from exc + return { + "filename": path.name, + "packagetype": "sdist", + "bytes": path.stat().st_size, + "sha256": sha256_path(path), + "member_count": len(members), + "metadata_name": metadata["Name"], + "metadata_version": metadata["Version"], + "embedded_asset_member": SDIST_ASSET_SUFFIX, + "embedded_asset_bytes": len(asset), + "embedded_asset_sha256": sha256_bytes(asset), + } + + +def inspect_distribution_directory(dist_dir: Path) -> dict[str, Any]: + """Inspect exactly one expected wheel and one expected sdist.""" + dist_dir = dist_dir.resolve() + files = sorted(path for path in dist_dir.iterdir() if path.is_file()) + expected = {EXPECTED_WHEEL, EXPECTED_SDIST} + actual = {path.name for path in files} + if actual != expected: + raise PublicReleaseError( + f"distribution directory must contain exactly {sorted(expected)}; " + f"found {sorted(actual)}" + ) + wheel = inspect_wheel(dist_dir / EXPECTED_WHEEL) + sdist = inspect_sdist(dist_dir / EXPECTED_SDIST) + return { + "wheel": wheel, + "sdist": sdist, + "embedded_asset_identity_exact": ( + wheel["embedded_asset_sha256"] + == sdist["embedded_asset_sha256"] + == ASSET_SHA256 + ), + } + + +def _fetch_json(url: str, *, timeout_seconds: int) -> dict[str, Any]: + request = urllib.request.Request( + url, + headers={"User-Agent": "PyStatsV1-Dropin-035-public-verifier/1.0"}, + ) + with urllib.request.urlopen(request, timeout=timeout_seconds) as response: + return json.loads(response.read().decode("utf-8")) + + +def wait_for_pypi_release( + *, timeout_seconds: int, poll_seconds: int +) -> dict[str, Any]: + """Wait for the exact PyPI release JSON to become publicly available.""" + deadline = time.monotonic() + timeout_seconds + last_error = "not attempted" + while time.monotonic() < deadline: + try: + payload = _fetch_json(PYPI_JSON_URL, timeout_seconds=30) + if payload.get("info", {}).get("version") == PACKAGE_VERSION: + return payload + last_error = "PyPI JSON returned the wrong version" + except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc: + last_error = str(exc) + time.sleep(poll_seconds) + raise PublicReleaseError( + f"PyPI release did not become available within {timeout_seconds}s: {last_error}" + ) + + +def pypi_version_exists(*, timeout_seconds: int = 30) -> bool: + """Return whether the exact package version already exists on PyPI.""" + try: + payload = _fetch_json(PYPI_JSON_URL, timeout_seconds=timeout_seconds) + except urllib.error.HTTPError as exc: + if exc.code == 404: + return False + raise PublicReleaseError(f"PyPI version check failed: {exc}") from exc + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc: + raise PublicReleaseError(f"PyPI version check failed: {exc}") from exc + return payload.get("info", {}).get("version") == PACKAGE_VERSION + + +def _download(url: str, destination: Path, expected_sha256: str) -> None: + request = urllib.request.Request( + url, + headers={"User-Agent": "PyStatsV1-Dropin-035-public-verifier/1.0"}, + ) + with urllib.request.urlopen(request, timeout=120) as response: + payload = response.read() + digest = sha256_bytes(payload) + if digest != expected_sha256: + raise PublicReleaseError( + f"download hash mismatch for {destination.name}: {digest}" + ) + destination.write_bytes(payload) + + +def download_public_distributions( + payload: dict[str, Any], destination: Path +) -> dict[str, Any]: + """Download the exact wheel and sdist reported by PyPI.""" + destination.mkdir(parents=True, exist_ok=True) + urls = payload.get("urls", []) + records = {row.get("filename"): row for row in urls} + if set(records) != {EXPECTED_WHEEL, EXPECTED_SDIST}: + raise PublicReleaseError( + "PyPI release must expose exactly the expected wheel and sdist; " + f"found {sorted(records)}" + ) + for filename, row in records.items(): + expected_type = "bdist_wheel" if filename == EXPECTED_WHEEL else "sdist" + if row.get("packagetype") != expected_type: + raise PublicReleaseError(f"unexpected PyPI package type for {filename}") + digest = row.get("digests", {}).get("sha256") + if not isinstance(digest, str) or len(digest) != 64: + raise PublicReleaseError(f"PyPI did not report a SHA-256 for {filename}") + _download(row["url"], destination / filename, digest) + inspected = inspect_distribution_directory(destination) + for key in ("wheel", "sdist"): + filename = inspected[key]["filename"] + if inspected[key]["sha256"] != records[filename]["digests"]["sha256"]: + raise PublicReleaseError(f"PyPI metadata hash mismatch for {filename}") + inspected[key]["pypi_upload_time_iso_8601"] = records[filename].get( + "upload_time_iso_8601" + ) + inspected[key]["requires_python"] = records[filename].get("requires_python") + return inspected + + +def _run( + command: list[str], + *, + cwd: Path | None = None, + env: dict[str, str] | None = None, + marker: str | None = None, +) -> str: + completed = subprocess.run( + command, + cwd=cwd, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + if completed.returncode != 0: + raise PublicReleaseError( + f"command failed ({completed.returncode}): {' '.join(command)}\n" + f"{completed.stdout}" + ) + if marker is not None and marker not in completed.stdout: + raise PublicReleaseError( + f"command did not emit required marker {marker!r}: {' '.join(command)}" + ) + return completed.stdout + + +def _venv_paths(venv: Path) -> tuple[Path, Path]: + if os.name == "nt": + return venv / "Scripts" / "python.exe", venv / "Scripts" / "pystatsv1.exe" + return venv / "bin" / "python", venv / "bin" / "pystatsv1" + + +def run_public_reader_smoke(work_root: Path) -> dict[str, Any]: + """Install from public PyPI in a fresh venv and run the first-result route.""" + if shutil.which("make") is None: + raise PublicReleaseError("make is required for the public first-result smoke") + if shutil.which("Rscript") is None: + raise PublicReleaseError("Rscript is required for the public first-result smoke") + + venv = work_root / ".venv" + workspace = work_root / "reader" / "psychological_statistics_by_design_companion_v0_1" + _run([sys.executable, "-m", "venv", str(venv)]) + python, cli = _venv_paths(venv) + _run([str(python), "-m", "pip", "install", "--upgrade", "pip"]) + install_output = _run( + [ + str(python), + "-m", + "pip", + "install", + "--no-cache-dir", + "--index-url", + PYPI_INDEX_URL, + f"{PACKAGE_NAME}[psych-design]=={PACKAGE_VERSION}", + ] + ) + identity_output = _run( + [ + str(python), + "-c", + ( + "from importlib import metadata, resources; " + "import hashlib, json; " + "p=resources.files('pystatsv1.assets')/" + "'psychological_statistics_by_design_companion_v0_1.zip'; " + "b=p.read_bytes(); " + "print(json.dumps({'version': metadata.version('pystatsv1'), " + "'asset_sha256': hashlib.sha256(b).hexdigest(), " + "'asset_bytes': len(b)}, sort_keys=True))" + ), + ] + ) + installed_identity = json.loads(identity_output.strip().splitlines()[-1]) + if installed_identity != { + "version": PACKAGE_VERSION, + "asset_sha256": ASSET_SHA256, + "asset_bytes": ASSET_BYTES, + }: + raise PublicReleaseError( + f"installed public package identity mismatch: {installed_identity}" + ) + + info_output = _run([str(cli), "psych-design", "info"]) + _run( + [ + str(cli), + "psych-design", + "init", + "--dest", + str(workspace), + ] + ) + verify_before = _run( + [str(cli), "psych-design", "verify", "--dest", str(workspace)], + marker="PYSTATSV1_PSYCH_DESIGN_VERIFY_OK", + ) + _run( + [ + str(python), + "-m", + "pip", + "install", + "-r", + str(workspace / "requirements-psych-design-companion.txt"), + ] + ) + doctor_output = _run( + [str(cli), "psych-design", "doctor", "--dest", str(workspace)], + marker="PYSTATSV1_PSYCH_DESIGN_DOCTOR_OK", + ) + smoke_env = os.environ.copy() + smoke_env["PYTHON"] = str(python) + first_result_output = _run( + ["make", "first-result", f"PYTHON={python}"], + cwd=workspace, + env=smoke_env, + marker="PYSTATSV1_PSYCH_DESIGN_FIRST_RESULT_OK", + ) + verify_after = _run( + [str(cli), "psych-design", "verify", "--dest", str(workspace)], + marker="PYSTATSV1_PSYCH_DESIGN_VERIFY_OK", + ) + required_outputs = [ + workspace / "outputs" / "swl_s02" / "python_result.json", + workspace / "outputs" / "swl_s02" / "r_result.csv", + workspace / "outputs" / "swl_s02" / "verification_receipt.json", + workspace / "outputs" / "swl_s02" / "figures" / "FIGURE_MANIFEST.json", + workspace / "outputs" / "swl_s02" / "reviewer_packet" / "MANIFEST.json", + ] + missing = [path.name for path in required_outputs if not path.exists()] + if missing: + raise PublicReleaseError(f"public first-result smoke missed outputs: {missing}") + + return { + "status": "pass", + "fresh_virtual_environment": True, + "public_index": PYPI_INDEX_URL, + "installed_version": installed_identity["version"], + "installed_asset_sha256": installed_identity["asset_sha256"], + "installed_asset_bytes": installed_identity["asset_bytes"], + "python_version": platform.python_version(), + "platform": platform.platform(), + "rscript_version": _run(["Rscript", "--version"]).strip(), + "command_status": { + "pip_install_public_exact": "Successfully installed" in install_output, + "psych_design_info": "Companion version: v0.1" in info_output, + "psych_design_init": True, + "verify_before": "PYSTATSV1_PSYCH_DESIGN_VERIFY_OK" in verify_before, + "doctor": "PYSTATSV1_PSYCH_DESIGN_DOCTOR_OK" in doctor_output, + "first_result": "PYSTATSV1_PSYCH_DESIGN_FIRST_RESULT_OK" + in first_result_output, + "verify_after": "PYSTATSV1_PSYCH_DESIGN_VERIFY_OK" in verify_after, + }, + "first_result_outputs": { + "python_result": True, + "r_result": True, + "verification_receipt": True, + "figure_manifest": True, + "reviewer_packet": True, + }, + "absolute_workspace_path_recorded": False, + } + + +def build_public_receipt( + *, + release_commit: str, + tag_ci_run_id: str, + publish_workflow_run_id: str, + distributions: dict[str, Any], + smoke: dict[str, Any], +) -> dict[str, Any]: + if not release_commit or len(release_commit) != 40: + raise PublicReleaseError("release commit must be a full 40-character SHA") + if not all(smoke.get("command_status", {}).values()): + raise PublicReleaseError("public reader smoke did not pass every command") + return { + "schema_version": PUBLIC_RECEIPT_SCHEMA, + "status": "public_release_verified", + "verified_at_utc": datetime.now(timezone.utc).replace(microsecond=0).isoformat(), + "package_name": PACKAGE_NAME, + "package_version": PACKAGE_VERSION, + "companion_version": "v0.1", + "release_tag": RELEASE_TAG, + "release_commit": release_commit, + "proof_tag": PROOF_TAG, + "tag_ci_run_id": str(tag_ci_run_id), + "publish_workflow_run_id": str(publish_workflow_run_id), + "trusted_publisher": True, + "public_index": PYPI_INDEX_URL, + "pypi_project": f"https://pypi.org/project/{PACKAGE_NAME}/{PACKAGE_VERSION}/", + "public_artifacts_verified": True, + "artifacts": distributions, + "reader_smoke": smoke, + "authorization_state": { + "pypi_release_authorized": True, + "portal_release_authorized": False, + "book_binding_authorized": False, + "real_data_authorized": False, + }, + "scientific_candidate_unchanged": True, + "embedded_asset_sha256": ASSET_SHA256, + "embedded_asset_bytes": ASSET_BYTES, + } + + +def verify_public_release( + *, + output: Path, + release_commit: str, + tag_ci_run_id: str, + publish_workflow_run_id: str, + timeout_seconds: int, + poll_seconds: int, +) -> dict[str, Any]: + payload = wait_for_pypi_release( + timeout_seconds=timeout_seconds, poll_seconds=poll_seconds + ) + with tempfile.TemporaryDirectory(prefix="pystatsv1-public-release-") as temp: + root = Path(temp) + distributions = download_public_distributions(payload, root / "dist") + smoke = run_public_reader_smoke(root / "smoke") + receipt = build_public_receipt( + release_commit=release_commit, + tag_ci_run_id=tag_ci_run_id, + publish_workflow_run_id=publish_workflow_run_id, + distributions=distributions, + smoke=smoke, + ) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text( + json.dumps(receipt, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + return receipt + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--expect-absent", action="store_true") + parser.add_argument("--output", type=Path) + parser.add_argument("--release-commit") + parser.add_argument("--tag-ci-run-id") + parser.add_argument("--publish-workflow-run-id") + parser.add_argument("--timeout-seconds", type=int, default=900) + parser.add_argument("--poll-seconds", type=int, default=15) + args = parser.parse_args() + try: + if args.expect_absent: + if pypi_version_exists(): + raise PublicReleaseError( + f"{PACKAGE_NAME} {PACKAGE_VERSION} already exists on PyPI" + ) + print( + "PYSTATSV1_PUBLIC_RELEASE_ABSENCE_OK " + f"package={PACKAGE_NAME} version={PACKAGE_VERSION}" + ) + return 0 + required = { + "--output": args.output, + "--release-commit": args.release_commit, + "--tag-ci-run-id": args.tag_ci_run_id, + "--publish-workflow-run-id": args.publish_workflow_run_id, + } + missing = [name for name, value in required.items() if not value] + if missing: + raise PublicReleaseError( + f"missing required public-verification arguments: {', '.join(missing)}" + ) + receipt = verify_public_release( + output=args.output, + release_commit=args.release_commit, + tag_ci_run_id=args.tag_ci_run_id, + publish_workflow_run_id=args.publish_workflow_run_id, + timeout_seconds=args.timeout_seconds, + poll_seconds=args.poll_seconds, + ) + except (OSError, PublicReleaseError) as exc: + print(f"PYSTATSV1_PUBLIC_RELEASE_VERIFY_ERROR: {exc}", file=sys.stderr) + return 1 + print( + "PYSTATSV1_PUBLIC_RELEASE_VERIFY_OK " + f"version={receipt['package_version']} " + f"wheel={receipt['artifacts']['wheel']['sha256']} " + f"sdist={receipt['artifacts']['sdist']['sha256']}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())