Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
name: tests

on:
workflow_call:
push:
branches: ["main"]
pull_request:
Expand Down
109 changes: 109 additions & 0 deletions scripts/release_checks.py
Original file line number Diff line number Diff line change
@@ -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()