From 0d6e42dcc6e990206c4902462335aded23bac42b Mon Sep 17 00:00:00 2001 From: jasper blues Date: Wed, 28 Oct 2026 15:43:59 +1000 Subject: [PATCH 1/2] Prepare release rewrites the other files that name the version: the sample's model file and poetry.lock, and the README Prepare release raised pyproject.toml and uv.lock alone, so its PR left the sample's model file naming the old generator version, which the sample's test of its typed references fails on, the sample's poetry.lock recording the old drivine, and the README's "0.2 is an alpha release" behind. scripts/release_version.py references rewrites each, from a list kept in the script, with patterns tight enough not to hit another package's version, and fails when one isn't found exactly once. The workflow commits what it lists, and fails if the bump changed anything it doesn't commit. --- .github/workflows/prepare-release.yml | 16 ++- RELEASING.md | 14 +++ scripts/release_version.py | 106 +++++++++++++++++ tests/unit/test_release_version.py | 157 ++++++++++++++++++++++++++ 4 files changed, 290 insertions(+), 3 deletions(-) diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 7902504..40c2f34 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -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: @@ -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 }} @@ -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: @@ -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" diff --git a/RELEASING.md b/RELEASING.md index 5997388..f590758 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -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 diff --git a/scripts/release_version.py b/scripts/release_version.py index 0963b12..8e07bdd 100644 --- a/scripts/release_version.py +++ b/scripts/release_version.py @@ -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. """ @@ -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
alpha|a|beta|b|c|rc|preview|pre)[-_.]?(?P[0-9]+)?)?"
+    r"(?:(?:[-_.]?(?Ppost|rev|r)[-_.]?(?P[0-9]+)?)|(?P[0-9]+))?"
+    r"(?:[-_.]?(?Pdev)[-_.]?(?P[0-9]+)?)?$",
+    re.IGNORECASE,
+)
+PRE_LABELS = {"alpha": "a", "a": "a", "beta": "b", "b": "b", "c": "rc", "rc": "rc", "preview": "rc", "pre": "rc"}
 
 
 class Version:
@@ -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"((? 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:
@@ -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
diff --git a/tests/unit/test_release_version.py b/tests/unit/test_release_version.py
index c648d07..1852b0c 100644
--- a/tests/unit/test_release_version.py
+++ b/tests/unit/test_release_version.py
@@ -4,8 +4,11 @@
 from __future__ import annotations
 
 import importlib.util
+import os
+import tomllib
 from pathlib import Path
 
+import packaging.version
 import pytest
 
 _path = Path(__file__).parents[2] / "scripts" / "release_version.py"
@@ -87,3 +90,157 @@ def test_set_refuses_another_tables_version(tmp_path: Path) -> None:
     with pytest.raises(ValueError):
         release_version.set_version(pyproject, release_version.Version("0.3.0"))
     assert pyproject.read_text() == original
