diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5f7eac3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,127 @@ +name: Release + +on: + push: + tags: + - "v*" + +# Publishing permissions are granted only to the jobs that need them. +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + verify-version: + name: Verify release version + runs-on: ubuntu-latest + steps: + - name: Reject releases outside the canonical repository + if: github.repository != 'lrntct/xarray-grass' + run: | + echo "Releases may only run from lrntct/xarray-grass." >&2 + exit 1 + + - uses: actions/checkout@v6 + + - name: Verify the tag and package version + run: python3 scripts/release_checks.py verify-version "$GITHUB_REF_NAME" + + test: + name: Test release + needs: verify-version + uses: ./.github/workflows/tests.yml + + build: + name: Build release artifacts + needs: verify-version + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-python@v6 + with: + python-version: "3.14" + + - name: Build source and wheel distributions + run: | + python -m pip install --disable-pip-version-check --upgrade build + python -m build + + - uses: actions/upload-artifact@v6 + with: + name: distributions + path: dist/ + if-no-files-found: error + + validate-artifacts: + name: Validate release artifacts + needs: [test, build] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-python@v6 + with: + python-version: "3.14" + + - uses: actions/download-artifact@v6 + with: + name: distributions + path: dist + + - name: Verify artifact versions and coverage + run: python scripts/release_checks.py verify-artifacts dist "$GITHUB_REF_NAME" + + - name: Check distribution metadata + run: | + python -m pip install --disable-pip-version-check --upgrade twine + python -m twine check dist/* + + - name: Install the source distribution + run: | + python -m venv sdist-venv + sdist-venv/bin/python -m pip install --no-deps dist/*.tar.gz + sdist-venv/bin/python -m pip show xarray-grass + + publish-pypi: + name: Publish to PyPI + needs: validate-artifacts + runs-on: ubuntu-latest + environment: + name: pypi + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v6 + with: + name: distributions + path: dist + + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist/ + + create-github-release: + name: Create GitHub release + needs: publish-pypi + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v6 + with: + name: distributions + path: dist + + - name: Create release and upload artifacts + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.ref_name }} + run: | + gh release create "$RELEASE_TAG" dist/* \ + --repo "$GITHUB_REPOSITORY" \ + --title "$RELEASE_TAG" \ + --generate-notes diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b42dbf3..9483d46 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,6 +1,7 @@ name: tests on: + workflow_call: push: branches: ["main"] pull_request: diff --git a/scripts/release_checks.py b/scripts/release_checks.py new file mode 100644 index 0000000..2d9f4bf --- /dev/null +++ b/scripts/release_checks.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import argparse +import ast +import re +import tarfile +import zipfile +from email.parser import BytesParser +from pathlib import Path, PurePosixPath + +PROJECT_NAME = "xarray-grass" +VERSION_FILE = Path("src/xarray_grass/__init__.py") + + +def version_from_tag(tag: str) -> str: + if not re.fullmatch(r"v\d+\.\d+\.\d+", tag): + raise SystemExit(f"Release tags must use the vX.Y.Z format, got {tag!r}.") + return tag[1:] + + +def version_from_source(version_file: Path) -> str: + module = ast.parse(version_file.read_text(), filename=str(version_file)) + for statement in module.body: + if not isinstance(statement, ast.Assign): + continue + if any( + isinstance(target, ast.Name) and target.id == "__version__" + for target in statement.targets + ): + value = ast.literal_eval(statement.value) + if isinstance(value, str): + return value + raise SystemExit(f"Could not find a string __version__ in {version_file}.") + + +def verify_version(tag: str, version_file: Path) -> None: + tag_version = version_from_tag(tag) + source_version = version_from_source(version_file) + if source_version != tag_version: + raise SystemExit( + f"Tag {tag!r} does not match package version {source_version!r}." + ) + + +def verify_metadata(metadata_bytes: bytes, artifact: Path, version: str) -> None: + metadata = BytesParser().parsebytes(metadata_bytes) + if metadata["Name"] != PROJECT_NAME or metadata["Version"] != version: + raise SystemExit( + f"{artifact} has {metadata['Name']} {metadata['Version']}, " + f"expected {PROJECT_NAME} {version}." + ) + + +def verify_artifacts(dist: Path, tag: str) -> None: + version = version_from_tag(tag) + sdists = list(dist.glob("*.tar.gz")) + wheels = list(dist.glob("*.whl")) + + if len(sdists) != 1: + raise SystemExit(f"Expected one sdist, found {len(sdists)}: {sdists}") + if len(wheels) != 1: + raise SystemExit(f"Expected one wheel, found {len(wheels)}: {wheels}") + + with tarfile.open(sdists[0]) as archive: + metadata_files = [ + member + for member in archive.getmembers() + if PurePosixPath(member.name).name == "PKG-INFO" + and len(PurePosixPath(member.name).parts) == 2 + ] + if len(metadata_files) != 1: + raise SystemExit(f"Could not identify sdist metadata in {sdists[0]}.") + metadata_file = archive.extractfile(metadata_files[0]) + if metadata_file is None: + raise SystemExit(f"Could not read sdist metadata in {sdists[0]}.") + verify_metadata(metadata_file.read(), sdists[0], version) + + with zipfile.ZipFile(wheels[0]) as archive: + metadata_files = [ + name for name in archive.namelist() if name.endswith(".dist-info/METADATA") + ] + if len(metadata_files) != 1: + raise SystemExit(f"Could not identify wheel metadata in {wheels[0]}.") + verify_metadata(archive.read(metadata_files[0]), wheels[0], version) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Validate release versions and artifacts." + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + version_parser = subparsers.add_parser("verify-version") + version_parser.add_argument("tag") + version_parser.add_argument("--version-file", type=Path, default=VERSION_FILE) + + artifacts_parser = subparsers.add_parser("verify-artifacts") + artifacts_parser.add_argument("dist", type=Path) + artifacts_parser.add_argument("tag") + + args = parser.parse_args() + if args.command == "verify-version": + verify_version(args.tag, args.version_file) + else: + verify_artifacts(args.dist, args.tag) + + +if __name__ == "__main__": + main()