diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 149cfc0..e0124e8 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -15,6 +15,30 @@ jobs: with: python-version: "3.11" + - name: Validate release tag matches project version + run: | + python - <<'PY' + import os + import re + from pathlib import Path + + tag = os.environ.get("GITHUB_REF_NAME", "") + text = Path("pyproject.toml").read_text(encoding="utf-8") + match = re.search(r'^version = "([^"]+)"$', text, re.MULTILINE) + if match is None: + raise SystemExit("Could not find project version in pyproject.toml") + + version = match.group(1) + expected_tag = f"v{version}" + if tag != expected_tag: + raise SystemExit( + f"Release tag {tag!r} does not match pyproject version {version!r}; " + f"expected {expected_tag!r}" + ) + + print(f"Release tag {tag} matches pyproject version {version}") + PY + - name: Install build tools run: | python -m pip install --upgrade pip @@ -89,6 +113,9 @@ jobs: publish: runs-on: ubuntu-latest needs: [validate_wheel, validate_sdist] + environment: + name: pypi + url: https://pypi.org/p/rtdfeatures permissions: id-token: write steps: diff --git a/docs/development/release-gate-1.0.0.md b/docs/development/release-gate-1.0.0.md index 42c6416..dabe10f 100644 --- a/docs/development/release-gate-1.0.0.md +++ b/docs/development/release-gate-1.0.0.md @@ -6,11 +6,11 @@ TBD until merge ## Date -Sun May 31 17:21:11 ChST 2026 +Sun May 31 18:01:19 ChST 2026 ## Environment -- Python 3.13.5 +- Python 3.12.3 - rtdfeatures 1.0.0 - Install mode: editable install with dev/examples/sklearn extras @@ -33,6 +33,17 @@ Sun May 31 17:21:11 ChST 2026 | `pytest tests/test_sklearn_adapter.py -v` | PASS — 26 passed | | `python examples/08_sklearn_adapter.py` | PASS | +## Publish Workflow Readiness + +- `publish.yml` triggers on GitHub Release publication. +- Build job validates release tag matches `pyproject.toml` version. +- Wheel and sdist are built once and uploaded as workflow artefacts. +- Wheel install is validated in a clean virtual environment. +- Sdist install is validated in a clean virtual environment. +- Final publish job uses GitHub environment `pypi`. +- Final publish job uses PyPI trusted publishing via OIDC with `id-token: write`. +- No PyPI API token or password secret is required. + ## Root API Stable V1 root exports: diff --git a/docs/development/release-process.md b/docs/development/release-process.md index 44cfb72..ce3e0b2 100644 --- a/docs/development/release-process.md +++ b/docs/development/release-process.md @@ -42,9 +42,10 @@ See [API stability policy](../api/stability.md) for the full tier definitions. ## Publishing workflow 1. **Trigger** — Publishing a GitHub Release starts `.github/workflows/publish.yml`. -2. **Build and integrity check** — The workflow builds wheel and sdist once and runs `twine check`. -3. **Artifact validation** — The workflow installs the built wheel and sdist in separate clean virtual environments, validates import path/version from the installed distribution, and runs a minimal import smoke check from outside the checkout. -4. **Trusted publish** — If both validation jobs pass, the workflow publishes the validated artifacts to PyPI using trusted publishing. +2. **Release tag/version guard** — The `build` job verifies the GitHub Release tag `v` matches `version` in `pyproject.toml` before running the build. +3. **Build and integrity check** — The workflow builds wheel and sdist once and runs `twine check`. +4. **Artifact validation** — The workflow installs the built wheel and sdist in separate clean virtual environments, validates import path/version from the installed distribution, and runs a minimal import smoke check from outside the checkout. +5. **Environment-gated trusted publish** — If both validation jobs pass, the final `publish` job enters GitHub environment `pypi` for approval, then publishes the validated artifacts to PyPI using OIDC trusted publishing. No PyPI token/password secret is required in normal operation. ## Security Governance @@ -62,7 +63,7 @@ Use these commands locally before creating a release: ```bash python -m pip install --upgrade pip -pip install -e ".[dev]" +pip install -e ".[dev,examples,sklearn]" pytest -m "not external_data" -v ruff check src/ tests/ mypy src/ tests/ @@ -70,6 +71,23 @@ python -m build twine check dist/* ``` +## First PyPI release checklist + +1. Confirm PyPI pending trusted publisher is configured: + - project: `rtdfeatures` + - owner: `MMerryweather` + - repository: `rtdfeatures` + - workflow: `publish.yml` + - environment: `pypi` +2. Confirm GitHub environment `pypi` exists and has the intended reviewer policy. +3. Confirm `pyproject.toml` version matches the intended release tag. +4. Run the full local release gate. +5. Merge release-prep PR to `main`. +6. Create annotated tag `v`. +7. Publish a GitHub Release for that tag. +8. Approve the `pypi` environment deployment. +9. Verify install from PyPI in a clean virtual environment. + ## Version history See [CHANGELOG.md](../../CHANGELOG.md) for the full version history. diff --git a/test_data/benchmarks/nrtd/NOTICE.md b/test_data/benchmarks/nrtd/NOTICE.md new file mode 100644 index 0000000..d4995c5 --- /dev/null +++ b/test_data/benchmarks/nrtd/NOTICE.md @@ -0,0 +1,8 @@ +# nRTD Benchmark Fixture Notice + +Source benchmark arrays were extracted from nRTD v1.0.0. + +- Zenodo DOI: 10.5281/zenodo.15609214 +- Zenodo record URL: https://zenodo.org/records/15609214 +- Source archive: ChemRxnEngLab/nRTD-v1.0.0.zip +- License: MIT diff --git a/test_data/benchmarks/nrtd/hsa_000_adler_kernel_reference.parquet b/test_data/benchmarks/nrtd/hsa_000_adler_kernel_reference.parquet new file mode 100644 index 0000000..f7f7e0e Binary files /dev/null and b/test_data/benchmarks/nrtd/hsa_000_adler_kernel_reference.parquet differ diff --git a/test_data/benchmarks/nrtd/hsa_000_cholette_kernel_reference.parquet b/test_data/benchmarks/nrtd/hsa_000_cholette_kernel_reference.parquet new file mode 100644 index 0000000..03441a4 Binary files /dev/null and b/test_data/benchmarks/nrtd/hsa_000_cholette_kernel_reference.parquet differ diff --git a/test_data/benchmarks/nrtd/hsa_000_dispersion_kernel_reference.parquet b/test_data/benchmarks/nrtd/hsa_000_dispersion_kernel_reference.parquet new file mode 100644 index 0000000..35ae89d Binary files /dev/null and b/test_data/benchmarks/nrtd/hsa_000_dispersion_kernel_reference.parquet differ diff --git a/test_data/benchmarks/nrtd/hsa_000_laminar_flow_kernel_reference.parquet b/test_data/benchmarks/nrtd/hsa_000_laminar_flow_kernel_reference.parquet new file mode 100644 index 0000000..013d38d Binary files /dev/null and b/test_data/benchmarks/nrtd/hsa_000_laminar_flow_kernel_reference.parquet differ diff --git a/test_data/benchmarks/nrtd/hsa_000_laminar_flow_signals.parquet b/test_data/benchmarks/nrtd/hsa_000_laminar_flow_signals.parquet new file mode 100644 index 0000000..03e01c9 Binary files /dev/null and b/test_data/benchmarks/nrtd/hsa_000_laminar_flow_signals.parquet differ diff --git a/test_data/benchmarks/nrtd/manifest.json b/test_data/benchmarks/nrtd/manifest.json new file mode 100644 index 0000000..f572076 --- /dev/null +++ b/test_data/benchmarks/nrtd/manifest.json @@ -0,0 +1,52 @@ +{ + "assumption_events": [], + "conversion_assumptions": { + "benchmark_kernel_sum_tolerance": 1e-09, + "benchmark_regular_grid_tolerance": 1e-09, + "direct_zip_reads": true, + "kernel_weights_normalized_in_derived_columns_only": true + }, + "extraction_script": "rtdfeatures.benchmarks.extract_nrtd_benchmarks", + "generated_files": [ + { + "case": "adler", + "kind": "kernel_reference", + "output_path": "test_data/benchmarks/nrtd/hsa_000_adler_kernel_reference.parquet", + "sha256": "acf1b92ec9a486bcdeaa461b64014a92b879ad35af1ef6243197da93067001ac", + "source_archive_paths": [] + }, + { + "case": "cholette", + "kind": "kernel_reference", + "output_path": "test_data/benchmarks/nrtd/hsa_000_cholette_kernel_reference.parquet", + "sha256": "94052f1fbaa3af45a2a1cc8bd00f5b8955b417e942067bf5a07299c25e48a7b6", + "source_archive_paths": [] + }, + { + "case": "dispersion", + "kind": "kernel_reference", + "output_path": "test_data/benchmarks/nrtd/hsa_000_dispersion_kernel_reference.parquet", + "sha256": "209d7252515655ce065c450bf692e198a2135e8ae89e1c80b3661a3eb1d37885", + "source_archive_paths": [] + }, + { + "case": "laminar_flow", + "kind": "kernel_reference", + "output_path": "test_data/benchmarks/nrtd/hsa_000_laminar_flow_kernel_reference.parquet", + "sha256": "8117d9405fab71122d73f2402d85a12406405bea32f7bad119c791a646c6ed4b", + "source_archive_paths": [] + }, + { + "case": "laminar_flow", + "kind": "signals", + "output_path": "test_data/benchmarks/nrtd/hsa_000_laminar_flow_signals.parquet", + "sha256": "0f9cfd6544fccf13b99ad22adbae35a8a7dab2c3c83632dc129b4eae6c2a93aa", + "source_archive_paths": [] + } + ], + "license": "MIT", + "skipped_selected_source_files": [], + "source_archive": "ChemRxnEngLab/nRTD-v1.0.0.zip", + "zenodo_doi": "10.5281/zenodo.15609214", + "zenodo_record_url": "https://zenodo.org/records/15609214" +} diff --git a/tests/test_release_gate_benchmark_harness.py b/tests/test_release_gate_benchmark_harness.py index 726251b..56ed3cc 100644 --- a/tests/test_release_gate_benchmark_harness.py +++ b/tests/test_release_gate_benchmark_harness.py @@ -2,6 +2,7 @@ from __future__ import annotations +import hashlib import json from pathlib import Path @@ -63,3 +64,26 @@ def test_required_tracked_fixture_files_exist_and_manifest_outputs_are_present() ) absolute = REPO_ROOT / output_path assert absolute.exists(), f"manifest output_path missing from repository: {absolute}" + + +def test_manifest_generated_files_sha256_match_tracked_fixture_contents() -> None: + manifest = json.loads((BENCHMARK_DIR / "manifest.json").read_text(encoding="utf-8")) + generated_files = manifest.get("generated_files", []) + assert generated_files, "manifest.generated_files must not be empty" + + for entry in generated_files: + output_path = entry.get("output_path") + expected_sha256 = entry.get("sha256") + + assert isinstance(output_path, str) and output_path, ( + "manifest generated_files.output_path missing" + ) + assert isinstance(expected_sha256, str) and expected_sha256, ( + "manifest generated_files.sha256 missing" + ) + + absolute = REPO_ROOT / output_path + assert absolute.exists(), f"manifest output_path missing from repository: {absolute}" + + digest = hashlib.sha256(absolute.read_bytes()).hexdigest() + assert digest == expected_sha256, f"sha256 mismatch for {output_path}"