diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0db7e3f..3b6f55d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,11 +1,34 @@ name: comlink-python release +# Tag-driven release. +# +# The package version is derived from the Git tag at build time via hatch-vcs +# (see [tool.hatch.version] in pyproject.toml), so a release does NOT commit a +# version bump to the repository. This keeps the workflow compatible with the +# protected `main` branch (which forbids direct pushes). +# +# Ordering is build -> publish -> tag so the Git tag / GitHub Release is created +# only after a successful PyPI publish, which avoids leaving an orphaned tag +# behind if the build or upload fails. + on: workflow_dispatch: + inputs: + bump: + description: "Semantic version bump from the latest release tag" + type: choice + options: + - patch + - minor + - major + default: minor + +permissions: + contents: read jobs: - release: - name: Semantic Release + build: + name: Build distributions runs-on: ubuntu-latest if: github.ref == 'refs/heads/main' @@ -13,20 +36,17 @@ jobs: group: ${{ github.workflow }}-release-${{ github.ref_name }} cancel-in-progress: false - environment: - name: pypi - url: https://pypi.org/project/swgoh-comlink/ - - permissions: - id-token: write - contents: write + outputs: + version: ${{ steps.version.outputs.version }} + tag: ${{ steps.version.outputs.tag }} + sha: ${{ steps.version.outputs.sha }} steps: - name: Check-out repository uses: actions/checkout@v6 with: ref: ${{ github.ref_name }} - fetch-depth: 0 + fetch-depth: 0 # full history + tags for version derivation - name: Install uv uses: astral-sh/setup-uv@v7 @@ -36,48 +56,61 @@ jobs: with: python-version: "3.12" - # Update version - - name: Update version - run: uvx hatch version minor - - # Update CHANGELOG.md - - name: Update CHANGELOG.md - run: uvx git-changelog -B auto -Tio CHANGELOG.md -c angular -s build,deps,fix,feat,refactor -n semver - - # Commit version bump and changelog, then tag and push - - name: Commit and tag + - name: Compute next version from the latest tag + id: version run: | - VERSION=$(uvx hatch version) - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add src/swgoh_comlink/version.py CHANGELOG.md - git commit -m "chore(release): bump version to $VERSION [skip ci]" - git tag -a "v$VERSION" -m "Release version $VERSION" - git push origin ${{ github.ref_name }} --follow-tags - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + set -euo pipefail + latest="$(git tag --list 'v[0-9]*' --sort=-v:refname | head -n1)" + latest="${latest:-v0.0.0}" + base="${latest#v}" + base="${base%%[-+]*}" # strip any pre-release / build metadata + IFS='.' read -r major minor patch <<< "$base" + case "${{ inputs.bump }}" in + major) major=$((major + 1)); minor=0; patch=0 ;; + minor) minor=$((minor + 1)); patch=0 ;; + patch) patch=$((patch + 1)) ;; + esac + version="${major}.${minor}.${patch}" + tag="v${version}" + if git rev-parse "$tag" >/dev/null 2>&1; then + echo "::error::Tag $tag already exists — choose a different bump or delete the tag." + exit 1 + fi + { + echo "version=$version" + echo "tag=$tag" + echo "sha=$GITHUB_SHA" + } >> "$GITHUB_OUTPUT" + echo "Releasing $tag from $latest (bump=${{ inputs.bump }}) at $GITHUB_SHA" - # Build the package - name: Build the package - run: uvx hatch build + env: + # Force the build version without needing the tag to exist yet, so the + # tag can be created last (after a successful publish). + SETUPTOOLS_SCM_PRETEND_VERSION: ${{ steps.version.outputs.version }} + run: uvx --with hatch-vcs hatch build + + - name: Verify built version matches the target + run: | + set -euo pipefail + ls -1 dist/ + test -f "dist/swgoh_comlink-${{ steps.version.outputs.version }}.tar.gz" + test -f "dist/swgoh_comlink-${{ steps.version.outputs.version }}-py3-none-any.whl" - # Upload build artifacts for the publish job - name: Upload build artifacts uses: actions/upload-artifact@v7 with: name: dist path: dist/ - # Publish the package to PyPI pypi-publish: + name: Publish to PyPI runs-on: ubuntu-latest - if: github.ref == 'refs/heads/main' needs: - - release + - build permissions: # IMPORTANT: this permission is mandatory for trusted publishing id-token: write - contents: write environment: name: pypi @@ -92,3 +125,24 @@ jobs: - name: Publish release distributions to PyPI uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + + tag-release: + name: Tag and create GitHub Release + runs-on: ubuntu-latest + needs: + - build + - pypi-publish + permissions: + contents: write # create the tag + GitHub Release + + steps: + - name: Create tag and GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + gh release create "${{ needs.build.outputs.tag }}" \ + --repo "${{ github.repository }}" \ + --target "${{ needs.build.outputs.sha }}" \ + --title "${{ needs.build.outputs.tag }}" \ + --generate-notes diff --git a/.gitignore b/.gitignore index 236002f..9a36a4f 100644 --- a/.gitignore +++ b/.gitignore @@ -187,3 +187,6 @@ Pipfile* pyvenv.cfg /src/swgoh_comlink/tools.py /docs/exhaustive-tests.md + +# hatch-vcs generated version file (derived from Git tag at build time) +/src/swgoh_comlink/_version.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8a2df10..82bdaa1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,7 +48,7 @@ Be respectful, constructive, and patient. We're all here because we enjoy the ga gh auth login gh repo fork swgoh-utils/comlink-python ``` - + ```bash git clone https://github.com//comlink-python.git cd comlink-python @@ -60,7 +60,7 @@ Be respectful, constructive, and patient. We're all here because we enjoy the ga git config --local branch.main.remote upstream git remote set-url --push upstream github@github.com:/comlink-python.git ``` - + 2. **Install uv** (if you don't have it) ```bash @@ -114,7 +114,7 @@ comlink-python/ │ │ ├── commitlint.yml # Commit message validation (npm-based) │ │ ├── integration.yml # Integration tests against live comlink services │ │ ├── labeler.yml # Auto-label PRs by file path -│ │ ├── release.yml # Semantic release → PyPI publish +│ │ ├── release.yml # Tag-driven release (build → PyPI → tag) via hatch-vcs │ │ └── test.yml # Quick test + docs build │ ├── CODEOWNERS # Code ownership │ ├── dependabot.yml # Automated dependency updates @@ -164,7 +164,7 @@ comlink-python/ │ ├── globals.py # Logging configuration │ ├── swgoh_comlink.py # SwgohComlink (sync client) │ ├── swgoh_comlink_async.py # SwgohComlinkAsync (async client) -│ └── version.py # Package version (managed by hatch) +│ └── version.py # Exposes __version__ (derived from the Git tag via hatch-vcs) ├── tests/ │ ├── integration/ # Tests requiring a running comlink service │ ├── resources/ # Test fixture data (example-player.json, etc.) @@ -191,7 +191,7 @@ comlink-python/ | `helpers/` | `DataItems` IntFlag enum, `Constants` class, 25+ utility functions split across focused submodules | | `exceptions.py` | `SwgohComlinkException`, `SwgohComlinkValueError`, and `SwgohComlinkTypeError` | | `globals.py` | Shared logging setup (`get_logger()`) | -| `version.py` | Single `__version__` string, managed by hatch during releases | +| `version.py` | Exposes `__version__`, derived from the Git tag at build time via hatch-vcs | --- @@ -506,13 +506,13 @@ Before opening an issue, check the [existing issues](https://github.com/swgoh-ut Releases are handled by the maintainer through the GitHub Actions `release.yml` workflow. Contributors don't need to manage versioning or releases, but here's how it works for reference: -1. The release workflow is triggered manually (`workflow_dispatch`) -2. [Hatch](https://hatch.pypa.io/) bumps the version in `src/swgoh_comlink/version.py` -3. [git-changelog](https://pawamoy.github.io/git-changelog/) regenerates `CHANGELOG.md` from commit history -4. The new version is tagged and pushed -5. The package is built with `hatch build` and published to [PyPI](https://pypi.org/project/swgoh-comlink/) +1. The release workflow is triggered manually (`workflow_dispatch`) with a `bump` input (`patch`, `minor`, or `major`) +2. The next version is computed from the latest release tag and the chosen bump level +3. The package is built — [hatch-vcs](https://github.com/ofek/hatch-vcs) derives the version from that target tag, so **no version string is committed to the repo** +4. The distributions are published to [PyPI](https://pypi.org/project/swgoh-comlink/) via trusted publishing +5. Only after a successful publish is the Git tag created and a GitHub Release cut, with release notes auto-generated from the commit/PR history -This is why conventional commit messages matter — they become the release notes automatically. +Because the version comes from the Git tag (not a committed file), the release workflow never pushes to the protected `main` branch. This is why conventional commit messages matter — they become the auto-generated release notes. **Version scheme:** [Semantic Versioning](https://semver.org/) diff --git a/pyproject.toml b/pyproject.toml index 3e76733..b550d0e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["hatchling>=1.27.0"] +requires = ["hatchling>=1.27.0", "hatch-vcs>=0.4.0"] build-backend = "hatchling.build" [project] @@ -55,8 +55,16 @@ markers = [ "cat_stress: load endurance and connection pool tests", ] +# Version is derived from the Git tag (e.g. v2.1.0) at build time. +# No version string is committed to the repo — tag the release and the +# build reads it. See .github/workflows/release.yml. [tool.hatch.version] -path = 'src/swgoh_comlink/version.py' +source = "vcs" + +# Write the resolved version to a generated, git-ignored module so it is +# importable at runtime (consumed by src/swgoh_comlink/version.py). +[tool.hatch.build.hooks.vcs] +version-file = "src/swgoh_comlink/_version.py" [tool.hatch.build.targets.wheel] packages = ["src/swgoh_comlink"] @@ -150,29 +158,3 @@ exclude_lines = [ "if TYPE_CHECKING:", "if __name__ == .__main__.", ] - -# ── Semantic Release ───────────────────────────────────────────────────── - -[tool.semantic_release] -version_variable = [ - "src/swgoh_comlink/version.py" -] -version_pattern = "src/swgoh_comlink/version.py:__version__ = '{version}'" -version_source = "commit" -branch = "main" -changelog_file = "CHANGELOG.md" -build_command = "uv build" -dist_path = "dist/" -upload_to_release = true -upload_to_pypi = false -remove_dist = true -patch_without_tag = true - -[tool.semantic_release.branches.main] -match = "main" -prerelease = false - -[tool.semantic_release.branches.tools] -match = "tools" -prerelease = true -prerelease_token = "rc" diff --git a/src/swgoh_comlink/version.py b/src/swgoh_comlink/version.py index 9ec2c15..b8b9f2a 100644 --- a/src/swgoh_comlink/version.py +++ b/src/swgoh_comlink/version.py @@ -1,2 +1,25 @@ # coding=utf-8 -__version__ = "2.0.7" +"""Expose the installed package version as ``__version__``. + +The version is derived from the Git tag at build time via ``hatch-vcs`` +(see ``[tool.hatch.version]`` in ``pyproject.toml``). The build writes the +resolved value to the git-ignored ``_version.py``; we read it from there, +falling back to installed package metadata, then to a sentinel for an +unbuilt source tree. +""" + +from __future__ import annotations + +try: + # Generated at build time by hatch-vcs (git-ignored). + from swgoh_comlink._version import __version__ +except ImportError: # pragma: no cover - exercised only in unbuilt trees + from importlib.metadata import PackageNotFoundError + from importlib.metadata import version as _package_version + + try: + __version__ = _package_version("swgoh_comlink") + except PackageNotFoundError: + __version__ = "0.0.0+unknown" + +__all__ = ["__version__"]