Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 17 additions & 15 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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@<sha> # 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] - <today>` 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 <version>`, tags `<version>`, 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] - <today>`,
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.

Expand Down
10 changes: 8 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
name: ci
name: CI

on:
workflow_call:
push:
branches: [main]
pull_request:
Expand All @@ -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/
33 changes: 33 additions & 0 deletions .github/workflows/pre-release.yml
Original file line number Diff line number Diff line change
@@ -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 }}
61 changes: 40 additions & 21 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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 }}
8 changes: 8 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
118 changes: 97 additions & 21 deletions scripts/release.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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.

Expand Down Expand Up @@ -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)
Expand All @@ -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)
Loading