From 0976f1e3890637ff20ee26123c453e884525d66a Mon Sep 17 00:00:00 2001 From: jasper blues Date: Wed, 28 Oct 2026 06:26:24 +1000 Subject: [PATCH 1/5] Release a version bump when it merges to main, and add a Prepare release workflow that opens the bump PR. A push to main that changes pyproject.toml now runs release.yml, which releases the version when its tag doesn't exist yet and it is greater than the newest vX.Y.Z tag by semver, and tags the merge commit with the GitHub release. A pushed tag still releases, as the fallback. Prepare release bumps pyproject.toml and uv.lock on release/vX.Y.Z and opens "Release vX.Y.Z". scripts/release_version.py does the semver arithmetic both use. Follow-up to #158. --- .github/workflows/prepare-release.yml | 167 ++++++++++++++++++++++++++ .github/workflows/release.yml | 50 ++++++-- RELEASING.md | 74 ++++++++---- scripts/release_version.py | 109 +++++++++++++++++ tests/unit/test_release_version.py | 70 +++++++++++ 5 files changed, 442 insertions(+), 28 deletions(-) create mode 100644 .github/workflows/prepare-release.yml create mode 100644 scripts/release_version.py create mode 100644 tests/unit/test_release_version.py diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 0000000..526de5a --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,167 @@ +# Opens the release PR: "Release vX.Y.Z", which raises pyproject.toml's version (and uv.lock's record of it) on a +# branch release/vX.Y.Z. Merging it releases the version: release.yml runs on the push to main. RELEASING.md +# describes the process. +# +# Run it from the Actions tab (Prepare release, Run workflow), or through the API, as the train controller in +# liberation-data/drivine-release does: +# +# gh workflow run prepare-release.yml -R liberation-data/drivine-python -f bump=minor +# +# A PR opened with GITHUB_TOKEN starts no workflow, so CI doesn't run on it by itself. With a RELEASE_PR_TOKEN secret +# set (a token that can push and open pull requests here), the PR is opened with that, and CI runs. +name: Prepare release + +on: + workflow_dispatch: + inputs: + bump: + description: Which part of the version to raise. A prerelease is X.Y.Z-rc.N. + type: choice + options: [patch, minor, major, prerelease] + default: patch + version: + description: An exact version, X.Y.Z or X.Y.Z-suffix, used instead of bump. + type: string + required: false + default: "" + workflow_call: + inputs: + bump: + description: Which part of the version to raise, patch, minor, major or prerelease. + type: string + required: false + default: patch + version: + description: An exact version, X.Y.Z or X.Y.Z-suffix, used instead of bump. + type: string + required: false + default: "" + secrets: + RELEASE_PR_TOKEN: + required: false + +permissions: + contents: read + +concurrency: + group: prepare-release + cancel-in-progress: false + +env: + # Named rather than read from the run's context: a workflow_call from another repository runs in that repository's. + REPO: liberation-data/drivine-python + DEFAULT_BRANCH: main + +jobs: + prepare: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Has a token for this repository + env: + HAS_PR_TOKEN: ${{ secrets.RELEASE_PR_TOKEN != '' }} + run: | + if [[ "$GITHUB_REPOSITORY" != "$REPO" && "$HAS_PR_TOKEN" != true ]]; then + echo "::error::Called from $GITHUB_REPOSITORY, whose GITHUB_TOKEN can't push to $REPO. Pass a RELEASE_PR_TOKEN secret that can." + exit 1 + fi + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ env.REPO }} + ref: ${{ env.DEFAULT_BRANCH }} + fetch-depth: 0 + token: ${{ secrets.RELEASE_PR_TOKEN || github.token }} + - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + with: + python-version: "3.14" + enable-cache: false + # The new version: the exact one asked for, or pyproject.toml's raised by semver. It must be greater than the + # newest vX.Y.Z tag, and neither its tag nor its branch may exist yet. + - name: Work out the version + id: version + env: + BUMP: ${{ inputs.bump }} + EXACT: ${{ inputs.version }} + run: | + current=$(python3 -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])') + if [[ -n "$EXACT" ]]; then + version="${EXACT#v}" + else + version=$(python3 scripts/release_version.py bump "$current" "$BUMP") + fi + tag="v$version" + branch="release/$tag" + if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "::error::$version isn't a version X.Y.Z (or X.Y.Z-suffix)." + exit 1 + fi + if git rev-parse -q --verify "refs/tags/$tag" > /dev/null; then + echo "::error::The tag $tag already exists. Ask for a later version." + exit 1 + fi + newest=$(git tag -l 'v*' | python3 scripts/release_version.py newest) + if [[ -n "$newest" ]] && ! python3 scripts/release_version.py greater "$version" "${newest#v}"; then + echo "::error::$version isn't greater than the newest tag, $newest, so merging it would release nothing. Ask for a later version." + exit 1 + fi + if git ls-remote --exit-code --heads origin "refs/heads/$branch" > /dev/null; then + echo "::error::The branch $branch already exists. Merge or close its PR and delete the branch, or ask for another version." + exit 1 + fi + echo "pyproject.toml's version $current becomes $version, on $branch" + { + echo "version=$version" + echo "tag=$tag" + echo "branch=$branch" + echo "since=$newest" + echo "current=$current" + } >> "$GITHUB_OUTPUT" + # uv lock writes the project's own version into uv.lock, as PEP 440 normalizes it, and changes nothing else. + - name: Raise the version + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + python3 scripts/release_version.py set pyproject.toml "$VERSION" + uv lock + git diff --stat + - name: Push the branch and open the PR + env: + GH_TOKEN: ${{ secrets.RELEASE_PR_TOKEN || github.token }} + HAS_PR_TOKEN: ${{ secrets.RELEASE_PR_TOKEN != '' }} + VERSION: ${{ steps.version.outputs.version }} + TAG: ${{ steps.version.outputs.tag }} + BRANCH: ${{ steps.version.outputs.branch }} + SINCE: ${{ steps.version.outputs.since }} + CURRENT: ${{ steps.version.outputs.current }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git switch -c "$BRANCH" + git add pyproject.toml uv.lock + git commit -m "Release $TAG" + git push origin "refs/heads/$BRANCH" + body="$RUNNER_TEMP/release-pr.md" + { + echo "Bumps the version from $CURRENT to $VERSION. Merging this PR releases $TAG: release.yml publishes it to PyPI and creates the GitHub release. See RELEASING.md." + echo + if [[ -n "$SINCE" ]]; then + echo "Commits since $SINCE:" + echo + git log --oneline --no-decorate "$SINCE..origin/$DEFAULT_BRANCH" | sed 's/^/- /' + else + echo "There is no earlier vX.Y.Z tag." + fi + if [[ "$HAS_PR_TOKEN" != true ]]; then + echo + echo "This PR was opened with GITHUB_TOKEN, so CI hasn't started on it. Push an empty commit to the branch, or close and reopen the PR, to start it." + fi + } > "$body" + url=$(gh pr create --repo "$REPO" --base "$DEFAULT_BRANCH" --head "$BRANCH" --title "Release $TAG" --body-file "$body") + echo "Opened $url" | tee -a "$GITHUB_STEP_SUMMARY" + if [[ "$HAS_PR_TOKEN" != true ]]; then + note="Opened with GITHUB_TOKEN, so CI doesn't run on it by itself. Push an empty commit to $BRANCH, or close and reopen the PR, to start it." + echo "::notice::$note" + echo "$note" >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2e0d303..f7cf89e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,14 +1,21 @@ -# Releases drivine to PyPI from a tag. RELEASING.md describes the process. +# Releases drivine to PyPI. RELEASING.md describes the process. # -# - A push of a tag vX.Y.Z publishes: the tag is checked, the full CI runs, the distributions are built and checked, -# and those exact files are uploaded to PyPI with trusted publishing. +# - A push to main that raises pyproject.toml's version (the merge of a "Release vX.Y.Z" PR) publishes it, and tags +# the commit vX.Y.Z. A push to main that changes pyproject.toml without raising the version publishes nothing. +# - A push of a tag vX.Y.Z, by hand, publishes too: the fallback. +# - Either way, the full CI runs, the distributions are built and checked, and those exact files are uploaded to PyPI +# with trusted publishing. # - workflow_dispatch, and a pull request that changes the release machinery, are dry runs: everything but the # publish, with the built files left as the run's artifact to inspect. name: Release on: push: + branches: [main] tags: ["v*"] + # A push to main runs this only when it changes the version's file. GitHub doesn't apply paths to a tag push. + paths: + - pyproject.toml workflow_dispatch: pull_request: paths: @@ -16,6 +23,7 @@ on: - RELEASING.md - pyproject.toml - scripts/smoke_wheel.py + - scripts/release_version.py permissions: contents: read @@ -30,6 +38,7 @@ jobs: outputs: version: ${{ steps.version.outputs.version }} publish: ${{ steps.version.outputs.publish }} + tag: ${{ steps.version.outputs.tag }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -56,19 +65,40 @@ jobs: exit 1 fi publish=true + elif [[ "$GITHUB_EVENT_NAME" == "push" ]]; then + # A push to main that changed pyproject.toml. It releases the version when that is a new one, greater than + # the newest vX.Y.Z tag; github-release then makes the tag. + version="$declared" + tag="v$version" + newest=$(git tag -l 'v*' | python3 scripts/release_version.py newest) + publish=false + if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "::notice::pyproject.toml's version $version isn't X.Y.Z (or X.Y.Z-suffix), so this push releases nothing." + elif git rev-parse -q --verify "refs/tags/$tag" > /dev/null; then + echo "::notice::The tag $tag already exists, so this push, which changed pyproject.toml but not to a new version, releases nothing." + elif [[ -n "$newest" ]] && ! python3 scripts/release_version.py greater "$version" "${newest#v}"; then + echo "::notice::pyproject.toml's version $version isn't greater than the newest tag, $newest, so this push releases nothing." + else + publish=true + fi else version="$declared" + tag="v$version" publish=false fi echo "Version $version, publish: $publish" echo "version=$version" >> "$GITHUB_OUTPUT" echo "publish=$publish" >> "$GITHUB_OUTPUT" + echo "tag=$tag" >> "$GITHUB_OUTPUT" - # The full CI, as ci.yml runs it on a push or a pull request. + # The full CI, as ci.yml runs it on a push or a pull request. A push that releases nothing stops at check: ci.yml + # runs on the same push to main by itself. gate: needs: check + if: needs.check.outputs.publish == 'true' || github.event_name != 'push' uses: ./.github/workflows/ci.yml + # A push that releases nothing skips gate, and so this and the jobs after it. build: needs: [check, gate] runs-on: ubuntu-latest @@ -96,7 +126,7 @@ jobs: status=$(curl -sL --retry 3 -o /dev/null -w '%{http_code}' "$url" || true) if [[ "$status" == 200 ]]; then if [[ "$PUBLISH" == true ]]; then - echo "::error::PyPI already has drivine $normalized." + echo "::error::PyPI already has drivine $normalized, so this run publishes nothing. If merging its version bump released it, and this is a tag pushed for it by hand afterwards, there is nothing left to do. Otherwise bump the version in a release PR." exit 1 fi echo "::warning::PyPI already has drivine $normalized. This dry run goes on, but a tag of it would fail." @@ -150,12 +180,18 @@ jobs: permissions: contents: write steps: + # On a push to main, the tag doesn't exist yet: the release makes it, on the commit this run built. A tag made + # with GITHUB_TOKEN starts no workflow, so it doesn't run this again. A tag pushed by hand is already there. - name: Create the GitHub release env: GH_TOKEN: ${{ github.token }} - TAG: ${{ github.ref_name }} + TAG: ${{ needs.check.outputs.tag }} VERSION: ${{ needs.check.outputs.version }} run: | prerelease=() if [[ "$VERSION" == *-* ]]; then prerelease=(--prerelease); fi - gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --generate-notes --verify-tag "${prerelease[@]}" + if [[ "$GITHUB_REF" == refs/tags/* ]]; then + gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --generate-notes --verify-tag "${prerelease[@]}" + else + gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --target "$GITHUB_SHA" --generate-notes "${prerelease[@]}" + fi diff --git a/RELEASING.md b/RELEASING.md index e5b9633..77e0e2a 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,19 +1,32 @@ # Releasing -drivine-python, drivine4j and drivine are released the same way, from a tag, by CI. Each repository has this file, -differing only in its publish step. +drivine-python, drivine4j and drivine are released the same way, by CI. Each repository has this file, differing +only in its publish step. Each library keeps its own version. A release train, named by date ("Drivine 2026.11"), +records which versions of the three, and which Cypher spec tag, ship together. The train controller, +[liberation-data/drivine-release](https://github.com/liberation-data/drivine-release), runs the steps below in all +three repositories at once. ## The process -1. **Release PR.** A PR bumps the version (build.gradle / package.json / pyproject.toml) and the changelog, if the - repo has one. It is reviewed and passes CI like any other PR. -2. **Tag the merge commit.** Merging the release PR is the release point. The maintainer then tags the merge commit - on the default branch `vX.Y.Z` and pushes the tag. -3. **CI publishes.** The tag push runs `.github/workflows/release.yml`, which checks out the tag, runs the full gate, - builds, checks, and publishes. Nothing is built from a laptop. +1. **Prepare release.** In the Actions tab, run "Prepare release" (`.github/workflows/prepare-release.yml`) with a + `bump` of `patch`, `minor`, `major` or `prerelease`, or an exact `version`. It bumps the version + (build.gradle / package.json / pyproject.toml) on a branch `release/vX.Y.Z`, and opens a PR "Release vX.Y.Z" that + lists the commits since the last tag. +2. **Review and merge the release PR.** It passes CI like any other PR. Merging it is the release point. +3. **CI publishes.** The merge, a push to the default branch that changes the version file, runs + `.github/workflows/release.yml`. It runs the full gate, builds, checks, publishes, and creates the GitHub release + with its tag `vX.Y.Z` on the merge commit. Nothing is built from a laptop. 4. **Credentials.** Repository secrets or trusted publishing (OIDC), held in a GitHub environment named `release`. + Give that environment required reviewers: now that merging publishes, their approval of the run is the human + checkpoint before anything goes out. -For step 2: +A PR that "Prepare release" opens with the default `GITHUB_TOKEN` starts no workflows, so CI doesn't run on it by +itself. Push an empty commit to its branch, or close and reopen it, to start CI. With a `RELEASE_PR_TOKEN` secret set +(below), the PR is opened with that token and CI runs. "Prepare release" fails, saying why, when the version is +already tagged, isn't later than the last release, or its `release/vX.Y.Z` branch already exists. + +**Tagging by hand is the fallback.** A pushed tag `vX.Y.Z` still releases the tagged commit. Use it when a +merge didn't release, for example when the bump was merged before the release machinery was in place: ``` git switch main && git pull @@ -24,26 +37,40 @@ git push origin vX.Y.Z ## What release.yml does - **`check`** works out the version: the tag without its `v` on a tag push, or the build file's version otherwise. - On a tag push it fails, saying why, unless the tag is `vX.Y.Z` (or `vX.Y.Z-suffix`), its version equals the build - file's, and the tagged commit is on the default branch. -- **`gate`** runs the repository's full CI, `ci.yml`, unchanged. + - On a push to the default branch that changes the version file, it releases when the version is new: no tag + `vX.Y.Z` exists yet, and the version is later than every `vX.Y.Z` tag (a prerelease `vX.Y.Z-rc.N` sorts below + `vX.Y.Z`). Otherwise the run releases nothing, and its notice says why: a change to the file that isn't a version + bump, for example. + - On a tag push it fails, saying why, unless the tag is `vX.Y.Z` (or `vX.Y.Z-suffix`), its version equals the build + file's, and the tagged commit is on the default branch. +- **`gate`** runs the repository's full CI, `ci.yml`, unchanged. A push to the default branch that releases nothing + skips it, as `ci.yml` runs on that push anyway. - **`build`** builds the release artifacts from the checked-out ref, checks them, runs a smoke test, and uploads them as the run's artifact. -- **`publish`** runs on a tag push alone, in the `release` environment. It publishes the exact files `build` made. -- **`github-release`** then creates the GitHub release for the tag, with generated notes, marked a prerelease when - the version has a `-suffix`. +- **`publish`** runs on a release alone, in the `release` environment. It publishes the exact files `build` made. +- **`github-release`** then creates the GitHub release, with generated notes, marked a prerelease when the version has + a `-suffix`. On a push to the default branch it also creates the tag `vX.Y.Z`, on the commit that was released. A + tag made this way starts no workflow, so it doesn't release again. A tag that fails `check` publishes nothing. Delete it (`git push origin :refs/tags/vX.Y.Z`), fix what the message -says, and tag again. +says, and tag again. A tag pushed by hand for a version that merging has already released fails in `build`, as the +registry already has that version; nothing is published twice. If a release failed after publishing, re-run its +failed jobs. A **dry run** is everything but `publish` and `github-release`. Run it from the Actions tab (Release, Run workflow, -on any branch or tag), and it runs by itself on a pull request that changes the release machinery. A tag push is the -only thing that publishes. +on any branch or tag), and it runs by itself on a pull request that changes the release machinery, a release PR +included. Only a merged version bump or a pushed tag publishes. ## drivine-python: PyPI - **Version:** `version` in `pyproject.toml`. The tag must be `v` and that version, character for character: `v0.3.0` - for `0.3.0`. There is no `__version__` to keep in step. + for `0.3.0`. There is no `__version__` to keep in step. `uv.lock` records the project's version too; Prepare release + updates it with `uv lock`. +- **Push trigger:** a push to `main` that changes `pyproject.toml`. +- **Version arithmetic:** `scripts/release_version.py`, which `check` uses too, to compare a version with + the newest tag. A `prerelease` bump gives `X.Y.Z-rc.N`: `0.3.0` becomes `0.3.1-rc.0`, and `0.3.1-rc.0` becomes + `0.3.1-rc.1`. For a release candidate of a minor or major version, give the exact `version`, for example + `0.4.0-rc.1`. - **Prereleases:** write the version in `pyproject.toml` as the tag has it, with a `-`: `0.3.0-rc.1`, tagged `v0.3.0-rc.1`. PEP 440 normalizes it, so the distributions and PyPI call it `0.3.0rc1`, and pip installs it only when asked (`pip install drivine==0.3.0rc1`, or `--pre`). `0.3.0rc1` in `pyproject.toml` can't be tagged: `check` @@ -69,7 +96,12 @@ python scripts/smoke_wheel.py dist/*.whl ### Settings, made once -- **GitHub:** Settings, Environments, a `release` environment. It can carry required reviewers, and a deployment tag - rule `v*`. +- **GitHub:** Settings, Environments, a `release` environment, with required reviewers. Its deployment branches and + tags, if limited, must allow both the branch `main` (a merged bump) and the tags `v*` (a tag pushed by hand). +- **GitHub Actions and PRs:** Settings, Actions, General, "Allow GitHub Actions to create and approve pull requests", + so "Prepare release" can open its PR with `GITHUB_TOKEN`. +- **Optional, a `RELEASE_PR_TOKEN` secret:** a fine-grained personal access token with Contents and Pull requests + read and write on this repository. "Prepare release" pushes its branch and opens its PR with it, so CI runs + on the PR. The train controller passes its own token the same way. - **PyPI:** the `drivine` project's Publishing settings, a GitHub trusted publisher with owner `liberation-data`, repository `drivine-python`, workflow `release.yml`, and environment `release`. diff --git a/scripts/release_version.py b/scripts/release_version.py new file mode 100644 index 0000000..b37fd4b --- /dev/null +++ b/scripts/release_version.py @@ -0,0 +1,109 @@ +""" +The version arithmetic the release workflows do, on versions written as the tags have them: semantic versions, +`0.3.0` or `0.3.0-rc.1`, ordered by semver precedence, so that a prerelease comes before its release. (`sort -V` +puts `0.3.0-rc.1` after `0.3.0`, and PEP 440's order needs the versions normalized first.) + + python scripts/release_version.py bump 0.3.0 minor # prints 0.4.0 + git tag -l | python scripts/release_version.py newest # prints the newest vX.Y.Z tag, or nothing + python scripts/release_version.py greater 0.4.0 0.3.0 # exits 0 when the first is the greater, 1 when not + python scripts/release_version.py set pyproject.toml 0.4.0 + +A bump works as npm's semver does, with `rc` for a prerelease: `patch`, `minor` and `major` of a prerelease give +the release it leads to (`0.4.0-rc.1` minor is `0.4.0`), and `prerelease` counts on a prerelease (`0.4.0-rc.1` to +`0.4.0-rc.2`) or starts the next patch's (`0.3.0` to `0.3.1-rc.0`). + +It reads the standard library alone, so nothing is installed for it. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +SEMVER = re.compile(r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-([0-9A-Za-z.-]+))?$") +TAG = re.compile(r"^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$") +PROJECT_VERSION = re.compile(r'^version = "[^"]*"$', re.MULTILINE) + + +class Version: + def __init__(self, text: str) -> None: + match = SEMVER.match(text) + if match is None or (match.group(4) is not None and "" in match.group(4).split(".")): + raise ValueError(f"{text} isn't a version X.Y.Z or X.Y.Z-suffix") + self.major, self.minor, self.patch = (int(match.group(n)) for n in (1, 2, 3)) + self.prerelease: list[str] = match.group(4).split(".") if match.group(4) else [] + + def __str__(self) -> str: + release = f"{self.major}.{self.minor}.{self.patch}" + return f"{release}-{'.'.join(self.prerelease)}" if self.prerelease else release + + def key(self) -> tuple[int, int, int, int, list[tuple[int, int, str]]]: + # A release sorts after all of its prereleases. Within a prerelease, a numeric identifier sorts before an + # alphanumeric one, numbers by value and words by ASCII, and a shorter list before a longer one it begins. + identifiers = [(0, int(i), "") if i.isdigit() else (1, 0, i) for i in self.prerelease] + return (self.major, self.minor, self.patch, 0 if self.prerelease else 1, identifiers) + + +def bump(version: Version, kind: str) -> Version: + major, minor, patch, prerelease = version.major, version.minor, version.patch, version.prerelease + if kind == "major": + if not (prerelease and minor == 0 and patch == 0): + major, minor, patch = major + 1, 0, 0 + prerelease = [] + elif kind == "minor": + if not (prerelease and patch == 0): + minor, patch = minor + 1, 0 + prerelease = [] + elif kind == "patch": + if not prerelease: + patch += 1 + prerelease = [] + elif kind == "prerelease": + if not prerelease: + patch, prerelease = patch + 1, ["rc", "0"] + elif prerelease[-1].isdigit(): + prerelease = [*prerelease[:-1], str(int(prerelease[-1]) + 1)] + else: + prerelease = [*prerelease, "0"] + else: + raise ValueError(f"{kind} isn't patch, minor, major or prerelease") + return Version(f"{major}.{minor}.{patch}" + (f"-{'.'.join(prerelease)}" if prerelease else "")) + + +def newest(tags: list[str]) -> str | None: + versions = [Version(tag[1:]) for tag in tags if TAG.match(tag) and SEMVER.match(tag[1:])] + return f"v{max(versions, key=Version.key)}" if versions else None + + +def set_version(pyproject: Path, version: Version) -> None: + text = pyproject.read_text() + if len(PROJECT_VERSION.findall(text)) != 1: + raise ValueError(f'{pyproject} doesn\'t have one line version = "..."') + pyproject.write_text(PROJECT_VERSION.sub(f'version = "{version}"', text)) + + +def main(args: list[str]) -> int: + try: + match args: + case ["bump", version, kind]: + print(bump(Version(version), kind)) + case ["newest"]: + found = newest(sys.stdin.read().split()) + if found: + print(found) + case ["greater", first, second]: + return 0 if Version(first).key() > Version(second).key() else 1 + case ["set", pyproject, version]: + set_version(Path(pyproject), Version(version)) + case _: + print(__doc__, file=sys.stderr) + return 2 + except ValueError as error: + print(error, file=sys.stderr) + return 2 + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tests/unit/test_release_version.py b/tests/unit/test_release_version.py new file mode 100644 index 0000000..66288fa --- /dev/null +++ b/tests/unit/test_release_version.py @@ -0,0 +1,70 @@ +"""scripts/release_version.py: the semver arithmetic the release workflows do. A prerelease comes before its release, +which `sort -V` gets the wrong way round.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import pytest + +_path = Path(__file__).parents[2] / "scripts" / "release_version.py" +_spec = importlib.util.spec_from_file_location("release_version", _path) +assert _spec is not None and _spec.loader is not None +release_version = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(release_version) + + +@pytest.mark.parametrize( + ("version", "kind", "expected"), + [ + ("0.2.0", "patch", "0.2.1"), + ("0.2.0", "minor", "0.3.0"), + ("0.2.0", "major", "1.0.0"), + ("0.2.0", "prerelease", "0.2.1-rc.0"), + ("0.3.1-rc.0", "prerelease", "0.3.1-rc.1"), + ("0.3.1-rc.0", "patch", "0.3.1"), + ("0.4.0-rc.1", "minor", "0.4.0"), + ("0.4.0-rc.1", "major", "1.0.0"), + ("1.0.0-rc.1", "major", "1.0.0"), + ], +) +def test_bump(version: str, kind: str, expected: str) -> None: + assert str(release_version.bump(release_version.Version(version), kind)) == expected + + +@pytest.mark.parametrize( + ("first", "second", "greater"), + [ + ("0.3.0", "0.3.0-rc.1", True), + ("0.3.0-rc.1", "0.3.0", False), + ("0.3.0-rc.10", "0.3.0-rc.2", True), + ("0.3.0-rc.1", "0.3.0-rc", True), + ("0.10.0", "0.9.0", True), + ("0.2.0", "0.2.0", False), + ], +) +def test_greater(first: str, second: str, greater: bool) -> None: + assert (release_version.main(["greater", first, second]) == 0) is greater + + +def test_newest_tag_by_semver() -> None: + tags = ["v0.1.0", "v0.3.0-rc.1", "v0.3.0", "0.9.0", "vnext", "v0.2.0"] + assert release_version.newest(tags) == "v0.3.0" + assert release_version.newest(["v0.3.0", "v0.3.1-rc.0"]) == "v0.3.1-rc.0" + assert release_version.newest([]) is None + + +def test_refuses_what_isnt_a_version() -> None: + with pytest.raises(ValueError): + release_version.Version("0.3") + with pytest.raises(ValueError): + release_version.Version("0.3.0rc1") + + +def test_set_rewrites_the_project_version(tmp_path: Path) -> None: + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nname = "drivine"\nversion = "0.2.0"\n\n[tool.x]\ntarget-version = "py311"\n') + release_version.set_version(pyproject, release_version.Version("0.3.0-rc.1")) + assert 'version = "0.3.0-rc.1"' in pyproject.read_text() + assert 'target-version = "py311"' in pyproject.read_text() From 9a667758fe1e057db31435b605cee70dd8c8fefe Mon Sep 17 00:00:00 2001 From: jasper blues Date: Wed, 28 Oct 2026 06:32:56 +1000 Subject: [PATCH 2/5] Bump a prerelease as npm does, skip a tag that isn't a version, refuse a stale uv.lock, and word errors as drivine does --- .github/workflows/prepare-release.yml | 18 ++++++++++++------ .github/workflows/release.yml | 7 ++++--- RELEASING.md | 2 +- scripts/release_version.py | 26 ++++++++++++++++++++------ tests/unit/test_release_version.py | 10 ++++++++++ 5 files changed, 47 insertions(+), 16 deletions(-) diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 526de5a..d4d5365 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -53,7 +53,7 @@ env: DEFAULT_BRANCH: main jobs: - prepare: + prepare-release: runs-on: ubuntu-latest permissions: contents: write @@ -94,20 +94,20 @@ jobs: tag="v$version" branch="release/$tag" if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then - echo "::error::$version isn't a version X.Y.Z (or X.Y.Z-suffix)." + echo "::error::$version isn't X.Y.Z (or X.Y.Z-suffix), so it can't be released." exit 1 fi if git rev-parse -q --verify "refs/tags/$tag" > /dev/null; then - echo "::error::The tag $tag already exists. Ask for a later version." + echo "::error::$tag is already tagged. Pick a later version." exit 1 fi newest=$(git tag -l 'v*' | python3 scripts/release_version.py newest) if [[ -n "$newest" ]] && ! python3 scripts/release_version.py greater "$version" "${newest#v}"; then - echo "::error::$version isn't greater than the newest tag, $newest, so merging it would release nothing. Ask for a later version." + echo "::error::$version isn't later than the last release, $newest, so merging it would release nothing. Pick a later version." exit 1 fi if git ls-remote --exit-code --heads origin "refs/heads/$branch" > /dev/null; then - echo "::error::The branch $branch already exists. Merge or close its PR and delete the branch, or ask for another version." + echo "::error::The branch $branch already exists. Merge or close its PR and delete the branch, or pick another version." exit 1 fi echo "pyproject.toml's version $current becomes $version, on $branch" @@ -118,11 +118,17 @@ jobs: echo "since=$newest" echo "current=$current" } >> "$GITHUB_OUTPUT" - # uv lock writes the project's own version into uv.lock, as PEP 440 normalizes it, and changes nothing else. + # uv lock keeps every locked package's version (it upgrades only with --upgrade), so on a uv.lock that is up to + # date it writes the project's own version, as PEP 440 normalizes it, and changes nothing else. One that isn't + # up to date fails here, rather than slipping other changes into the release PR. - name: Raise the version env: VERSION: ${{ steps.version.outputs.version }} run: | + if ! uv lock --check; then + echo "::error::uv.lock on $DEFAULT_BRANCH isn't up to date with pyproject.toml. Run uv lock and merge that first, so the release PR changes the version alone." + exit 1 + fi python3 scripts/release_version.py set pyproject.toml "$VERSION" uv lock git diff --stat diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f7cf89e..01a27b6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -73,11 +73,12 @@ jobs: newest=$(git tag -l 'v*' | python3 scripts/release_version.py newest) publish=false if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then - echo "::notice::pyproject.toml's version $version isn't X.Y.Z (or X.Y.Z-suffix), so this push releases nothing." + echo "::error::pyproject.toml's version $version isn't X.Y.Z (or X.Y.Z-suffix), so it can't be released." + exit 1 elif git rev-parse -q --verify "refs/tags/$tag" > /dev/null; then - echo "::notice::The tag $tag already exists, so this push, which changed pyproject.toml but not to a new version, releases nothing." + echo "::notice::pyproject.toml's version $version is already tagged $tag, so this push releases nothing. A change to pyproject.toml that isn't a version bump does this." elif [[ -n "$newest" ]] && ! python3 scripts/release_version.py greater "$version" "${newest#v}"; then - echo "::notice::pyproject.toml's version $version isn't greater than the newest tag, $newest, so this push releases nothing." + echo "::notice::pyproject.toml's version $version isn't later than the last release, $newest, so this push releases nothing." else publish=true fi diff --git a/RELEASING.md b/RELEASING.md index 77e0e2a..5997388 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -65,7 +65,7 @@ included. Only a merged version bump or a pushed tag publishes. - **Version:** `version` in `pyproject.toml`. The tag must be `v` and that version, character for character: `v0.3.0` for `0.3.0`. There is no `__version__` to keep in step. `uv.lock` records the project's version too; Prepare release - updates it with `uv lock`. + updates it with `uv lock`, and fails first if `uv.lock` isn't up to date, so the PR changes the version alone. - **Push trigger:** a push to `main` that changes `pyproject.toml`. - **Version arithmetic:** `scripts/release_version.py`, which `check` uses too, to compare a version with the newest tag. A `prerelease` bump gives `X.Y.Z-rc.N`: `0.3.0` becomes `0.3.1-rc.0`, and `0.3.1-rc.0` becomes diff --git a/scripts/release_version.py b/scripts/release_version.py index b37fd4b..50f80de 100644 --- a/scripts/release_version.py +++ b/scripts/release_version.py @@ -10,7 +10,8 @@ A bump works as npm's semver does, with `rc` for a prerelease: `patch`, `minor` and `major` of a prerelease give the release it leads to (`0.4.0-rc.1` minor is `0.4.0`), and `prerelease` counts on a prerelease (`0.4.0-rc.1` to -`0.4.0-rc.2`) or starts the next patch's (`0.3.0` to `0.3.1-rc.0`). +`0.4.0-rc.2`) or starts the next patch's (`0.3.0` to `0.3.1-rc.0`), as `npm version prerelease --preid rc` does: a +prerelease that isn't `rc.N` moves to `rc.0` (`0.4.0-beta.2` to `0.4.0-rc.0`). It reads the standard library alone, so nothing is installed for it. """ @@ -60,19 +61,32 @@ def bump(version: Version, kind: str) -> Version: patch += 1 prerelease = [] elif kind == "prerelease": + # As npm's `prerelease --preid rc`: count the last numeric identifier up, or add a 0 when there is none; then + # a prerelease that isn't rc.N starts over at rc.0. if not prerelease: - patch, prerelease = patch + 1, ["rc", "0"] - elif prerelease[-1].isdigit(): - prerelease = [*prerelease[:-1], str(int(prerelease[-1]) + 1)] + patch, prerelease = patch + 1, ["0"] else: - prerelease = [*prerelease, "0"] + numeric = [n for n, identifier in enumerate(prerelease) if identifier.isdigit()] + if numeric: + prerelease = list(prerelease) + prerelease[numeric[-1]] = str(int(prerelease[numeric[-1]]) + 1) + else: + prerelease = [*prerelease, "0"] + if prerelease[0] != "rc" or not prerelease[1].isdigit(): + prerelease = ["rc", "0"] else: raise ValueError(f"{kind} isn't patch, minor, major or prerelease") return Version(f"{major}.{minor}.{patch}" + (f"-{'.'.join(prerelease)}" if prerelease else "")) def newest(tags: list[str]) -> str | None: - versions = [Version(tag[1:]) for tag in tags if TAG.match(tag) and SEMVER.match(tag[1:])] + versions: list[Version] = [] + for tag in tags: + if TAG.match(tag): + try: + versions.append(Version(tag[1:])) + except ValueError: + pass # v01.2.3 or v1.2.3-rc..1: a tag, but not a semantic version, so no release's. return f"v{max(versions, key=Version.key)}" if versions else None diff --git a/tests/unit/test_release_version.py b/tests/unit/test_release_version.py index 66288fa..1a8b31b 100644 --- a/tests/unit/test_release_version.py +++ b/tests/unit/test_release_version.py @@ -27,6 +27,12 @@ ("0.4.0-rc.1", "minor", "0.4.0"), ("0.4.0-rc.1", "major", "1.0.0"), ("1.0.0-rc.1", "major", "1.0.0"), + ("1.2.0-rc.1", "major", "2.0.0"), + ("1.2.3-rc.0", "minor", "1.3.0"), + ("0.4.0-beta.2", "prerelease", "0.4.0-rc.0"), + ("0.4.0-rc", "prerelease", "0.4.0-rc.0"), + ("0.4.0-rc.alpha", "prerelease", "0.4.0-rc.0"), + ("0.4.0-rc.1.fix", "prerelease", "0.4.0-rc.2.fix"), ], ) def test_bump(version: str, kind: str, expected: str) -> None: @@ -42,6 +48,9 @@ def test_bump(version: str, kind: str, expected: str) -> None: ("0.3.0-rc.1", "0.3.0-rc", True), ("0.10.0", "0.9.0", True), ("0.2.0", "0.2.0", False), + ("1.0.0-alpha.beta", "1.0.0-alpha.1", True), + ("1.0.0-alpha.1", "1.0.0-alpha", True), + ("1.0.0-beta.11", "1.0.0-beta.2", True), ], ) def test_greater(first: str, second: str, greater: bool) -> None: @@ -53,6 +62,7 @@ def test_newest_tag_by_semver() -> None: assert release_version.newest(tags) == "v0.3.0" assert release_version.newest(["v0.3.0", "v0.3.1-rc.0"]) == "v0.3.1-rc.0" assert release_version.newest([]) is None + assert release_version.newest(["v0.2.0", "v0.9.0-rc..1", "v09.0.0"]) == "v0.2.0" def test_refuses_what_isnt_a_version() -> None: From 186b6bbe09863abbbff9ec0bf861571aa5210d56 Mon Sep 17 00:00:00 2001 From: jasper blues Date: Wed, 28 Oct 2026 06:35:53 +1000 Subject: [PATCH 3/5] Check the version written is [project].version before saving pyproject.toml --- scripts/release_version.py | 7 ++++++- tests/unit/test_release_version.py | 9 +++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/scripts/release_version.py b/scripts/release_version.py index 50f80de..0963b12 100644 --- a/scripts/release_version.py +++ b/scripts/release_version.py @@ -20,6 +20,7 @@ import re import sys +import tomllib from pathlib import Path SEMVER = re.compile(r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-([0-9A-Za-z.-]+))?$") @@ -94,7 +95,11 @@ def set_version(pyproject: Path, version: Version) -> None: text = pyproject.read_text() if len(PROJECT_VERSION.findall(text)) != 1: raise ValueError(f'{pyproject} doesn\'t have one line version = "..."') - pyproject.write_text(PROJECT_VERSION.sub(f'version = "{version}"', text)) + changed = PROJECT_VERSION.sub(f'version = "{version}"', text) + # The line must be [project]'s version, not another table's. + if tomllib.loads(changed).get("project", {}).get("version") != str(version): + raise ValueError(f"{pyproject}'s line version = \"...\" isn't [project]'s version") + pyproject.write_text(changed) def main(args: list[str]) -> int: diff --git a/tests/unit/test_release_version.py b/tests/unit/test_release_version.py index 1a8b31b..c648d07 100644 --- a/tests/unit/test_release_version.py +++ b/tests/unit/test_release_version.py @@ -78,3 +78,12 @@ def test_set_rewrites_the_project_version(tmp_path: Path) -> None: release_version.set_version(pyproject, release_version.Version("0.3.0-rc.1")) assert 'version = "0.3.0-rc.1"' in pyproject.read_text() assert 'target-version = "py311"' in pyproject.read_text() + + +def test_set_refuses_another_tables_version(tmp_path: Path) -> None: + pyproject = tmp_path / "pyproject.toml" + original = '[project]\nname = "drivine"\nversion="0.2.0"\n\n[tool.x]\nversion = "1.0"\n' + pyproject.write_text(original) + with pytest.raises(ValueError): + release_version.set_version(pyproject, release_version.Version("0.3.0")) + assert pyproject.read_text() == original From 398f6545de7af7a5f085ea36623d1d6e1c10e33f Mon Sep 17 00:00:00 2001 From: jasper blues Date: Wed, 28 Oct 2026 06:37:53 +1000 Subject: [PATCH 4/5] Name prepare-release's concurrency group after the repository, and refuse an exact version equal to the current one --- .github/workflows/prepare-release.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index d4d5365..7902504 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -36,6 +36,7 @@ on: type: string required: false default: "" + # The calling job must grant contents: write and pull-requests: write, which the job below asks for. secrets: RELEASE_PR_TOKEN: required: false @@ -43,8 +44,10 @@ on: permissions: contents: read +# Named for this repository: a run called from another repository is in that repository's concurrency groups, where +# the train controller may call all three libraries' prepare-release at once. concurrency: - group: prepare-release + group: prepare-release-liberation-data/drivine-python cancel-in-progress: false env: @@ -97,6 +100,10 @@ jobs: echo "::error::$version isn't X.Y.Z (or X.Y.Z-suffix), so it can't be released." exit 1 fi + if [[ "$version" == "$current" ]]; then + echo "::error::pyproject.toml's version is $version already. Pick a later version, or tag the commit by hand if merging it didn't release it." + exit 1 + fi if git rev-parse -q --verify "refs/tags/$tag" > /dev/null; then echo "::error::$tag is already tagged. Pick a later version." exit 1 From 8843047689e64ad6e31fc3dbf019f45cf353c347 Mon Sep 17 00:00:00 2001 From: jasper blues Date: Wed, 28 Oct 2026 06:40:01 +1000 Subject: [PATCH 5/5] Keep dry runs out of the release's concurrency group, so one can't cancel a waiting release --- .github/workflows/release.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 01a27b6..ff489e6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,8 +28,10 @@ on: permissions: contents: read +# A push (a release) queues behind another on the same ref. A dry run gets a group of its own: GitHub keeps one pending +# run per group, so a dry run queued on main would otherwise cancel a release waiting there. concurrency: - group: release-${{ github.ref }} + group: ${{ github.event_name == 'push' && format('release-{0}', github.ref) || format('release-dry-run-{0}', github.run_id) }} cancel-in-progress: false jobs: