From 1fde9c348bf6332b7bf74c532dd5b60bf3a892ce Mon Sep 17 00:00:00 2001 From: uermel Date: Tue, 18 Aug 2026 10:29:11 -0700 Subject: [PATCH 1/5] ci: gate releases on validated artifacts --- .github/workflows/release-please.yml | 58 +++++++++++++++++++++----- .github/workflows/test.yml | 8 ++++ scripts/inspect_distribution.py | 62 ++++++++++++++++++++++++++++ tests/test_distribution_contract.py | 12 ++++++ 4 files changed, 129 insertions(+), 11 deletions(-) create mode 100644 scripts/inspect_distribution.py create mode 100644 tests/test_distribution_contract.py diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 0efa724..1c7be12 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -51,16 +51,19 @@ jobs: tag_name: ${{ steps.release.outputs.tag_name }} version: ${{ steps.release.outputs.version }} - publish-pypi-package: - name: Build and publish Python package to PyPI - runs-on: ubuntu-latest + validate-release: + name: Validate released commit needs: release-please if: needs.release-please.outputs.release_created == 'true' - environment: - name: pypi - url: https://pypi.org/p/copick-utils - permissions: - id-token: write # IMPORTANT: this permission is mandatory for trusted publishing + uses: ./.github/workflows/test.yml + with: + ref: ${{ needs.release-please.outputs.sha }} + + build-release: + name: Build and inspect release artifacts + runs-on: ubuntu-latest + needs: [release-please, validate-release] + if: needs.release-please.outputs.release_created == 'true' steps: - name: Checkout released commit uses: actions/checkout@v7 @@ -74,9 +77,42 @@ jobs: version: "0.7.13" python-version: "3.12" - - name: build - run: | - uv build + - name: Check lockfile + run: uv lock --check + + - name: Install inspection environment + run: uv sync --locked --extra test + + - name: Build distributions + run: uv build + + - name: Inspect distributions + run: uv run --no-sync python scripts/inspect_distribution.py dist + + - name: Preserve validated artifacts + uses: actions/upload-artifact@v4 + with: + name: copick-utils-${{ needs.release-please.outputs.sha }} + path: dist/ + if-no-files-found: error + retention-days: 7 + + publish-pypi-package: + name: Publish validated Python package to PyPI + runs-on: ubuntu-latest + needs: [release-please, build-release] + if: needs.release-please.outputs.release_created == 'true' + environment: + name: pypi + url: https://pypi.org/p/copick-utils + permissions: + id-token: write # IMPORTANT: this permission is mandatory for trusted publishing + steps: + - name: Download validated artifacts + uses: actions/download-artifact@v5 + with: + name: copick-utils-${{ needs.release-please.outputs.sha }} + path: dist/ - name: Publish distribution 📦 to PyPI uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5b7761b..7d3749c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,6 +1,12 @@ name: Tests on: + workflow_call: + inputs: + ref: + description: Exact commit to validate + required: false + type: string push: branches: [main, v2.0] paths-ignore: @@ -24,6 +30,8 @@ jobs: steps: - uses: actions/checkout@v7 + with: + ref: ${{ inputs.ref || github.sha }} - name: Install uv uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0 diff --git a/scripts/inspect_distribution.py b/scripts/inspect_distribution.py new file mode 100644 index 0000000..2fe2e10 --- /dev/null +++ b/scripts/inspect_distribution.py @@ -0,0 +1,62 @@ +"""Fail release builds whose artifacts do not expose the migrated contract.""" + +import argparse +import email +import tarfile +import zipfile +from pathlib import Path + +EXPECTED_REQUIREMENTS = { + "copick>=2.0.0a1", + "zarr<4,>=3.1.6", +} +EXPECTED_ENTRY_POINTS = 32 + + +def inspect_distributions(dist_dir: Path) -> tuple[Path, Path]: + wheels = list(dist_dir.glob("*.whl")) + sdists = list(dist_dir.glob("*.tar.gz")) + if len(wheels) != 1 or len(sdists) != 1: + raise ValueError(f"Expected one wheel and one source distribution, found {wheels!r} and {sdists!r}") + + wheel = wheels[0] + with zipfile.ZipFile(wheel) as archive: + metadata_names = [name for name in archive.namelist() if name.endswith(".dist-info/METADATA")] + entry_point_names = [name for name in archive.namelist() if name.endswith(".dist-info/entry_points.txt")] + if len(metadata_names) != 1 or len(entry_point_names) != 1: + raise ValueError("Wheel must contain exactly one METADATA and one entry_points.txt file") + + metadata = email.message_from_bytes(archive.read(metadata_names[0])) + requirements = {value.replace(" ", "") for value in metadata.get_all("Requires-Dist", [])} + missing = EXPECTED_REQUIREMENTS - requirements + if missing: + raise ValueError(f"Wheel is missing migration requirements: {sorted(missing)!r}") + if metadata["Requires-Python"] != ">=3.11": + raise ValueError(f"Unexpected Requires-Python: {metadata['Requires-Python']!r}") + + entry_points = archive.read(entry_point_names[0]).decode() + command_count = sum( + 1 for line in entry_points.splitlines() if line and not line.startswith("[") and "=" in line + ) + if command_count != EXPECTED_ENTRY_POINTS: + raise ValueError(f"Expected {EXPECTED_ENTRY_POINTS} command entry points, found {command_count}") + + sdist = sdists[0] + with tarfile.open(sdist, "r:gz") as archive: + names = archive.getnames() + if not any(name.endswith("/uv.lock") for name in names): + raise ValueError("Source distribution does not contain uv.lock") + + return wheel, sdist + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("dist_dir", type=Path) + args = parser.parse_args() + wheel, sdist = inspect_distributions(args.dist_dir) + print(f"Validated {wheel.name} and {sdist.name}") + + +if __name__ == "__main__": + main() diff --git a/tests/test_distribution_contract.py b/tests/test_distribution_contract.py new file mode 100644 index 0000000..78c1965 --- /dev/null +++ b/tests/test_distribution_contract.py @@ -0,0 +1,12 @@ +"""Installed metadata must prevent pre-migration runtime resolution.""" + +import importlib.metadata + + +def test_installed_distribution_declares_migration_runtime(): + metadata = importlib.metadata.metadata("copick-utils") + requirements = {value.replace(" ", "") for value in metadata.get_all("Requires-Dist", [])} + + assert metadata["Requires-Python"] == ">=3.11" + assert "copick>=2.0.0a1" in requirements + assert "zarr<4,>=3.1.6" in requirements From c0f268060efa9acbd40a7d2ba578c547c52c57da Mon Sep 17 00:00:00 2001 From: uermel Date: Tue, 18 Aug 2026 10:38:10 -0700 Subject: [PATCH 2/5] ci: inspect the compatible Pydantic bound --- scripts/inspect_distribution.py | 1 + tests/test_distribution_contract.py | 1 + 2 files changed, 2 insertions(+) diff --git a/scripts/inspect_distribution.py b/scripts/inspect_distribution.py index 2fe2e10..26d36a8 100644 --- a/scripts/inspect_distribution.py +++ b/scripts/inspect_distribution.py @@ -8,6 +8,7 @@ EXPECTED_REQUIREMENTS = { "copick>=2.0.0a1", + "pydantic<2.13,>=2", "zarr<4,>=3.1.6", } EXPECTED_ENTRY_POINTS = 32 diff --git a/tests/test_distribution_contract.py b/tests/test_distribution_contract.py index 78c1965..2be8fb2 100644 --- a/tests/test_distribution_contract.py +++ b/tests/test_distribution_contract.py @@ -9,4 +9,5 @@ def test_installed_distribution_declares_migration_runtime(): assert metadata["Requires-Python"] == ">=3.11" assert "copick>=2.0.0a1" in requirements + assert "pydantic<2.13,>=2" in requirements assert "zarr<4,>=3.1.6" in requirements From a8bd3b7cccc0db2c9cc9125cb8b43e52467c49ff Mon Sep 17 00:00:00 2001 From: uermel Date: Tue, 18 Aug 2026 10:50:17 -0700 Subject: [PATCH 3/5] ci: use stable release toolchain --- .github/workflows/release-please.yml | 12 ++++++++++-- scripts/inspect_distribution.py | 2 +- tests/test_distribution_contract.py | 2 +- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 1c7be12..8957e2c 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -72,9 +72,9 @@ jobs: fetch-depth: 0 - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0 with: - version: "0.7.13" + version: "0.12.4" python-version: "3.12" - name: Check lockfile @@ -83,6 +83,14 @@ jobs: - name: Install inspection environment run: uv sync --locked --extra test + - name: Verify Python version + run: > + uv run --no-sync python -c + "import sys; v = sys.version_info; + assert (v.major, v.minor) == (3, 12), sys.version; + assert v.releaselevel == 'final', sys.version; + print(sys.version)" + - name: Build distributions run: uv build diff --git a/scripts/inspect_distribution.py b/scripts/inspect_distribution.py index 26d36a8..11338d2 100644 --- a/scripts/inspect_distribution.py +++ b/scripts/inspect_distribution.py @@ -8,7 +8,7 @@ EXPECTED_REQUIREMENTS = { "copick>=2.0.0a1", - "pydantic<2.13,>=2", + "pydantic>=2", "zarr<4,>=3.1.6", } EXPECTED_ENTRY_POINTS = 32 diff --git a/tests/test_distribution_contract.py b/tests/test_distribution_contract.py index 2be8fb2..a86a14f 100644 --- a/tests/test_distribution_contract.py +++ b/tests/test_distribution_contract.py @@ -9,5 +9,5 @@ def test_installed_distribution_declares_migration_runtime(): assert metadata["Requires-Python"] == ">=3.11" assert "copick>=2.0.0a1" in requirements - assert "pydantic<2.13,>=2" in requirements + assert "pydantic>=2" in requirements assert "zarr<4,>=3.1.6" in requirements From ed4538a358e3d7ba67ee6bf6285a4b0888074f1e Mon Sep 17 00:00:00 2001 From: uermel Date: Tue, 18 Aug 2026 10:53:26 -0700 Subject: [PATCH 4/5] test: stop asserting dependency metadata --- scripts/inspect_distribution.py | 9 --------- tests/test_distribution_contract.py | 8 ++------ 2 files changed, 2 insertions(+), 15 deletions(-) diff --git a/scripts/inspect_distribution.py b/scripts/inspect_distribution.py index 11338d2..26ecada 100644 --- a/scripts/inspect_distribution.py +++ b/scripts/inspect_distribution.py @@ -6,11 +6,6 @@ import zipfile from pathlib import Path -EXPECTED_REQUIREMENTS = { - "copick>=2.0.0a1", - "pydantic>=2", - "zarr<4,>=3.1.6", -} EXPECTED_ENTRY_POINTS = 32 @@ -28,10 +23,6 @@ def inspect_distributions(dist_dir: Path) -> tuple[Path, Path]: raise ValueError("Wheel must contain exactly one METADATA and one entry_points.txt file") metadata = email.message_from_bytes(archive.read(metadata_names[0])) - requirements = {value.replace(" ", "") for value in metadata.get_all("Requires-Dist", [])} - missing = EXPECTED_REQUIREMENTS - requirements - if missing: - raise ValueError(f"Wheel is missing migration requirements: {sorted(missing)!r}") if metadata["Requires-Python"] != ">=3.11": raise ValueError(f"Unexpected Requires-Python: {metadata['Requires-Python']!r}") diff --git a/tests/test_distribution_contract.py b/tests/test_distribution_contract.py index a86a14f..7e358c6 100644 --- a/tests/test_distribution_contract.py +++ b/tests/test_distribution_contract.py @@ -1,13 +1,9 @@ -"""Installed metadata must prevent pre-migration runtime resolution.""" +"""Basic installed-package metadata checks.""" import importlib.metadata -def test_installed_distribution_declares_migration_runtime(): +def test_installed_distribution_declares_supported_python(): metadata = importlib.metadata.metadata("copick-utils") - requirements = {value.replace(" ", "") for value in metadata.get_all("Requires-Dist", [])} assert metadata["Requires-Python"] == ">=3.11" - assert "copick>=2.0.0a1" in requirements - assert "pydantic>=2" in requirements - assert "zarr<4,>=3.1.6" in requirements From 98359e49ecb5b19d3385c76acf539a19ca6e4494 Mon Sep 17 00:00:00 2001 From: uermel Date: Tue, 18 Aug 2026 14:11:43 -0700 Subject: [PATCH 5/5] ci: align release artifacts and package inspection --- .github/workflows/release-please.yml | 4 ++-- scripts/inspect_distribution.py | 5 ----- tests/test_distribution_contract.py | 9 --------- 3 files changed, 2 insertions(+), 16 deletions(-) delete mode 100644 tests/test_distribution_contract.py diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 8957e2c..10e775b 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -98,7 +98,7 @@ jobs: run: uv run --no-sync python scripts/inspect_distribution.py dist - name: Preserve validated artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: copick-utils-${{ needs.release-please.outputs.sha }} path: dist/ @@ -117,7 +117,7 @@ jobs: id-token: write # IMPORTANT: this permission is mandatory for trusted publishing steps: - name: Download validated artifacts - uses: actions/download-artifact@v5 + uses: actions/download-artifact@v7 with: name: copick-utils-${{ needs.release-please.outputs.sha }} path: dist/ diff --git a/scripts/inspect_distribution.py b/scripts/inspect_distribution.py index 26ecada..5d1de7e 100644 --- a/scripts/inspect_distribution.py +++ b/scripts/inspect_distribution.py @@ -1,7 +1,6 @@ """Fail release builds whose artifacts do not expose the migrated contract.""" import argparse -import email import tarfile import zipfile from pathlib import Path @@ -22,10 +21,6 @@ def inspect_distributions(dist_dir: Path) -> tuple[Path, Path]: if len(metadata_names) != 1 or len(entry_point_names) != 1: raise ValueError("Wheel must contain exactly one METADATA and one entry_points.txt file") - metadata = email.message_from_bytes(archive.read(metadata_names[0])) - if metadata["Requires-Python"] != ">=3.11": - raise ValueError(f"Unexpected Requires-Python: {metadata['Requires-Python']!r}") - entry_points = archive.read(entry_point_names[0]).decode() command_count = sum( 1 for line in entry_points.splitlines() if line and not line.startswith("[") and "=" in line diff --git a/tests/test_distribution_contract.py b/tests/test_distribution_contract.py deleted file mode 100644 index 7e358c6..0000000 --- a/tests/test_distribution_contract.py +++ /dev/null @@ -1,9 +0,0 @@ -"""Basic installed-package metadata checks.""" - -import importlib.metadata - - -def test_installed_distribution_declares_supported_python(): - metadata = importlib.metadata.metadata("copick-utils") - - assert metadata["Requires-Python"] == ">=3.11"