+
+
+ROOT = Path(__file__).parents[2]
+
+MODEL = """{
+  "$comment": "Drivine model file",
+  "format": "drivine-model/1",
+  "generator": {
+    "library": "drivine-python",
+    "version": "0.2.0",
+    "language": "python"
+  },
+  "module": "drivine-sample",
+  "fragments": [{"id": "x", "properties": [{"name": "version", "default": "0.2.0"}]}]
+}
+"""
+
+POETRY_LOCK = """[[package]]
+name = "drivine"
+version = "0.2.0"
+description = "Graph database client"
+
+[package.dependencies]
+pi-pilgrim = ">=0.2.0"
+
+[[package]]
+name = "drivine-extra"
+version = "0.2.0"
+
+[[package]]
+name = "pi-pilgrim"
+version = "0.2.0"
+"""
+
+README = """# Drivine for Python
+
+0.2 is an alpha release: the API may still change.
+
+The design names pi-pilgrim 0.2, and version 0.2.0 of a dependency.
+"""
+
+
+def a_project(tmp_path: Path, model: str = MODEL, lock: str = POETRY_LOCK, readme: str = README) -> Path:
+    (tmp_path / "sample" / ".drivine").mkdir(parents=True)
+    (tmp_path / "sample" / ".drivine" / "model.json").write_text(model)
+    (tmp_path / "sample" / "poetry.lock").write_text(lock)
+    (tmp_path / "README.md").write_text(readme)
+    return tmp_path
+
+
+def test_references_rewrites_each_and_nothing_else(tmp_path: Path) -> None:
+    root = a_project(tmp_path)
+    changed = release_version.set_references(root, release_version.Version("0.3.0"))
+    assert changed == ["sample/.drivine/model.json", "sample/poetry.lock", "README.md"]
+    assert (root / "sample" / ".drivine" / "model.json").read_text() == MODEL.replace(
+        '"version": "0.2.0"', '"version": "0.3.0"'
+    )
+    # drivine's own entry moves; a package of a name that begins drivine's, and a dependency's version, don't.
+    assert (root / "sample" / "poetry.lock").read_text() == POETRY_LOCK.replace(
+        'name = "drivine"\nversion = "0.2.0"', 'name = "drivine"\nversion = "0.3.0"'
+    )
+    assert (root / "README.md").read_text() == README.replace("0.2 is an alpha", "0.3 is an alpha")
+
+
+def test_references_of_a_prerelease(tmp_path: Path) -> None:
+    root = a_project(tmp_path)
+    release_version.set_references(root, release_version.Version("0.4.0-rc.1"))
+    # The model file has the version as drivine's metadata does, as pyproject.toml writes it; poetry.lock has it as
+    # PEP 440 normalizes it; the README has the release it leads to.
+    assert '"version": "0.4.0-rc.1"' in (root / "sample" / ".drivine" / "model.json").read_text()
+    assert 'name = "drivine"\nversion = "0.4.0rc1"' in (root / "sample" / "poetry.lock").read_text()
+    assert "0.4 is an alpha release:" in (root / "README.md").read_text()
+
+
+@pytest.mark.parametrize(
+    ("model", "lock", "readme"),
+    [
+        (MODEL.replace("drivine-python", "drivine4j"), POETRY_LOCK, README),
+        (MODEL, POETRY_LOCK.replace('name = "drivine"\n', 'name = "drivine-core"\n'), README),
+        (MODEL, POETRY_LOCK, README.replace("is an alpha release:", "is a release:")),
+        (MODEL, POETRY_LOCK, README + "\n1.0 is an alpha release: again.\n"),
+    ],
+    ids=["model file of another library", "lock without drivine", "README reworded", "README says it twice"],
+)
+def test_references_fails_on_a_reference_not_found_once_and_writes_nothing(
+    tmp_path: Path, model: str, lock: str, readme: str
+) -> None:
+    root = a_project(tmp_path, model, lock, readme)
+    with pytest.raises(ValueError, match="should name the version once"):
+        release_version.set_references(root, release_version.Version("0.3.0"))
+    assert (root / "sample" / ".drivine" / "model.json").read_text() == model
+    assert (root / "sample" / "poetry.lock").read_text() == lock
+    assert (root / "README.md").read_text() == readme
+
+
+def test_references_fails_on_a_missing_file(tmp_path: Path) -> None:
+    root = a_project(tmp_path)
+    (root / "sample" / "poetry.lock").unlink()
+    with pytest.raises(ValueError, match="no such file"):
+        release_version.set_references(root, release_version.Version("0.3.0"))
+    assert (root / "README.md").read_text() == README
+
+
+def test_the_repository_s_references_name_pyproject_s_version(tmp_path: Path) -> None:
+    # Rewriting the repository's own files to the version they should have already changes nothing: every reference
+    # is found, and each names pyproject.toml's version. A file moved or reworded fails here, before a release does.
+    version = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"]["version"]
+    for reference in release_version.REFERENCES:
+        copy = tmp_path / reference.path
+        copy.parent.mkdir(parents=True, exist_ok=True)
+        copy.write_text((ROOT / reference.path).read_text())
+    release_version.set_references(tmp_path, release_version.Version(version))
+    for reference in release_version.REFERENCES:
+        assert (tmp_path / reference.path).read_text() == (ROOT / reference.path).read_text(), reference.path
+
+
+def test_every_model_file_in_the_repository_is_a_reference() -> None:
+    found: list[str] = []
+    for directory, subdirectories, files in os.walk(ROOT):
+        subdirectories[:] = [it for it in subdirectories if it == ".drivine" or not it.startswith((".", "__"))]
+        if Path(directory).name == ".drivine" and "model.json" in files:
+            found.append((Path(directory) / "model.json").relative_to(ROOT).as_posix())
+    assert found
+    assert set(found) <= {reference.path for reference in release_version.REFERENCES}
+
+
+@pytest.mark.parametrize(
+    "version",
+    [
+        "0.3.0",
+        "0.3.0-rc.1",
+        "0.3.0-rc1",
+        "0.3.0-rc",
+        "0.3.0-RC.01",
+        "0.3.0-alpha.2",
+        "0.3.0-beta.3",
+        "0.3.0-a.1",
+        "0.3.0-c.4",
+        "0.3.0-pre.5",
+        "0.3.0-preview.6",
+        "0.3.0-dev.7",
+        "0.3.0-post.1",
+        "0.3.0-1",
+        "0.3.0-rc.1.dev.2",
+        "0.3.0-rc.1.post.2",
+    ],
+)
+def test_pep440_normalizes_as_packaging_does(version: str) -> None:
+    assert release_version.pep440(release_version.Version(version)) == str(packaging.version.Version(version))
+
+
+def test_pep440_refuses_a_suffix_it_doesnt_read() -> None:
+    with pytest.raises(ValueError, match="PEP 440"):
+        release_version.pep440(release_version.Version("0.3.0-rc.1.fix"))

