From 19df8fbae186cefc95cc7f4d3664aed111e21593 Mon Sep 17 00:00:00 2001 From: yossi <54272821+Apakottur@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:43:45 +0000 Subject: [PATCH 1/5] Switch releases to tag-driven OIDC trusted publishing Move release from a pyproject.toml path-filter trigger to git tags, and publish to PyPI via Trusted Publishing (OIDC) instead of a stored token. - Derive the package version from the git tag via hatch-vcs, so it no longer lives in pyproject.toml (removes version duplication and the manual bump PR step). - Trigger the Release workflow on `v*` tags; use `id-token: write` with `uv publish --trusted-publishing always` (no PYPI_TOKEN secret). - Create a GitHub Release with auto-generated notes on each release. - Drop the curl PyPI-exists check; tags are inherently idempotent. - Document the tag-based release flow in the README. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/release.yml | 46 ++++++++++++++--------------------- README.md | 14 ++++++++++- linters/cspell/words.txt | 1 + pyproject.toml | 7 ++++-- uv.lock | 1 - 5 files changed, 37 insertions(+), 32 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 53d3f62..4418dde 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,9 +2,7 @@ name: Release on: push: - branches: [main] - paths: - - "pyproject.toml" + tags: ["v*"] workflow_dispatch: @@ -12,36 +10,28 @@ jobs: release: runs-on: ubuntu-latest + permissions: + # Required for PyPI Trusted Publishing (OIDC), so no API token is stored. + id-token: write + # Required to create the GitHub Release. + contents: write + steps: - uses: actions/checkout@v7 + with: + # hatch-vcs derives the version from the git tag, so it needs the + # full history and tags, not a shallow clone. + fetch-depth: 0 - uses: ./.github/workflows/util/setup_python - - name: Determine version - id: version - run: echo "version=$(uv version --short)" >> "$GITHUB_OUTPUT" - - - name: Check if version is already released - id: check - env: - VERSION: ${{ steps.version.outputs.version }} - run: | - code=$(curl -fsS -o /dev/null -w "%{http_code}" \ - "https://pypi.org/pypi/shpyx/${VERSION}/json" || true) - if [ "$code" = "200" ]; then - echo "shpyx ${VERSION} is already on PyPI, skipping release." - echo "publish=false" >> "$GITHUB_OUTPUT" - else - echo "shpyx ${VERSION} is new, releasing." - echo "publish=true" >> "$GITHUB_OUTPUT" - fi - - name: Build - if: steps.check.outputs.publish == 'true' run: uv build - - name: Publish - if: steps.check.outputs.publish == 'true' - env: - UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }} - run: uv publish + - name: Publish to PyPI + run: uv publish --trusted-publishing always + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true diff --git a/README.md b/README.md index f575caf..f3f7729 100644 --- a/README.md +++ b/README.md @@ -212,4 +212,16 @@ and `ty`: ty check --config-file linters/ty.toml src tests ``` -To trigger a deployment of a new version upon merge, bump the version number in `pyproject.toml`. +### Releasing + +The package version is derived from the git tag (via `hatch-vcs`), so there is no version to bump in +`pyproject.toml`. To release a new version, push a `v`-prefixed tag from `main`: + +```shell +git tag v0.0.37 +git push origin v0.0.37 +``` + +This triggers the `Release` workflow, which builds the package, publishes it to PyPI using +[Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (OIDC, no stored token), and creates a +GitHub Release with auto-generated notes. diff --git a/linters/cspell/words.txt b/linters/cspell/words.txt index a65c054..502735f 100644 --- a/linters/cspell/words.txt +++ b/linters/cspell/words.txt @@ -33,3 +33,4 @@ pathlib anyio multibyte haia +softprops diff --git a/pyproject.toml b/pyproject.toml index adf7ff6..ee27289 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,10 @@ [build-system] build-backend = "hatchling.build" -requires = ["hatchling"] +requires = ["hatchling", "hatch-vcs"] [project] name = "shpyx" -version = "0.0.36" +dynamic = ["version"] description = "Run shell commands in Python" authors = [{name = "Yossi Rozantsev"}] license = {text = "MIT"} @@ -34,5 +34,8 @@ homepage = "https://github.com/Apakottur/shpyx" repository = "https://github.com/Apakottur/shpyx" documentation = "https://github.com/Apakottur/shpyx" +[tool.hatch.version] +source = "vcs" + [tool.uv] package = true diff --git a/uv.lock b/uv.lock index 562a6aa..98864ac 100644 --- a/uv.lock +++ b/uv.lock @@ -590,7 +590,6 @@ wheels = [ [[package]] name = "shpyx" -version = "0.0.36" source = { editable = "." } dependencies = [ { name = "mypy" }, From a34bc50994b491211ebe64773d6054d85fedd186 Mon Sep 17 00:00:00 2001 From: yossi <54272821+Apakottur@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:49:21 +0000 Subject: [PATCH 2/5] Move dev tooling out of runtime dependencies shpyx has no runtime dependencies, but mypy/pytest/ruff/ty/pre-commit were listed under [project.dependencies], so they were installed for every end user of the package. Move them to a [dependency-groups] dev group, which uv still syncs by default for local dev and CI. The published wheel now has zero Requires-Dist. Co-Authored-By: Claude Opus 4.8 (1M context) --- pyproject.toml | 20 +++++++++++--------- uv.lock | 8 ++++++-- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ee27289..4501afc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,6 +2,17 @@ build-backend = "hatchling.build" requires = ["hatchling", "hatch-vcs"] +[dependency-groups] +dev = [ + "mypy==2.2.0", + "pre-commit==4.6.0", + "pytest-cov==7.1.0", + "pytest-mock==3.15.1", + "pytest==9.1.1", + "ruff==0.15.21", + "ty==0.0.58", +] + [project] name = "shpyx" dynamic = ["version"] @@ -19,15 +30,6 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ] -dependencies = [ - "mypy==2.2.0", - "pre-commit==4.6.0", - "pytest-cov==7.1.0", - "pytest-mock==3.15.1", - "pytest==9.1.1", - "ruff==0.15.21", - "ty==0.0.58", -] [project.urls] homepage = "https://github.com/Apakottur/shpyx" diff --git a/uv.lock b/uv.lock index 98864ac..af28884 100644 --- a/uv.lock +++ b/uv.lock @@ -591,7 +591,9 @@ wheels = [ [[package]] name = "shpyx" source = { editable = "." } -dependencies = [ + +[package.dev-dependencies] +dev = [ { name = "mypy" }, { name = "pre-commit" }, { name = "pytest" }, @@ -602,7 +604,9 @@ dependencies = [ ] [package.metadata] -requires-dist = [ + +[package.metadata.requires-dev] +dev = [ { name = "mypy", specifier = "==2.2.0" }, { name = "pre-commit", specifier = "==4.6.0" }, { name = "pytest", specifier = "==9.1.1" }, From e47b4f28aea69c1f8cadfedc9541ab689bdbc952 Mon Sep 17 00:00:00 2001 From: yossi <54272821+Apakottur@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:08:05 +0000 Subject: [PATCH 3/5] wip --- .github/workflows/release.yml | 2 +- README.md | 26 ++++++-------------------- 2 files changed, 7 insertions(+), 21 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4418dde..016cf04 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,6 +32,6 @@ jobs: run: uv publish --trusted-publishing always - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: generate_release_notes: true diff --git a/README.md b/README.md index f3f7729..fc28334 100644 --- a/README.md +++ b/README.md @@ -178,50 +178,36 @@ Other 3rd-party libraries for running shell commands in Python: To contribute simply open a PR with your changes. -Tests, linters and type checks are run in CI through GitHub Actions. +All checks (Linters, type checks and tests) automatically run in CI through GitHub Actions. ### Running checks locally -To run checks locally, start by installing all the development dependencies: +Local development is done with [uv](https://docs.astral.sh/uv/getting-started/installation/). +Start by installing all the development dependencies: ```shell uv sync ``` To run the linters use `pre-commit`: - ```shell pre-commit run -a ``` To run the unit tests use `pytest`: - ```shell pytest -c tests/pytest.ini tests ``` -To run type checks use `mypy`: - +To run type checks use `mypy` or `ty` (both are run in CI): ```shell mypy --config-file linters/mypy.toml src tests -``` - -and `ty`: - -```shell ty check --config-file linters/ty.toml src tests ``` ### Releasing -The package version is derived from the git tag (via `hatch-vcs`), so there is no version to bump in -`pyproject.toml`. To release a new version, push a `v`-prefixed tag from `main`: - +To release a new version: ```shell -git tag v0.0.37 -git push origin v0.0.37 +./scripts/release.py ``` - -This triggers the `Release` workflow, which builds the package, publishes it to PyPI using -[Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (OIDC, no stored token), and creates a -GitHub Release with auto-generated notes. From cc933621e1e4357058107d296bf5a530a3a1f9f3 Mon Sep 17 00:00:00 2001 From: yossi <54272821+Apakottur@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:10:30 +0000 Subject: [PATCH 4/5] Add scripts/release.py to cut releases safely Interactive helper that dogfoods shpyx to run the git commands. It: - verifies the working tree is clean and on `main`; - fetches and fast-forwards to `origin/main`; - reads the latest published version from PyPI and prompts for the next one (patch / minor / major); - offers to delete a pre-existing tag (e.g. from a failed release run) before recreating it; - creates and pushes the `v*` tag that triggers the Release workflow. The shebang uses `uv run`, so `./scripts/release.py` works without a manually activated virtualenv. Ignore T201 (print) under scripts/. Co-Authored-By: Claude Opus 4.8 (1M context) --- linters/ruff.toml | 3 ++ scripts/release.py | 129 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100755 scripts/release.py diff --git a/linters/ruff.toml b/linters/ruff.toml index 8d998f9..9373e79 100644 --- a/linters/ruff.toml +++ b/linters/ruff.toml @@ -126,3 +126,6 @@ ban-relative-imports = "all" "tests/*" = [ "S101", # Use of `assert` detected ] +"scripts/*" = [ + "T201", # `print` found (scripts are CLIs that talk to the user via stdout) +] diff --git a/scripts/release.py b/scripts/release.py new file mode 100755 index 0000000..af51ff5 --- /dev/null +++ b/scripts/release.py @@ -0,0 +1,129 @@ +#!/usr/bin/env -S uv run python +""" +Cut a new shpyx release. + +The published version is derived from the git tag (via hatch-vcs), so releasing +is simply a matter of pushing a `v*` tag to `main`. This script does that safely: + + 1. Verify the working tree is clean and on `main`. + 2. Fetch and fast-forward to `origin/main`. + 3. Look up the latest version currently on PyPI. + 4. Let the user pick the next version (patch / minor / major). + 5. If the tag already exists (e.g. a previous release run failed), offer to + delete it first. + 6. Create the tag and push it, which triggers the `Release` GitHub Action. + +Run from the repository root with `./scripts/release.py` (the shebang uses `uv run`, +so shpyx and its environment are set up automatically). +""" + +import json +import sys +import urllib.request + +import shpyx + +PYPI_URL = "https://pypi.org/pypi/shpyx/json" +MAIN_BRANCH = "main" + + +def abort(message: str) -> None: + """Print an error and exit with a non-zero status.""" + print(f"\n❌ {message}") + sys.exit(1) + + +def confirm(question: str) -> bool: + """Ask a yes/no question, defaulting to 'no'.""" + return input(f"{question} [y/N] ").strip().lower() in ("y", "yes") + + +def get_pypi_version() -> tuple[int, int, int]: + """Return the latest published shpyx version on PyPI as a (major, minor, patch) tuple.""" + with urllib.request.urlopen(PYPI_URL) as response: # noqa: S310 (trusted, hardcoded https URL) + data = json.load(response) + + version = data["info"]["version"] + parts = version.split(".") + if len(parts) != 3 or not all(part.isdigit() for part in parts): + abort(f"Cannot parse PyPI version {version!r} as 'major.minor.patch'.") + + major, minor, patch = (int(part) for part in parts) + return major, minor, patch + + +def select_next_version(current: tuple[int, int, int]) -> str: + """Prompt the user to pick the next version relative to the current one.""" + major, minor, patch = current + bumps = { + "1": ("patch", f"{major}.{minor}.{patch + 1}"), + "2": ("minor", f"{major}.{minor + 1}.0"), + "3": ("major", f"{major + 1}.0.0"), + } + + print(f"\nLatest version on PyPI: {major}.{minor}.{patch}") + print("Select the next version:") + for key, (name, version) in bumps.items(): + print(f" {key}) {name:<5} -> {version}") + + while True: + choice = input("Choice [1/2/3]: ").strip() + if choice in bumps: + return bumps[choice][1] + print("Invalid choice, please enter 1, 2 or 3.") + + +def ensure_release_preconditions() -> None: + """Verify we are on a clean, up-to-date `main` before tagging.""" + branch = shpyx.run("git rev-parse --abbrev-ref HEAD").stdout.strip() + if branch != MAIN_BRANCH: + abort(f"Must be on the '{MAIN_BRANCH}' branch, but currently on '{branch}'.") + + if shpyx.run("git status --porcelain").stdout.strip(): + abort("Working tree is not clean. Commit or stash your changes first.") + + print("Fetching from origin...") + shpyx.run("git fetch origin --tags --prune", log_output=True) + + # Fast-forward only: aborts if local `main` has diverged from origin. + shpyx.run(f"git pull --ff-only origin {MAIN_BRANCH}", log_output=True) + + +def tag_exists(tag: str) -> bool: + """Return True if the tag exists locally or on origin.""" + local = shpyx.run(f"git tag --list {tag}").stdout.strip() + remote = shpyx.run(f"git ls-remote --tags origin {tag}").stdout.strip() + return bool(local or remote) + + +def delete_tag(tag: str) -> None: + """Delete the tag both locally and on origin.""" + # Local delete may fail if the tag only exists on the remote; ignore that. + shpyx.run(f"git tag --delete {tag}", verify_return_code=False) + shpyx.run(f"git push --delete origin {tag}", verify_return_code=False, log_output=True) + + +def main() -> None: + ensure_release_preconditions() + + next_version = select_next_version(get_pypi_version()) + tag = f"v{next_version}" + + if tag_exists(tag): + print(f"\n⚠️ Tag {tag} already exists (a previous release may have failed).") + if not confirm(f"Delete the existing {tag} and recreate it?"): + abort("Aborted: tag already exists.") + delete_tag(tag) + + if not confirm(f"\nCreate and push tag {tag} to trigger the release?"): + abort("Aborted by user.") + + shpyx.run(f"git tag {tag}") + shpyx.run(f"git push origin {tag}", log_output=True) + + print(f"\n✅ Pushed {tag}. The Release workflow is now running:") + print(" https://github.com/Apakottur/shpyx/actions/workflows/release.yml") + + +if __name__ == "__main__": + main() From 0b54a5ac8886f96fa632333001ddab3a81f677b5 Mon Sep 17 00:00:00 2001 From: yossi <54272821+Apakottur@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:15:32 +0000 Subject: [PATCH 5/5] Drop release script from this PR Remove scripts/release.py (and its scripts/* ruff ignore) and revert the README release section to the manual tag instructions. The script will land in a follow-up PR. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 9 +++- linters/ruff.toml | 3 -- scripts/release.py | 129 --------------------------------------------- 3 files changed, 7 insertions(+), 134 deletions(-) delete mode 100755 scripts/release.py diff --git a/README.md b/README.md index fc28334..faceebb 100644 --- a/README.md +++ b/README.md @@ -207,7 +207,12 @@ ty check --config-file linters/ty.toml src tests ### Releasing -To release a new version: +The package version is derived from the git tag (via `hatch-vcs`), so there is nothing to bump in +`pyproject.toml`. To release a new version, push a `v`-prefixed tag from `main`: ```shell -./scripts/release.py +git tag v0.0.37 +git push origin v0.0.37 ``` + +This triggers the `Release` workflow, which builds the package, publishes it to PyPI using Trusted +Publishing (OIDC, no stored token), and creates a GitHub Release with auto-generated notes. diff --git a/linters/ruff.toml b/linters/ruff.toml index 9373e79..8d998f9 100644 --- a/linters/ruff.toml +++ b/linters/ruff.toml @@ -126,6 +126,3 @@ ban-relative-imports = "all" "tests/*" = [ "S101", # Use of `assert` detected ] -"scripts/*" = [ - "T201", # `print` found (scripts are CLIs that talk to the user via stdout) -] diff --git a/scripts/release.py b/scripts/release.py deleted file mode 100755 index af51ff5..0000000 --- a/scripts/release.py +++ /dev/null @@ -1,129 +0,0 @@ -#!/usr/bin/env -S uv run python -""" -Cut a new shpyx release. - -The published version is derived from the git tag (via hatch-vcs), so releasing -is simply a matter of pushing a `v*` tag to `main`. This script does that safely: - - 1. Verify the working tree is clean and on `main`. - 2. Fetch and fast-forward to `origin/main`. - 3. Look up the latest version currently on PyPI. - 4. Let the user pick the next version (patch / minor / major). - 5. If the tag already exists (e.g. a previous release run failed), offer to - delete it first. - 6. Create the tag and push it, which triggers the `Release` GitHub Action. - -Run from the repository root with `./scripts/release.py` (the shebang uses `uv run`, -so shpyx and its environment are set up automatically). -""" - -import json -import sys -import urllib.request - -import shpyx - -PYPI_URL = "https://pypi.org/pypi/shpyx/json" -MAIN_BRANCH = "main" - - -def abort(message: str) -> None: - """Print an error and exit with a non-zero status.""" - print(f"\n❌ {message}") - sys.exit(1) - - -def confirm(question: str) -> bool: - """Ask a yes/no question, defaulting to 'no'.""" - return input(f"{question} [y/N] ").strip().lower() in ("y", "yes") - - -def get_pypi_version() -> tuple[int, int, int]: - """Return the latest published shpyx version on PyPI as a (major, minor, patch) tuple.""" - with urllib.request.urlopen(PYPI_URL) as response: # noqa: S310 (trusted, hardcoded https URL) - data = json.load(response) - - version = data["info"]["version"] - parts = version.split(".") - if len(parts) != 3 or not all(part.isdigit() for part in parts): - abort(f"Cannot parse PyPI version {version!r} as 'major.minor.patch'.") - - major, minor, patch = (int(part) for part in parts) - return major, minor, patch - - -def select_next_version(current: tuple[int, int, int]) -> str: - """Prompt the user to pick the next version relative to the current one.""" - major, minor, patch = current - bumps = { - "1": ("patch", f"{major}.{minor}.{patch + 1}"), - "2": ("minor", f"{major}.{minor + 1}.0"), - "3": ("major", f"{major + 1}.0.0"), - } - - print(f"\nLatest version on PyPI: {major}.{minor}.{patch}") - print("Select the next version:") - for key, (name, version) in bumps.items(): - print(f" {key}) {name:<5} -> {version}") - - while True: - choice = input("Choice [1/2/3]: ").strip() - if choice in bumps: - return bumps[choice][1] - print("Invalid choice, please enter 1, 2 or 3.") - - -def ensure_release_preconditions() -> None: - """Verify we are on a clean, up-to-date `main` before tagging.""" - branch = shpyx.run("git rev-parse --abbrev-ref HEAD").stdout.strip() - if branch != MAIN_BRANCH: - abort(f"Must be on the '{MAIN_BRANCH}' branch, but currently on '{branch}'.") - - if shpyx.run("git status --porcelain").stdout.strip(): - abort("Working tree is not clean. Commit or stash your changes first.") - - print("Fetching from origin...") - shpyx.run("git fetch origin --tags --prune", log_output=True) - - # Fast-forward only: aborts if local `main` has diverged from origin. - shpyx.run(f"git pull --ff-only origin {MAIN_BRANCH}", log_output=True) - - -def tag_exists(tag: str) -> bool: - """Return True if the tag exists locally or on origin.""" - local = shpyx.run(f"git tag --list {tag}").stdout.strip() - remote = shpyx.run(f"git ls-remote --tags origin {tag}").stdout.strip() - return bool(local or remote) - - -def delete_tag(tag: str) -> None: - """Delete the tag both locally and on origin.""" - # Local delete may fail if the tag only exists on the remote; ignore that. - shpyx.run(f"git tag --delete {tag}", verify_return_code=False) - shpyx.run(f"git push --delete origin {tag}", verify_return_code=False, log_output=True) - - -def main() -> None: - ensure_release_preconditions() - - next_version = select_next_version(get_pypi_version()) - tag = f"v{next_version}" - - if tag_exists(tag): - print(f"\n⚠️ Tag {tag} already exists (a previous release may have failed).") - if not confirm(f"Delete the existing {tag} and recreate it?"): - abort("Aborted: tag already exists.") - delete_tag(tag) - - if not confirm(f"\nCreate and push tag {tag} to trigger the release?"): - abort("Aborted by user.") - - shpyx.run(f"git tag {tag}") - shpyx.run(f"git push origin {tag}", log_output=True) - - print(f"\n✅ Pushed {tag}. The Release workflow is now running:") - print(" https://github.com/Apakottur/shpyx/actions/workflows/release.yml") - - -if __name__ == "__main__": - main()