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..4f77a6c 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,193 @@ 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_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.
+    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"))
+
+
+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