diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 0000000..7902504 --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,180 @@ +# 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: "" + # The calling job must grant contents: write and pull-requests: write, which the job below asks for. + secrets: + RELEASE_PR_TOKEN: + required: false + +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-liberation-data/drivine-python + 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-release: + 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 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 + 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 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 pick 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 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 + - 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..ff489e6 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,12 +23,15 @@ on: - RELEASING.md - pyproject.toml - scripts/smoke_wheel.py + - scripts/release_version.py 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: @@ -30,6 +40,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 +67,41 @@ 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 "::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::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 later than the last release, $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 +129,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 +183,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..5997388 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`, 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 + `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..0963b12 --- /dev/null +++ b/scripts/release_version.py @@ -0,0 +1,128 @@ +""" +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`), 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. +""" + +from __future__ import annotations + +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.-]+))?$") +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": + # 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, ["0"] + else: + 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: 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 + + +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 = "..."') + 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: + 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..c648d07 --- /dev/null +++ b/tests/unit/test_release_version.py @@ -0,0 +1,89 @@ +"""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"), + ("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: + 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), + ("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: + 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 + 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: + 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() + + +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