From 9d7ef6f161da37d2915d7fdf49f3a3814c51f493 Mon Sep 17 00:00:00 2001 From: Mike Date: Sun, 7 Jun 2026 15:08:20 +0100 Subject: [PATCH 1/7] ci: publish to PyPI via Trusted Publishing, drop the RC scheme - replace twine + username/password upload with pypa/gh-action-pypi-publish using OIDC trusted publishing (no secrets) - guard the release by asserting VERSION matches the release tag, replacing the update_version.py script and its dead 'pip search' call - delete update_version.py and upload_new_package.py; drop the RC versioning scheme entirely - trigger on release 'published' rather than 'created' - trim the deploy extra to just 'build' (requests/twine were only used by the removed scripts) --- .github/workflows/test_and_deploy.yml | 27 +++--- scripts/update_version.py | 125 -------------------------- scripts/upload_new_package.py | 36 -------- setup.py | 2 +- 4 files changed, 14 insertions(+), 176 deletions(-) delete mode 100644 scripts/update_version.py delete mode 100644 scripts/upload_new_package.py diff --git a/.github/workflows/test_and_deploy.yml b/.github/workflows/test_and_deploy.yml index 8617f76..3559821 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,25 @@ 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 + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 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: From 77fd9630c42aa23e98b2eb0e1ecbde2f22c802bf Mon Sep 17 00:00:00 2001 From: Mike Date: Sun, 7 Jun 2026 15:47:53 +0100 Subject: [PATCH 2/7] docs: add CONTRIBUTING.md with dev setup and release instructions Documents local development, the make targets, and the Trusted Publishing release flow (one-time setup plus per-release steps). --- CONTRIBUTING.md | 62 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6ab4178 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,62 @@ +# 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 + +A `Makefile` wraps the same commands CI runs: + +```bash +make test # run the test suite (with the 100% coverage gate) +make lint # check formatting and style (isort, black, flake8) +make format # apply isort and black fixes +make check # lint + test — run this before pushing +``` + +CI runs `lint` and the test suite across every supported Python version on +each pull request. A green `make check` locally means a green pipeline. + +## Releasing + +Releases publish to [PyPI](https://pypi.org/project/api-client/) automatically +when a GitHub Release is published. Publishing uses +[PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/) over OIDC, +so no API tokens or passwords are stored. + +### One-time setup + +These must be configured once by a maintainer with admin access: + +1. Create a GitHub Environment named `pypi` + (repo Settings -> Environments). +2. Register the repository as a trusted publisher on PyPI + (the `api-client` project -> Settings -> Publishing -> add a GitHub Actions + publisher) with: + - Owner: `MikeWooster` + - Repository: `api-client` + - Workflow: `test_and_deploy.yml` + - Environment: `pypi` + +### Cutting a 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. From 56474a0b7928621e3813e81ff0fdb574428f1a41 Mon Sep 17 00:00:00 2001 From: Mike Date: Sun, 7 Jun 2026 15:51:20 +0100 Subject: [PATCH 3/7] docs: simplify the releasing section --- CONTRIBUTING.md | 22 ++++------------------ 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6ab4178..78acb71 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,25 +28,11 @@ each pull request. A green `make check` locally means a green pipeline. ## Releasing Releases publish to [PyPI](https://pypi.org/project/api-client/) automatically -when a GitHub Release is published. Publishing uses -[PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/) over OIDC, -so no API tokens or passwords are stored. +when a GitHub Release is published, using +[PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/) over OIDC +(no API tokens or passwords are stored). -### One-time setup - -These must be configured once by a maintainer with admin access: - -1. Create a GitHub Environment named `pypi` - (repo Settings -> Environments). -2. Register the repository as a trusted publisher on PyPI - (the `api-client` project -> Settings -> Publishing -> add a GitHub Actions - publisher) with: - - Owner: `MikeWooster` - - Repository: `api-client` - - Workflow: `test_and_deploy.yml` - - Environment: `pypi` - -### Cutting a release +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` From b52943edecab76ced6ac2cdab6b99138c175162f Mon Sep 17 00:00:00 2001 From: Mike Date: Sun, 7 Jun 2026 15:52:45 +0100 Subject: [PATCH 4/7] ci: note the PyPI trusted-publisher config near the publish step --- .github/workflows/test_and_deploy.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test_and_deploy.yml b/.github/workflows/test_and_deploy.yml index 3559821..2f6203d 100644 --- a/.github/workflows/test_and_deploy.yml +++ b/.github/workflows/test_and_deploy.yml @@ -56,5 +56,6 @@ jobs: run: | python -m pip install --upgrade pip build python -m build + # 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 From a01a737fcf79741804d0dc0fd8f4156dd543a1f7 Mon Sep 17 00:00:00 2001 From: Mike Date: Sun, 7 Jun 2026 15:53:43 +0100 Subject: [PATCH 5/7] docs: drop CI/pipeline note from the tests section --- CONTRIBUTING.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 78acb71..c6f8a66 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,9 +22,6 @@ make format # apply isort and black fixes make check # lint + test — run this before pushing ``` -CI runs `lint` and the test suite across every supported Python version on -each pull request. A green `make check` locally means a green pipeline. - ## Releasing Releases publish to [PyPI](https://pypi.org/project/api-client/) automatically From d32439c9e6fe41571e3ec02e8166b425fd0702fc Mon Sep 17 00:00:00 2001 From: Mike Date: Sun, 7 Jun 2026 15:55:22 +0100 Subject: [PATCH 6/7] docs: trim trusted publishing description to the facts --- CONTRIBUTING.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c6f8a66..15b40e7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,8 +26,7 @@ make check # lint + test — run this before pushing 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/) over OIDC -(no API tokens or passwords are stored). +[PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/) over OIDC. To release: From 364b5791b19fc7708e36924a1cecf4fe0479bff9 Mon Sep 17 00:00:00 2001 From: Mike Date: Sun, 7 Jun 2026 15:57:18 +0100 Subject: [PATCH 7/7] docs: tidy tests and releasing wording --- CONTRIBUTING.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 15b40e7..f6e146e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,10 +13,10 @@ pip install -e '.[dev]' ## Tests and linting -A `Makefile` wraps the same commands CI runs: +Available Makefile commands: ```bash -make test # run the test suite (with the 100% coverage gate) +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 @@ -26,7 +26,7 @@ make check # lint + test — run this before pushing 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/) over OIDC. +[PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/). To release: