diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 32a555e..9bf30dd 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -41,21 +41,23 @@ No runtime dependencies - keep it that way. `uv sync --locked`, `uv run poe ci`, `uv build`. Keep `uv.lock` committed and in sync. - Actions are pinned by commit SHA with the version in a trailing comment (`uses: actions/checkout@ # v7.0.1`); bump both together. -- Cut a release from a clean `main` with: - `RELEASE_VERSION=X.Y.Z NEXT_VERSION=X.Y.Z-dev uv run poe release` - (`scripts/release.py`, runs `git` and `uv` through `proclet` itself, both env vars required, versions - picked by the caller). `pyproject.toml`'s `version` is `X.Y.Z.dev0` between releases - (`uv version` normalizes `-dev`); `RELEASE_VERSION` is that without the dev suffix, - `NEXT_VERSION` the next patch + `-dev`. -- The script refuses an empty `## [Unreleased]` changelog section, then moves its entries - under `## [X.Y.Z] - ` and updates the compare links, sets the version with - `uv version` (keeps `uv.lock` in sync, which `uv sync --locked` needs), `git add -A`, - signed-off `--no-verify` commit `release: proclet `, tags ``, sets - `NEXT_VERSION`, commits `chore: prepare for next iteration`, then pushes `main` and the - tag to `origin` - no dry-run or confirmation step. -- The tag push runs `.github/workflows/release.yml`: fails if the tag != `uv version --short`, - runs CI, builds, then publishes via PyPI trusted publishing (OIDC, GitHub environment - `pypi`, no API token). +- Releases are cut by manually dispatching two workflows on `main`, each running a + `poe` task of `scripts/release.py` (runs `git`, `uv` and `gh` through `proclet` itself): + 1. `pre-release.yml` (input: `X.Y.Z`) runs `release:pre`: refuses an empty + `## [Unreleased]` changelog section, moves its entries under `## [X.Y.Z] - `, + updates the compare links, sets the version with `uv version` (keeps `uv.lock` in + sync, which `uv sync --locked` needs) and opens a PR from `feat/pre-release-vX.Y.Z`. + Merge it before going on. + 2. `release.yml` (input: next version, `X.Y.Z-dev`) runs CI (`ci.yml` through + `workflow_call`, its ubuntu/3.12 job uploads the `dist` artifact), `release` tags the + project version and pushes the tag (refuses a dev version), publishes `dist` via PyPI + trusted publishing (OIDC, GitHub environment `pypi`, no API token; the job must stay + in `release.yml`, the publisher is bound to that file), then `release:post` opens a PR + from `feat/post-release-vX.Y.Z-dev` setting the next version. Merge it too. +- `pyproject.toml`'s `version` is `X.Y.Z.dev0` between releases (`uv version` normalizes + `-dev`). +- PRs opened with the workflow `GITHUB_TOKEN` don't trigger `ci.yml`; `release.yml` runs + CI itself before publishing. - `CHANGELOG.md` (Keep a Changelog): add user-facing changes under `## [Unreleased]` as they land; the release script stamps the version and date. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a69a31..b0de78e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,7 @@ -name: ci +name: CI on: + workflow_call: push: branches: [main] pull_request: @@ -19,9 +20,14 @@ jobs: python: ["3.12", "3.13"] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + - uses: techcode-io/github-actions/setup-uv@f1054ff1cbac66b5acdc7c4d0d1d38f18a3a6030 # setup-uv/v1 with: python-version: ${{ matrix.python }} - run: uv sync --locked - run: uv run poe ci - run: uv build + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + if: matrix.os == 'ubuntu-latest' && matrix.python == '3.12' + with: + name: dist + path: dist/ diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml new file mode 100644 index 0000000..9e11474 --- /dev/null +++ b/.github/workflows/pre-release.yml @@ -0,0 +1,33 @@ +name: Pre Release + +on: + workflow_dispatch: + inputs: + version: + description: "The version to release (e.g. 1.2.3). Opens a pull request stamping the changelog and bumping the version" + required: true + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }} + +jobs: + pre-release: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + - uses: techcode-io/github-actions/setup-github-bot@cd725590b5db9dd4b9855453f3fc83f66e882abf # setup-github-bot/v1 + - uses: techcode-io/github-actions/setup-uv@f1054ff1cbac66b5acdc7c4d0d1d38f18a3a6030 # setup-uv/v1 + - run: uv sync --locked + - run: uv run poe release:pre + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ inputs.version }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c8ea06b..ed4579a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,40 +1,41 @@ -name: release +name: Release on: - push: - tags: - - "[0-9]+.[0-9]+.[0-9]+" + workflow_dispatch: + inputs: + version: + description: "The next iteration version (e.g. 1.2.4-dev). Releases the version on main, then opens a pull request bumping to this one" + required: true permissions: contents: read +concurrency: + group: ${{ github.workflow }} + jobs: - build: + ci: + if: github.ref == 'refs/heads/main' + uses: ./.github/workflows/ci.yml + + release: + needs: ci runs-on: ubuntu-latest + permissions: + contents: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 - - name: Check tag matches project version - run: | - version=$(uv version --short) - if [ "$version" != "$GITHUB_REF_NAME" ]; then - echo "Tag $GITHUB_REF_NAME doesn't match project version $version" >&2 - exit 1 - fi + - uses: techcode-io/github-actions/setup-uv@f1054ff1cbac66b5acdc7c4d0d1d38f18a3a6030 # setup-uv/v1 - run: uv sync --locked - - run: uv run poe ci - - run: uv build - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: dist - path: dist/ + - run: uv run poe release + # Trusted publishing doesn't support reusable workflows: this job must stay here. publish: - needs: build + needs: release runs-on: ubuntu-latest environment: name: pypi - url: https://pypi.org/project/proclet/${{ github.ref_name }}/ + url: https://pypi.org/project/proclet/ permissions: id-token: write steps: @@ -43,3 +44,21 @@ jobs: name: dist path: dist/ - uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3 + + post-release: + needs: publish + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + - uses: techcode-io/github-actions/setup-github-bot@cd725590b5db9dd4b9855453f3fc83f66e882abf # setup-github-bot/v1 + - uses: techcode-io/github-actions/setup-uv@f1054ff1cbac66b5acdc7c4d0d1d38f18a3a6030 # setup-uv/v1 + - run: uv sync --locked + - run: uv run poe release:post + env: + GH_TOKEN: ${{ github.token }} + NEXT_VERSION: ${{ inputs.version }} diff --git a/pyproject.toml b/pyproject.toml index 9e4dd1b..45621fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,14 @@ asyncio_mode = "auto" help = "Create a new proclet release." script = "scripts.release:run" +[tool.poe.tasks."release:pre"] +help = "Create a PR with changes for release." +script = "scripts.release:pre" + +[tool.poe.tasks."release:post"] +help = "Create a PR preparing the next iteration." +script = "scripts.release:post" + [tool.poe.tasks.ci] sequence = [ "lint", diff --git a/scripts/release.py b/scripts/release.py index 317780c..dd3f4e1 100644 --- a/scripts/release.py +++ b/scripts/release.py @@ -1,13 +1,15 @@ -"""Release proclet: stamp the changelog, tag the release version and push it. +"""Release proclet, one task per step of the release workflows. -Run with `RELEASE_VERSION=X.Y.Z NEXT_VERSION=X.Y.Z-dev uv run poe release`; the tag -push triggers the workflow publishing to PyPI. +`pre` opens a pull request stamping the changelog and setting the release version, +`run` tags the version on `main`, and `post` opens a pull request setting the next +iteration version. Publishing to PyPI is left to the `release` workflow. """ import datetime import os import re import sys +from io import StringIO from pathlib import Path from typing import Final @@ -29,6 +31,51 @@ def _git(*args: str) -> None: Command("git").args(*args).cwd(ROOT_PATH).inherit().exec() +def _create_pull_request(branch: str) -> None: + """Open a pull request from `branch` against `main`, filled from its commits. + + Args: + branch: the branch to open the pull request from. + Raises: + ErrorReturnCode: if gh fails. + """ + Command("gh").args("pr", "create", "--fill", "--base=main", f"--head={branch}").cwd( + ROOT_PATH + ).inherit().exec() + + +def _project_version() -> str: + """Read the project version. + + Returns: + The version, as normalized by uv. + Raises: + ErrorReturnCode: if uv fails. + """ + out = StringIO() + Command("uv").args("version", "--short").cwd(ROOT_PATH).stdout(out).exec() + return out.getvalue().strip() + + +def _version_from_env(name: str, pattern: str) -> str: + """Read a version from the environment. + + Args: + name: the environment variable holding the version. + pattern: the regex the whole version must match. + Returns: + The version. + Raises: + SystemExit: if the variable is missing or doesn't match `pattern`. + """ + version = os.getenv(name) + if not version: + sys.exit(f"You must define `{name}` environment variable") + if not re.fullmatch(pattern, version): + sys.exit(f"`{name}` must match `{pattern}`, got `{version}`") + return version + + def _set_version(version: str) -> None: """Set the project version. @@ -84,26 +131,18 @@ def _stamp_changelog(version: str) -> None: CHANGELOG_PATH.write_text(data) -def run() -> None: - """Cut a release from `main` and push it, using the versions from the environment. - - Commits and tags `RELEASE_VERSION`, then commits `NEXT_VERSION` and pushes - `main` and the tag to `origin`. +def pre() -> None: + """Open a pull request preparing the release of `RELEASE_VERSION`. Raises: - SystemExit: if an env var is missing or the changelog has no entry. - ErrorReturnCode: if git or uv fails. + SystemExit: if the env var is invalid or the changelog has no entry. + ErrorReturnCode: if git, uv or gh fails. """ - # Ensure we have inputs - release_version = os.getenv("RELEASE_VERSION") - next_version = os.getenv("NEXT_VERSION") - if not release_version: - sys.exit("You must define `RELEASE_VERSION` environment variable") - if not next_version: - sys.exit("You must define `NEXT_VERSION` environment variable") + release_version = _version_from_env("RELEASE_VERSION", r"\d+\.\d+\.\d+") - # Switch to main branch - _git("checkout", "main") + # Create a new branch + branch = f"feat/pre-release-v{release_version}" + _git("checkout", "-b", branch) # Update all files _stamp_changelog(release_version) @@ -112,11 +151,48 @@ def run() -> None: # Push changes _git("add", "-A") _git("commit", "-s", "--no-verify", "-m", f"release: proclet {release_version}") + _git("push", "origin", branch) + + # Create a PR + _create_pull_request(branch) + + +def run() -> None: + """Tag the project version and push the tag to `origin`. + + Raises: + SystemExit: if the project version isn't a release version, which means the + pull request opened by `pre` isn't merged. + ErrorReturnCode: if git or uv fails, including when the tag already exists. + """ + release_version = _project_version() + if not re.fullmatch(r"\d+\.\d+\.\d+", release_version): + sys.exit(f"`{release_version}` isn't a release version: run `pre` first") + _git("tag", release_version) + _git("push", "origin", release_version) + + +def post() -> None: + """Open a pull request preparing the iteration of `NEXT_VERSION`. + + Raises: + SystemExit: if the env var is invalid. + ErrorReturnCode: if git, uv or gh fails. + """ + next_version = _version_from_env("NEXT_VERSION", r"\d+\.\d+\.\d+-dev") + + # Create a new branch + branch = f"feat/post-release-v{next_version}" + _git("checkout", "-b", branch) # Update all files _set_version(next_version) + + # Push changes _git("add", "-A") _git("commit", "-s", "--no-verify", "-m", "chore: prepare for next iteration") - _git("push", "origin", "main") - _git("push", "origin", release_version) + _git("push", "origin", branch) + + # Create a PR + _create_pull_request(branch)