diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index adf5631..67aa737 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,6 +4,10 @@ on: push: tags: ["v*"] workflow_dispatch: + inputs: + ref: + description: "tag to publish (e.g. v0.2.0a1)" + required: true permissions: contents: write @@ -13,9 +17,24 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.ref || github.ref }} - uses: astral-sh/setup-uv@v6 with: enable-cache: false + - name: Resolve tag + id: tag + run: echo "name=${{ github.event.inputs.ref || github.ref_name }}" >> "$GITHUB_OUTPUT" + - name: Check version consistency + run: python scripts/check_version.py "${{ steps.tag.outputs.name }}" + - name: Detect pre-release + id: rel + run: | + if [[ "${{ steps.tag.outputs.name }}" =~ ^v[0-9]+(\.[0-9]+)*[-.](a|b|rc|alpha|beta|preview|dev)([-.]?[0-9]+)?$ ]]; then + echo "prerelease=true" >> "$GITHUB_OUTPUT" + else + echo "prerelease=false" >> "$GITHUB_OUTPUT" + fi - name: Build wheel + sdist run: uv build - name: Publish to PyPI @@ -25,6 +44,7 @@ jobs: - name: Attach artifacts to release uses: softprops/action-gh-release@v2 with: + prerelease: ${{ steps.rel.outputs.prerelease }} files: | dist/*.whl dist/*.tar.gz diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..9d2bc71 --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,78 @@ +# Releasing + +urag publishes two kinds of releases to PyPI: + +- **Stable releases** (`vX.Y.Z`) — the default for `pip install urag-cli`. +- **Pre-releases** (`vX.Y.ZaN` / `vX.Y.ZrcN`) — opt-in for testers. PyPI + accepts them, but installers exclude pre-releases by default, so a + pre-release never shadows the latest stable version. + +Both flows use the same `publish` workflow (`.github/workflows/publish.yml`), +which builds the wheel + sdist, uploads them to PyPI, and attaches the +artifacts to a GitHub release (flagged "pre-release" when the tag contains +an `a`/`b`/`rc`/`dev` segment). The workflow verifies that the tag, the +`[project] version` in `pyproject.toml`, and `__version__` in +`src/urag/__init__.py` all agree, so a mismatch can never publish. + +## Prerequisites + +- `PYPI_TOKEN` secret (PyPI API token for the `urag-cli` project) set on the + repository. +- The branch/tag being published passes CI (tests + lint). + +## Stable release + +1. Merge the feature PRs into `main`. +2. Bump the version in **two places**: + - `pyproject.toml` → `[project] version` + - `src/urag/__init__.py` → `__version__` +3. Update `CHANGELOG.md`: rename the `## Unreleased` section to + `## X.Y.Z - YYYY-MM-DD`. +4. Commit (`chore(release): prepare X.Y.Z`), push, and tag: + + ```bash + git tag -a v0.2.0 -m "urag 0.2.0" + git push origin v0.2.0 + ``` + +5. The publish workflow runs on the tag, uploads to PyPI, and creates the + GitHub release. Verify with: + + ```bash + pip install urag-cli # resolves to the new stable + urag --version + ``` + +## Pre-release (based on a PR branch) + +Use when testers should try a pending change set (e.g. the latest PR) +before it is finalized: + +1. From the branch with the changes, bump the version to the next + pre-release of the upcoming release line, e.g. `0.2.0a1` (or `0.2.0rc1` + when the branch is believed final). Update both version locations and + the CHANGELOG header. +2. Commit and tag: + + ```bash + git tag -a v0.2.0a1 -m "urag 0.2.0a1 (pre-release)" + git push origin v0.2.0a1 + ``` + +3. The workflow publishes to PyPI and creates a GitHub **pre-release**. + Testers install it explicitly (pre-releases are never the default): + + ```bash + pip install urag-cli==0.2.0a1 + # or the latest pre-release of the project: + pip install --pre urag-cli + ``` + +4. When the branch merges into `main`, follow the stable flow with the + final version (e.g. `0.2.0`). + +## Manual re-publish + +The workflow also accepts a `workflow_dispatch` with a `ref` input (the tag +name). Use it to re-run a failed publish without re-tagging; the version +checks still apply. diff --git a/scripts/check_version.py b/scripts/check_version.py new file mode 100644 index 0000000..b897f6b --- /dev/null +++ b/scripts/check_version.py @@ -0,0 +1,35 @@ +"""Release consistency check: pyproject version, __init__ version, and tag. + +Usage: python scripts/check_version.py [vX.Y.Z] + +Exits non-zero (with a joined message) when: +- src/urag/__init__.py __version__ != pyproject [project].version +- the git tag (without leading 'v') != pyproject [project].version +""" + +import re +import sys +import tomllib +from pathlib import Path + +root = Path(__file__).resolve().parents[1] +data = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8")) +pyproject_version = data["project"]["version"] + +init = (root / "src" / "urag" / "__init__.py").read_text(encoding="utf-8") +match = re.search(r'__version__\s*=\s*"([^"]+)"', init) +init_version = match.group(1) if match else None + +errors: list[str] = [] +if init_version != pyproject_version: + errors.append( + f"src/urag/__init__.py __version__={init_version!r} does not match " + f"pyproject version {pyproject_version!r}" + ) +tag = sys.argv[1] if len(sys.argv) > 1 else "" +if tag.startswith("v") and tag[1:] != pyproject_version: + errors.append(f"tag {tag!r} does not match pyproject version {pyproject_version!r}") + +if errors: + raise SystemExit("\n".join(errors)) +print(f"version ok: {pyproject_version}")