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
16 changes: 13 additions & 3 deletions .github/workflows/prepare-release.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 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.
# Opens the release PR: "Release vX.Y.Z", which raises pyproject.toml's version (and uv.lock's record of it, and the
# other files that name it, which scripts/release_version.py lists) 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:
Expand Down Expand Up @@ -128,6 +128,10 @@ jobs:
# 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.
#
# `references` then rewrites the other files that name the version, the sample's model file and poetry.lock and
# the README, as the stubs step and `poetry lock` would, and lists them for the commit. It fails when one of them
# doesn't name the version where it expects, so that the release PR doesn't leave it naming the old one.
- name: Raise the version
env:
VERSION: ${{ steps.version.outputs.version }}
Expand All @@ -138,6 +142,7 @@ jobs:
fi
python3 scripts/release_version.py set pyproject.toml "$VERSION"
uv lock
python3 scripts/release_version.py references "$VERSION" > "$RUNNER_TEMP/references"
git diff --stat
- name: Push the branch and open the PR
env:
Expand All @@ -153,6 +158,11 @@ jobs:
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git switch -c "$BRANCH"
git add pyproject.toml uv.lock
xargs git add -- < "$RUNNER_TEMP/references"
if ! git diff --quiet; then
echo "::error::Raising the version changed files it doesn't commit: $(git diff --name-only | xargs)."
exit 1
fi
git commit -m "Release $TAG"
git push origin "refs/heads/$BRANCH"
body="$RUNNER_TEMP/release-pr.md"
Expand Down
14 changes: 14 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,20 @@ 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`, and fails first if `uv.lock` isn't up to date, so the PR changes the version alone.
- **Other files that name the version:** Prepare release rewrites them too, with
`scripts/release_version.py references X.Y.Z`, from the list `REFERENCES` in that script, and commits them with the
version:
- `sample/.drivine/model.json`, whose `generator.version` is the version as `pyproject.toml` writes it. The sample's
test of its typed references (`sample/tests/test_typed_refs_up_to_date.py`) compares the whole file with what the
stubs step writes, so a model file that names another version fails it.
- `sample/poetry.lock`, whose entry for drivine has the version as PEP 440 normalizes it (`0.3.0rc1`), as
`poetry lock` writes it.
- `README.md`'s "0.3 is an alpha release": its `MAJOR.MINOR`.

Each must be found exactly once, or Prepare release fails, saying which: a file moved or a line reworded is then
updated in `REFERENCES`. A unit test checks that the list matches the files and names `pyproject.toml`'s version,
and that every `.drivine/model.json` in the repository is on it. A file that names a version only as history, as
the examples here do, isn't on it.
- **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
Expand Down
106 changes: 106 additions & 0 deletions scripts/release_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,20 @@
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
python scripts/release_version.py references 0.4.0 # the other files that name it; prints their paths

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`).

`set` writes the version in pyproject.toml, and `uv lock` then writes it in uv.lock. Other files name the version
too, and `references` rewrites them, from the list in REFERENCES below: the sample's model file, whose generator
version the sample's test of its typed references compares (sample/tests/test_typed_refs_up_to_date.py), the
sample's poetry.lock, and the README's line on which release this is. Each is matched by a pattern tight enough that
it can't hit another package's version, and must be found exactly once, so that a file moved or a line reworded
fails the release PR here, rather than leaving it to name the old version.

