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"(?:(?:[-_.]?(?P post|rev|r)[-_.]?(?P [0-9]+)?)|(?P [0-9]+))?" + r"(?:[-_.]?(?P dev)[-_.]?(?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