From 5c2e5f3976e955a550fa0186845e29ad23e38ab9 Mon Sep 17 00:00:00 2001
From: jasper blues 
Date: Wed, 28 Oct 2026 15:53:33 +1000
Subject: [PATCH 2/2] Test the references command from the command line, and
 two references to one file

---
 tests/unit/test_release_version.py | 36 ++++++++++++++++++++++++++++++
 1 file changed, 36 insertions(+)

diff --git a/tests/unit/test_release_version.py b/tests/unit/test_release_version.py
index 1852b0c..4f77a6c 100644
--- a/tests/unit/test_release_version.py
+++ b/tests/unit/test_release_version.py
@@ -193,6 +193,20 @@ def test_references_fails_on_a_missing_file(tmp_path: Path) -> None:
     assert (root / "README.md").read_text() == README
 
 
+def test_references_to_one_file_each_rewrite_it_and_name_it_once(tmp_path: Path) -> None:
+    # A second reference to a file rewrites the text the first one gave, not the file as it was, and the file is
+    # listed for the commit once.
+    root = a_project(tmp_path)
+    references = [
+        release_version.Reference("README.md", r"((? None:
     # Rewriting the repository's own files to the version they should have already changes nothing: every reference
     # is found, and each names pyproject.toml's version. A file moved or reworded fails here, before a release does.
@@ -244,3 +258,25 @@ def test_pep440_normalizes_as_packaging_does(version: str) -> None:
 def test_pep440_refuses_a_suffix_it_doesnt_read() -> None:
     with pytest.raises(ValueError, match="PEP 440"):
         release_version.pep440(release_version.Version("0.3.0-rc.1.fix"))
+
+
+def test_references_from_the_command_line_prints_the_paths_for_the_commit(
+    tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
+) -> None:
+    # The command rewrites the files of the repository the script is in, and prints each path on a line of its own,
+    # which Prepare release passes to `git add`.
+    root = a_project(tmp_path)
+    monkeypatch.setattr(release_version, "__file__", str(root / "scripts" / "release_version.py"))
+    assert release_version.main(["references", "0.3.0-rc.1"]) == 0
+    assert capsys.readouterr().out.splitlines() == ["sample/.drivine/model.json", "sample/poetry.lock", "README.md"]
+    assert 'name = "drivine"\nversion = "0.3.0rc1"' in (root / "sample" / "poetry.lock").read_text()
+
+
+def test_references_from_the_command_line_fails_on_a_version_pep440_doesnt_read(
+    tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
+) -> None:
+    root = a_project(tmp_path)
+    monkeypatch.setattr(release_version, "__file__", str(root / "scripts" / "release_version.py"))
+    assert release_version.main(["references", "0.3.0-rc.1.fix"]) == 2
+    assert "PEP 440" in capsys.readouterr().err
+    assert (root / "sample" / ".drivine" / "model.json").read_text() == MODEL