diff --git a/.github/workflows/test_and_deploy.yml b/.github/workflows/test_and_deploy.yml index 8617f76..2f6203d 100644 --- a/.github/workflows/test_and_deploy.yml +++ b/.github/workflows/test_and_deploy.yml @@ -2,7 +2,7 @@ name: Python test and deploy on: release: - types: [created] + types: [published] jobs: @@ -36,26 +36,26 @@ jobs: deploy: needs: test runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write steps: - uses: actions/checkout@v6 - name: Set Up Build uses: actions/setup-python@v6 with: python-version: "3.14" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -e .[deploy] - - name: Update RC Version + - name: Check VERSION matches the release tag run: | - python scripts/update_version.py ${GITHUB_REF} + version="$(cat VERSION)" + if [ "v$version" != "$GITHUB_REF_NAME" ]; then + echo "VERSION ($version) does not match release tag ($GITHUB_REF_NAME)" >&2 + exit 1 + fi - name: Build Dist run: | - rm -rf dist/* + python -m pip install --upgrade pip build python -m build - - name: Publish - env: - TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} - TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} - run: | - python scripts/upload_new_package.py + # Trusted Publishing (OIDC): the repo is registered as a publisher on PyPI (api-client -> Settings -> Publishing) for the `pypi` environment. + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..f6e146e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,44 @@ +# Contributing + +## Development setup + +The library supports Python 3.9 to 3.14. Create a virtual environment and +install the package with its development dependencies: + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -e '.[dev]' +``` + +## Tests and linting + +Available Makefile commands: + +```bash +make test # run the test suite +make lint # check formatting and style (isort, black, flake8) +make format # apply isort and black fixes +make check # lint + test — run this before pushing +``` + +## Releasing + +Releases publish to [PyPI](https://pypi.org/project/api-client/) automatically +when a GitHub Release is published, using +[PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/). + +To release: + +1. Bump the version in the `VERSION` file in a pull request and merge it. +2. Publish a GitHub Release whose tag is `v` + (for example, `v1.4.0` for `VERSION` `1.4.0`). + +Publishing the release triggers the `Python test and deploy` workflow, which +runs the full test suite, verifies the tag matches `VERSION`, builds the sdist +and wheel, and publishes them to PyPI. A tag that does not match `VERSION` +fails the release before anything is published. + +For a pre-release, tag it as a PEP 440 pre-release (for example, `v1.4.0rc1`, +with `VERSION` set to `1.4.0rc1`) and tick the "Set as a pre-release" box on the +GitHub Release. diff --git a/scripts/update_version.py b/scripts/update_version.py deleted file mode 100644 index 61270d7..0000000 --- a/scripts/update_version.py +++ /dev/null @@ -1,125 +0,0 @@ -""" -Automate the generation of release candidate versions -for rc/ named branches. -""" -import subprocess -import sys -from typing import List, Optional, Tuple - -VERSION_FILE = "VERSION" - - -def main(github_ref: str): - major, minor, patch, rc = extract_version_from_ref(github_ref) - print(f"tagged version: {fmt_version(major, minor, patch, rc)}") - versions = get_all_versions() - - if (major, minor, patch, rc) in versions: - raise SystemExit( - f"Unable to upload new version. version already exists: '{fmt_version(major, minor, patch, rc)}'" - ) - cur_version = get_version() - if cur_version != f"{major}.{minor}.{patch}": - raise SystemExit( - "Cannot upload tag version. it does not match the current version of the project: " - f"current_version = {cur_version}, tag_version = {fmt_version(major, minor, patch)}" - ) - if rc > 0: - print("Tagged as RC version. Updating version file.") - update_rc_version(rc) - - print(f"Version in VERSION file: {read_version()}") - - -def fmt_version(major: int, minor: int, patch: int, rc: int = 0): - if rc > 0: - return f"{major}.{minor}.{patch}" - return f"{major}.{minor}.{patch}rc{rc}" - - -def extract_version_from_ref(ref: str) -> Tuple[int, int, int, int]: - err = ( - f"invalid github ref, expecting in format `refs/tags/v..[rc]`, got: '{ref}'" - ) - try: - title, subtitle, version = ref.split("/") - except ValueError: - raise SystemExit(err) - - if title != "refs" or subtitle != "tags" or not version.startswith("v"): - raise SystemExit(err) - - try: - major, minor, patch, rc = split_ver(version[1:]) - except ValueError: - raise SystemExit(err) - - return major, minor, patch, rc - - -def get_current_branch() -> str: - p = subprocess.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], capture_output=True) - if p.returncode != 0: - raise SystemExit("Unable to determine current git branch name") - return p.stdout.decode("utf-8").strip() - - -def update_rc_version(rc_version): - # Extract the first 8 chars of the git hash and create a new rc version - version = get_version() - update_version_file(version, rc_version) - - -def get_all_versions() -> List[Tuple[int, int, int, int]]: - p = subprocess.run(["pip", "search", "api-client"], capture_output=True) - if p.returncode != 0: - raise SystemExit("Unable to determine current git branch name") - - # A list to contain the existing rc versions deployed - versions = [] - - resp = p.stdout.decode("utf-8").strip() - for line in resp.split("\n"): - elems = line.split() - if elems[0] != "api-client": - continue - version = elems[1].strip("()") - - versions.append(split_ver(version)) - - return versions - - -def split_ver(version: str) -> Tuple[int, int, int, int]: - major, minor, patch = version.split(".") - if "rc" in patch: - patch, rc = patch.split("rc") - else: - rc = 0 - return int(major), int(minor), int(patch), int(rc) - - -def get_version() -> str: - v = read_version() - - # version is symantic. Need 3 parts and last part must be an integer - # otherwise we cant update the version. - parts = v.split(".") - if len(parts) != 3 or not parts[2].isnumeric(): - raise SystemExit(f"version is invalid: '{v}'") - return v - - -def read_version() -> str: - with open(VERSION_FILE, "r") as buf: - v = buf.read() - return v - - -def update_version_file(version: str, rc_ver: int): - with open(VERSION_FILE, "w") as buf: - buf.write(f"{version}rc{rc_ver}") - - -if __name__ == "__main__": - sys.exit(main(github_ref=sys.argv[1])) diff --git a/scripts/upload_new_package.py b/scripts/upload_new_package.py deleted file mode 100644 index dac1531..0000000 --- a/scripts/upload_new_package.py +++ /dev/null @@ -1,36 +0,0 @@ -""" -PyPi package uploader that doesn't return a bad status code -if the package already exists. -""" -import sys - -from requests import HTTPError -from twine.cli import dispatch - -VERSION_FILE = "VERSION" - - -def main(): - print("Uploading to pypi") - upload_to_pypi() - - -def upload_to_pypi(): - try: - return dispatch(["upload", "dist/*"]) - except HTTPError as error: - handle_http_error(error) - - -def handle_http_error(error: HTTPError): - try: - if error.response.status_code == 400: - print(error) - else: - raise error - except Exception: - raise error - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/setup.py b/setup.py index ee0c522..00d6d13 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ lint_dependencies = ["flake8", "flake8-docstrings", "black", "isort"] docs_dependencies = [] dev_dependencies = test_dependencies + lint_dependencies + docs_dependencies + ["ipdb"] -deploy_dependencies = ["build", "requests", "twine"] +deploy_dependencies = ["build"] with open("README.md", "r") as fh: