diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 0efa724..10e775b 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 @@ -69,14 +72,55 @@ 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: build - run: | - uv build + - name: Check lockfile + run: uv lock --check + + - 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 + + - name: Inspect distributions + run: uv run --no-sync python scripts/inspect_distribution.py dist + + - name: Preserve validated artifacts + uses: actions/upload-artifact@v7 + 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@v7 + 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..5d1de7e --- /dev/null +++ b/scripts/inspect_distribution.py @@ -0,0 +1,49 @@ +"""Fail release builds whose artifacts do not expose the migrated contract.""" + +import argparse +import tarfile +import zipfile +from pathlib import Path + +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") + + 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()