It reads the standard library alone, so nothing is installed for it.
"""

Expand All @@ -21,11 +29,22 @@
import re
import sys
import tomllib
from collections.abc import Callable
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)
# A prerelease suffix as PEP 440 reads it, after the tag's "-", with the spellings it normalizes: X.Y.Z-rc.1 is
# X.Y.Zrc1, X.Y.Z-beta.2 is X.Y.Zb2, X.Y.Z-dev.3 is X.Y.Z.dev3, and a bare number, X.Y.Z-1, is the post-release
# X.Y.Z.post1. A missing number is 0.
PEP440_SUFFIX = re.compile(
r"^(?:(?P<pre>alpha|a|beta|b|c|rc|preview|pre)[-_.]?(?P<pre_n>[0-9]+)?)?"
r"(?:(?:[-_.]?(?P<post>post|rev|r)[-_.]?(?P<post_n>[0-9]+)?)|(?P<implicit_post>[0-9]+))?"
r"(?:[-_.]?(?P<dev>dev)[-_.]?(?P<dev_n>[0-9]+)?)?$",
re.IGNORECASE,
)
PRE_LABELS = {"alpha": "a", "a": "a", "beta": "b", "b": "b", "c": "rc", "rc": "rc", "preview": "rc", "pre": "rc"}


class Version:
Expand Down Expand Up @@ -91,6 +110,90 @@ def newest(tags: list[str]) -> str | None:
return f"v{max(versions, key=Version.key)}" if versions else None


def pep440(version: Version) -> str:
"""
The version as PEP 440 normalizes it, as uv.lock and poetry.lock record it: 0.3.0-rc.1 is 0.3.0rc1. The wheel's
own metadata keeps the version as pyproject.toml writes it, so a model file records 0.3.0-rc.1.
"""
release = f"{version.major}.{version.minor}.{version.patch}"
if not version.prerelease:
return release
suffix = ".".join(version.prerelease)
match = PEP440_SUFFIX.match(suffix)
if match is None:
raise ValueError(f"{version}'s suffix isn't one PEP 440 reads, such as rc.1, beta.2 or dev.3")
out = release
if match["pre"]:
out += f"{PRE_LABELS[match['pre'].lower()]}{int(match['pre_n'] or 0)}"
if match["post"] or match["implicit_post"]:
out += f".post{int(match['post_n'] or match['implicit_post'] or 0)}"
if match["dev"]:
out += f".dev{int(match['dev_n'] or 0)}"
return out


class Reference:
"""
One file's mention of the version, besides pyproject.toml's and uv.lock's. The pattern has two groups, the text
before the version and the text after it, and matches the version between them; `write` gives what replaces it.
"""

def __init__(self, path: str, pattern: str, write: Callable[[Version], str], flags: int = 0) -> None:
self.path = path
self.pattern = re.compile(pattern, flags)
self.write = write


# Every file but pyproject.toml and uv.lock that names drivine's own version, and moves with each release. A file
# that names it only as history, as RELEASING.md's examples do, isn't listed. tests/unit/test_release_version.py
# checks that every model file in the repository is listed.
REFERENCES = [
# The generator the model file names: the version importlib.metadata reads from drivine's metadata, as
# pyproject.toml writes it. The sample's test of its typed references compares the whole file, so it fails on a
# model file that names another version.
Reference(
"sample/.drivine/model.json",
r'("generator": \{\s*"library": "drivine-python",\s*"version": ")[^"]*(")',
str,
),
# The sample's lock records drivine, which it takes from this repository, at the version PEP 440 normalizes it
# to, as `poetry lock` writes it. Its other packages are left alone.
Reference(
"sample/poetry.lock", r'(^\[\[package\]\]\nname = "drivine"\nversion = ")[^"\n]*(")', pep440, re.MULTILINE
),
# "0.2 is an alpha release": the MAJOR.MINOR, of a prerelease too.
Reference(
"README.md",
r"((?<![0-9.]))[0-9]+\.[0-9]+( is an alpha release:)",
lambda version: f"{version.major}.{version.minor}",
),
]


def set_references(root: Path, version: Version, references: list[Reference] = REFERENCES) -> list[str]:
"""
Rewrites each reference under `root` to name `version`, and gives the paths of the files. Every reference is
checked before any file is written: one that isn't found exactly once fails, and nothing is changed.
"""
changed: dict[Path, str] = {}
for reference in references:
path = root / reference.path
if not path.is_file():
raise ValueError(f"{reference.path} names the version, but there is no such file. Update REFERENCES.")
text = changed.get(path, path.read_text())
found = len(reference.pattern.findall(text))
if found != 1:
raise ValueError(
f"{reference.path} should name the version once, matching {reference.pattern.pattern}, but has "
f"{found} matches. Update REFERENCES to match the file."
)
# A version is digits, letters, dots and dashes, so it can't hold a backreference.
changed[path] = reference.pattern.sub(rf"\g<1>{reference.write(version)}\g<2>", text)
for path, text in changed.items():
path.write_text(text)
return list(dict.fromkeys(reference.path for reference in references))


def set_version(pyproject: Path, version: Version) -> None:
text = pyproject.read_text()
if len(PROJECT_VERSION.findall(text)) != 1:
Expand All @@ -115,6 +218,9 @@ def main(args: list[str]) -> int:
return 0 if Version(first).key() > Version(second).key() else 1
case ["set", pyproject, version]:
set_version(Path(pyproject), Version(version))
case ["references", version]:
for path in set_references(Path(__file__).resolve().parents[1], Version(version)):
print(path)
case _:
print(__doc__, file=sys.stderr)
return 2
Expand Down
Loading
Loading