From e751d7aeb8b5f165c20a03653fe8a4ed2da607b1 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 20 Sep 2026 22:15:48 -0300 Subject: [PATCH 1/6] feat: add release train planning release_train_plan.py --- scripts/release_train_plan.py | 359 ++++++++++++++++++++++++++++++++++ 1 file changed, 359 insertions(+) create mode 100644 scripts/release_train_plan.py diff --git a/scripts/release_train_plan.py b/scripts/release_train_plan.py new file mode 100644 index 0000000..3fbb1f5 --- /dev/null +++ b/scripts/release_train_plan.py @@ -0,0 +1,359 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from xml.etree import ElementTree + +SEMVER = re.compile(r"^(?P[0-9]+)\.(?P[0-9]+)\.(?P[0-9]+)$") +STABLE = re.compile(r"^stable(?P[0-9]+)$") + + +@dataclass(frozen=True) +class ReleaseLine: + branch: str + head_sha: str + current_version: str + nextcloud_major: int + previous_tag: str | None + proposed_version: str + requested_version: str + milestone: str + ready: bool + blockers: tuple[str, ...] + warnings: tuple[str, ...] + + +def _git(repo: Path, *args: str, check: bool = True) -> str: + result = subprocess.run( + ["git", "-C", str(repo), *args], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if check and result.returncode != 0: + detail = result.stderr.strip() or result.stdout.strip() + raise ValueError(f"git {' '.join(args)} failed: {detail}") + return result.stdout.strip() + + +def _read_at(repo: Path, ref: str, path: str) -> str: + return _git(repo, "show", f"{ref}:{path}") + + +def _parse_appinfo(content: str) -> tuple[str, int]: + try: + root = ElementTree.fromstring(content) + except ElementTree.ParseError as error: + raise ValueError(f"invalid appinfo XML: {error}") from error + + version = (root.findtext("version") or "").strip() + if not SEMVER.fullmatch(version): + raise ValueError(f"appinfo version must use MAJOR.MINOR.PATCH, got {version!r}") + + nextcloud = root.find("./dependencies/nextcloud") + if nextcloud is None: + raise ValueError("appinfo is missing dependencies/nextcloud") + + min_version = nextcloud.attrib.get("min-version", "") + max_version = nextcloud.attrib.get("max-version", "") + if not min_version.isdigit(): + raise ValueError("nextcloud min-version must be an integer") + if max_version and max_version != min_version: + raise ValueError( + f"LibreSign-style release line requires matching min/max Nextcloud major, got {min_version}/{max_version}" + ) + + return version, int(min_version) + + +def _previous_release_tag(repo: Path, branch: str) -> str | None: + output = _git( + repo, + "describe", + "--tags", + "--match", + "v[0-9]*.[0-9]*.[0-9]*", + "--abbrev=0", + branch, + check=False, + ) + return output or None + + +def _version_from_tag(tag: str | None, fallback: str) -> str: + value = tag[1:] if tag and tag.startswith("v") else fallback + if not SEMVER.fullmatch(value): + raise ValueError(f"cannot derive semantic version from {value!r}") + return value + + +def _next_patch(version: str) -> str: + match = SEMVER.fullmatch(version) + if match is None: + raise ValueError(f"invalid semantic version: {version}") + return f"{match.group('major')}.{match.group('minor')}.{int(match.group('patch')) + 1}" + + +def _parse_json_mapping(value: str, name: str) -> dict[str, str]: + try: + parsed = json.loads(value) + except json.JSONDecodeError as error: + raise ValueError(f"{name} must be valid JSON: {error.msg}") from error + if not isinstance(parsed, dict) or not all( + isinstance(key, str) and isinstance(item, str) + for key, item in parsed.items() + ): + raise ValueError(f"{name} must be a JSON object of string keys and values") + return parsed + + +def _parse_branches(value: str) -> tuple[str, ...]: + try: + parsed = json.loads(value) + except json.JSONDecodeError as error: + raise ValueError(f"branches must be valid JSON: {error.msg}") from error + if not isinstance(parsed, list) or not parsed or not all( + isinstance(item, str) and item for item in parsed + ): + raise ValueError("branches must be a non-empty JSON array of strings") + if len(set(parsed)) != len(parsed): + raise ValueError("branches must not contain duplicates") + return tuple(parsed) + + +def _branch_sort_key(branch: str) -> tuple[int, str]: + match = STABLE.fullmatch(branch) + if match: + return (int(match.group("major")), branch) + return (10**9, branch) + + +def build_release_train( + repo: Path, + branches: tuple[str, ...], + version_overrides: dict[str, str], +) -> dict[str, object]: + lines: list[ReleaseLine] = [] + + for branch in sorted(branches, key=_branch_sort_key): + blockers: list[str] = [] + warnings: list[str] = [] + + head_sha = _git(repo, "rev-parse", "--verify", f"{branch}^{{commit}}", check=False) + if not head_sha: + lines.append( + ReleaseLine( + branch=branch, + head_sha="", + current_version="", + nextcloud_major=-1, + previous_tag=None, + proposed_version="", + requested_version=version_overrides.get(branch, ""), + milestone="", + ready=False, + blockers=(f"branch {branch!r} does not exist",), + warnings=(), + ) + ) + continue + + try: + current_version, nextcloud_major = _parse_appinfo( + _read_at(repo, branch, "appinfo/info.xml") + ) + except ValueError as error: + lines.append( + ReleaseLine( + branch=branch, + head_sha=head_sha, + current_version="", + nextcloud_major=-1, + previous_tag=None, + proposed_version="", + requested_version=version_overrides.get(branch, ""), + milestone="", + ready=False, + blockers=(str(error),), + warnings=(), + ) + ) + continue + + branch_match = STABLE.fullmatch(branch) + if branch_match and int(branch_match.group("major")) != nextcloud_major: + blockers.append( + f"{branch} implies Nextcloud {branch_match.group('major')} but appinfo declares {nextcloud_major}" + ) + + previous_tag = _previous_release_tag(repo, branch) + previous_version = _version_from_tag(previous_tag, current_version) + proposed_version = _next_patch(previous_version) + requested_version = version_overrides.get(branch, proposed_version) + + if not SEMVER.fullmatch(requested_version): + blockers.append( + f"requested version {requested_version!r} must use MAJOR.MINOR.PATCH" + ) + + expected_major = int(SEMVER.fullmatch(current_version).group("major")) + requested_match = SEMVER.fullmatch(requested_version) + if requested_match and int(requested_match.group("major")) != expected_major: + warnings.append( + "requested version changes the app major; review explicitly before preparation" + ) + + if _git(repo, "rev-parse", "-q", "--verify", f"refs/tags/v{requested_version}", check=False): + blockers.append(f"tag v{requested_version} already exists") + + if requested_version != proposed_version: + warnings.append( + f"version override selected: proposed {proposed_version}, requested {requested_version}" + ) + + milestone = ( + f"Next Patch ({nextcloud_major})" + if branch_match + else f"Next Major ({nextcloud_major})" + ) + + lines.append( + ReleaseLine( + branch=branch, + head_sha=head_sha, + current_version=current_version, + nextcloud_major=nextcloud_major, + previous_tag=previous_tag, + proposed_version=proposed_version, + requested_version=requested_version, + milestone=milestone, + ready=not blockers, + blockers=tuple(blockers), + warnings=tuple(warnings), + ) + ) + + return { + "ready": all(line.ready for line in lines), + "publication_order": [line.branch for line in lines], + "releases": [ + { + "branch": line.branch, + "head_sha": line.head_sha, + "current_version": line.current_version, + "nextcloud_major": line.nextcloud_major, + "previous_tag": line.previous_tag, + "proposed_version": line.proposed_version, + "requested_version": line.requested_version, + "milestone": line.milestone, + "ready": line.ready, + "blockers": list(line.blockers), + "warnings": list(line.warnings), + } + for line in lines + ], + } + + +def render_summary(plan: dict[str, object]) -> str: + lines = [ + "## Release train plan", + "", + f"Overall readiness: **{'ready' if plan['ready'] else 'blocked'}**", + "", + "| Branch | Current | Previous tag | Proposed | Requested | Milestone | Status |", + "| --- | --- | --- | --- | --- | --- | --- |", + ] + for release in plan["releases"]: + status = "ready" if release["ready"] else "blocked" + lines.append( + "| {branch} | {current_version} | {previous_tag} | {proposed_version} | " + "{requested_version} | {milestone} | {status} |".format( + previous_tag=release["previous_tag"] or "-", + status=status, + **release, + ) + ) + for blocker in release["blockers"]: + lines.append(f"- **{release['branch']} blocker:** {blocker}") + for warning in release["warnings"]: + lines.append(f"- **{release['branch']} warning:** {warning}") + lines.append("") + return "\n".join(lines) + + +def _write_output(name: str, value: str) -> None: + output = os.environ.get("GITHUB_OUTPUT") + if not output: + return + with Path(output).open("a", encoding="utf-8") as stream: + delimiter = f"RELEASE_TRAIN_{name.upper()}" + stream.write(f"{name}<<{delimiter}\n{value}\n{delimiter}\n") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--repo", type=Path, default=Path(".")) + parser.add_argument( + "--branches-json", + default=os.environ.get("RELEASE_TRAIN_BRANCHES", "[]"), + ) + parser.add_argument( + "--version-overrides-json", + default=os.environ.get("RELEASE_TRAIN_VERSION_OVERRIDES", "{}"), + ) + parser.add_argument( + "--output", + type=Path, + default=Path(os.environ.get("RELEASE_TRAIN_OUTPUT", "release-train-plan.json")), + ) + args = parser.parse_args() + + try: + branches = _parse_branches(args.branches_json) + overrides = _parse_json_mapping( + args.version_overrides_json, "version overrides" + ) + unknown = sorted(set(overrides) - set(branches)) + if unknown: + raise ValueError( + "version overrides reference branches not in the train: " + + ", ".join(unknown) + ) + plan = build_release_train(args.repo.resolve(), branches, overrides) + except (OSError, ValueError) as error: + print(f"release-train-plan: {error}", file=sys.stderr) + return 2 + + rendered = json.dumps(plan, indent=2, sort_keys=True) + args.output.write_text(rendered + "\n", encoding="utf-8") + print(rendered) + + summary = render_summary(plan) + summary_path = os.environ.get("GITHUB_STEP_SUMMARY") + if summary_path: + with Path(summary_path).open("a", encoding="utf-8") as stream: + stream.write(summary) + + _write_output("plan", json.dumps(plan, separators=(",", ":"))) + _write_output("ready", str(plan["ready"]).lower()) + _write_output( + "publication_order", + json.dumps(plan["publication_order"], separators=(",", ":")), + ) + return 0 if plan["ready"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) From 2a7403b9eb61a5b907c013067aa6c24fc38ee5fb Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 20 Sep 2026 22:15:50 -0300 Subject: [PATCH 2/6] feat: add release train planning action.yml --- actions/release-train-plan/action.yml | 36 +++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 actions/release-train-plan/action.yml diff --git a/actions/release-train-plan/action.yml b/actions/release-train-plan/action.yml new file mode 100644 index 0000000..fa9d8b7 --- /dev/null +++ b/actions/release-train-plan/action.yml @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: Plan release train +description: Discover and validate a multi-branch Nextcloud app release train without mutations. + +inputs: + branches: + description: JSON array of branches to include in the release train + required: true + version-overrides: + description: JSON object mapping branch names to explicit release versions + required: false + default: '{}' + +outputs: + plan: + description: Compact JSON release train plan + value: ${{ steps.plan.outputs.plan }} + ready: + description: Whether every selected release line passed read-only validation + value: ${{ steps.plan.outputs.ready }} + publication-order: + description: JSON array of branches ordered oldest stable first + value: ${{ steps.plan.outputs.publication_order }} + +runs: + using: composite + steps: + - name: Plan release train + id: plan + shell: bash + env: + RELEASE_TRAIN_BRANCHES: ${{ inputs.branches }} + RELEASE_TRAIN_VERSION_OVERRIDES: ${{ inputs.version-overrides }} + run: python3 "$GITHUB_ACTION_PATH/../../scripts/release_train_plan.py" From 146fa1fac12584540d66b70283b36ea271c9bc80 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 20 Sep 2026 22:15:52 -0300 Subject: [PATCH 3/6] feat: add release train planning test_release_train_plan.py --- tests/test_release_train_plan.py | 179 +++++++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 tests/test_release_train_plan.py diff --git a/tests/test_release_train_plan.py b/tests/test_release_train_plan.py new file mode 100644 index 0000000..59cca3c --- /dev/null +++ b/tests/test_release_train_plan.py @@ -0,0 +1,179 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +import json +import subprocess +import tempfile +import unittest +from pathlib import Path + +from scripts.release_train_plan import ( + _parse_appinfo, + _parse_branches, + build_release_train, + render_summary, +) + + +def git(repo: Path, *args: str) -> str: + result = subprocess.run( + ["git", "-C", str(repo), *args], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True, + ) + return result.stdout.strip() + + +class ReleaseTrainPlanTest(unittest.TestCase): + def init_repo(self, directory: str) -> Path: + repo = Path(directory) + git(repo, "init", "-b", "main") + git(repo, "config", "user.email", "test@example.com") + git(repo, "config", "user.name", "Test") + return repo + + def write_appinfo(self, repo: Path, version: str, nc: int) -> None: + appinfo = repo / "appinfo/info.xml" + appinfo.parent.mkdir(parents=True, exist_ok=True) + appinfo.write_text( + "" + f"{version}" + "" + f'' + "" + "", + encoding="utf-8", + ) + + def commit_release_line(self, repo: Path, branch: str, version: str, nc: int) -> None: + git(repo, "checkout", "-B", branch) + self.write_appinfo(repo, version, nc) + git(repo, "add", "appinfo/info.xml") + git(repo, "commit", "-m", f"{branch} {version}") + + def test_orders_stable_lines_oldest_first(self) -> None: + with tempfile.TemporaryDirectory() as directory: + repo = self.init_repo(directory) + self.commit_release_line(repo, "stable35", "15.2.3", 35) + git(repo, "tag", "v15.2.3") + self.commit_release_line(repo, "stable34", "14.4.8", 34) + git(repo, "tag", "v14.4.8") + + plan = build_release_train( + repo, + ("stable35", "stable34"), + {}, + ) + + self.assertTrue(plan["ready"]) + self.assertEqual( + plan["publication_order"], + ["stable34", "stable35"], + ) + self.assertEqual( + plan["releases"][0]["proposed_version"], + "14.4.9", + ) + self.assertEqual( + plan["releases"][1]["milestone"], + "Next Patch (35)", + ) + + def test_explicit_version_override_is_visible(self) -> None: + with tempfile.TemporaryDirectory() as directory: + repo = self.init_repo(directory) + self.commit_release_line(repo, "stable35", "15.2.3", 35) + git(repo, "tag", "v15.2.3") + + plan = build_release_train( + repo, + ("stable35",), + {"stable35": "15.3.0"}, + ) + + release = plan["releases"][0] + self.assertEqual(release["proposed_version"], "15.2.4") + self.assertEqual(release["requested_version"], "15.3.0") + self.assertIn("version override selected", release["warnings"][0]) + + def test_existing_tag_blocks_release(self) -> None: + with tempfile.TemporaryDirectory() as directory: + repo = self.init_repo(directory) + self.commit_release_line(repo, "stable35", "15.2.3", 35) + git(repo, "tag", "v15.2.3") + git(repo, "tag", "v15.2.4") + + plan = build_release_train( + repo, + ("stable35",), + {"stable35": "15.2.4"}, + ) + + self.assertFalse(plan["ready"]) + self.assertIn( + "tag v15.2.4 already exists", + plan["releases"][0]["blockers"], + ) + + def test_branch_nextcloud_major_mismatch_blocks(self) -> None: + with tempfile.TemporaryDirectory() as directory: + repo = self.init_repo(directory) + self.commit_release_line(repo, "stable35", "15.2.3", 34) + + plan = build_release_train(repo, ("stable35",), {}) + + self.assertFalse(plan["ready"]) + self.assertIn( + "stable35 implies Nextcloud 35 but appinfo declares 34", + plan["releases"][0]["blockers"], + ) + + def test_missing_branch_is_reported_not_crashed(self) -> None: + with tempfile.TemporaryDirectory() as directory: + repo = self.init_repo(directory) + plan = build_release_train(repo, ("stable99",), {}) + self.assertFalse(plan["ready"]) + self.assertIn("does not exist", plan["releases"][0]["blockers"][0]) + + def test_rejects_duplicate_branches(self) -> None: + with self.assertRaisesRegex(ValueError, "duplicates"): + _parse_branches('["stable34","stable34"]') + + def test_parses_libresign_style_appinfo(self) -> None: + version, major = _parse_appinfo( + '15.2.3' + '' + '' + ) + self.assertEqual(version, "15.2.3") + self.assertEqual(major, 35) + + def test_summary_contains_publication_order_rows(self) -> None: + summary = render_summary( + { + "ready": True, + "publication_order": ["stable34"], + "releases": [ + { + "branch": "stable34", + "head_sha": "abc", + "current_version": "14.4.8", + "nextcloud_major": 34, + "previous_tag": "v14.4.8", + "proposed_version": "14.4.9", + "requested_version": "14.4.9", + "milestone": "Next Patch (34)", + "ready": True, + "blockers": [], + "warnings": [], + } + ], + } + ) + self.assertIn("| stable34 | 14.4.8 | v14.4.8 |", summary) + + +if __name__ == "__main__": + unittest.main() From 8609eb51ca6198c56fc2e33d9bf3580df9aa7fa2 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 20 Sep 2026 22:19:58 -0300 Subject: [PATCH 4/6] feat: derive release bump from conventional commits --- scripts/release_train_plan.py | 110 ++++++++++++++++++++++++++++++++-- 1 file changed, 104 insertions(+), 6 deletions(-) diff --git a/scripts/release_train_plan.py b/scripts/release_train_plan.py index 3fbb1f5..bc8ea72 100644 --- a/scripts/release_train_plan.py +++ b/scripts/release_train_plan.py @@ -16,6 +16,9 @@ SEMVER = re.compile(r"^(?P[0-9]+)\.(?P[0-9]+)\.(?P[0-9]+)$") STABLE = re.compile(r"^stable(?P[0-9]+)$") +CONVENTIONAL = re.compile( + r"^(?P[a-z][a-z0-9-]*)(?:\([^)]+\))?(?P!)?:\s+.+$" +) @dataclass(frozen=True) @@ -25,6 +28,8 @@ class ReleaseLine: current_version: str nextcloud_major: int previous_tag: str | None + release_kind: str + conventional_types: tuple[str, ...] proposed_version: str requested_version: str milestone: str @@ -105,6 +110,80 @@ def _next_patch(version: str) -> str: return f"{match.group('major')}.{match.group('minor')}.{int(match.group('patch')) + 1}" +def _next_minor(version: str) -> str: + match = SEMVER.fullmatch(version) + if match is None: + raise ValueError(f"invalid semantic version: {version}") + return f"{match.group('major')}.{int(match.group('minor')) + 1}.0" + + +def _conventional_commits_since( + repo: Path, + previous_tag: str | None, + branch: str, +) -> tuple[tuple[str, ...], bool]: + if previous_tag is None: + return (), False + + raw = _git( + repo, + "log", + "--format=%s%x1f%b%x1e", + f"{previous_tag}..{branch}", + ) + types: list[str] = [] + breaking = False + + for record in raw.split("\x1e"): + if not record.strip(): + continue + subject, _, body = record.partition("\x1f") + candidates = [subject, *body.splitlines()] + matched = None + for candidate in candidates: + match = CONVENTIONAL.fullmatch(candidate.strip()) + if match: + matched = match + break + if matched is None: + continue + + commit_type = matched.group("type") + types.append(commit_type) + if matched.group("breaking") or "BREAKING CHANGE:" in body: + breaking = True + + return tuple(types), breaking + + +def _propose_version( + repo: Path, + previous_tag: str | None, + branch: str, + current_version: str, +) -> tuple[str, str, tuple[str, ...], bool]: + if previous_tag is None: + return current_version, "major", (), False + + previous_version = _version_from_tag(previous_tag, current_version) + conventional_types, breaking = _conventional_commits_since( + repo, previous_tag, branch + ) + if "feat" in conventional_types: + return ( + _next_minor(previous_version), + "minor", + conventional_types, + breaking, + ) + return ( + _next_patch(previous_version), + "patch", + conventional_types, + breaking, + ) + + def _parse_json_mapping(value: str, name: str) -> dict[str, str]: try: parsed = json.loads(value) @@ -159,6 +238,8 @@ def build_release_train( current_version="", nextcloud_major=-1, previous_tag=None, + release_kind="", + conventional_types=(), proposed_version="", requested_version=version_overrides.get(branch, ""), milestone="", @@ -181,6 +262,8 @@ def build_release_train( current_version="", nextcloud_major=-1, previous_tag=None, + release_kind="", + conventional_types=(), proposed_version="", requested_version=version_overrides.get(branch, ""), milestone="", @@ -198,10 +281,21 @@ def build_release_train( ) previous_tag = _previous_release_tag(repo, branch) - previous_version = _version_from_tag(previous_tag, current_version) - proposed_version = _next_patch(previous_version) + ( + proposed_version, + release_kind, + conventional_types, + breaking_change, + ) = _propose_version(repo, previous_tag, branch, current_version) requested_version = version_overrides.get(branch, proposed_version) + if breaking_change: + warnings.append( + "breaking Conventional Commit marker detected; LibreSign policy " + "does not infer a major release from commits, so review whether " + "the change belongs in the current line" + ) + if not SEMVER.fullmatch(requested_version): blockers.append( f"requested version {requested_version!r} must use MAJOR.MINOR.PATCH" @@ -235,6 +329,8 @@ def build_release_train( current_version=current_version, nextcloud_major=nextcloud_major, previous_tag=previous_tag, + release_kind=release_kind, + conventional_types=conventional_types, proposed_version=proposed_version, requested_version=requested_version, milestone=milestone, @@ -254,6 +350,8 @@ def build_release_train( "current_version": line.current_version, "nextcloud_major": line.nextcloud_major, "previous_tag": line.previous_tag, + "release_kind": line.release_kind, + "conventional_types": list(line.conventional_types), "proposed_version": line.proposed_version, "requested_version": line.requested_version, "milestone": line.milestone, @@ -272,14 +370,14 @@ def render_summary(plan: dict[str, object]) -> str: "", f"Overall readiness: **{'ready' if plan['ready'] else 'blocked'}**", "", - "| Branch | Current | Previous tag | Proposed | Requested | Milestone | Status |", - "| --- | --- | --- | --- | --- | --- | --- |", + "| Branch | Current | Previous tag | Bump | Proposed | Requested | Milestone | Status |", + "| --- | --- | --- | --- | --- | --- | --- | --- |", ] for release in plan["releases"]: status = "ready" if release["ready"] else "blocked" lines.append( - "| {branch} | {current_version} | {previous_tag} | {proposed_version} | " - "{requested_version} | {milestone} | {status} |".format( + "| {branch} | {current_version} | {previous_tag} | {release_kind} | " + "{proposed_version} | {requested_version} | {milestone} | {status} |".format( previous_tag=release["previous_tag"] or "-", status=status, **release, From 4599ef56f66160498dad7addf3c5a84296c10b31 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 20 Sep 2026 22:20:00 -0300 Subject: [PATCH 5/6] test: cover conventional release version policy --- tests/test_release_train_plan.py | 67 +++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 2 deletions(-) diff --git a/tests/test_release_train_plan.py b/tests/test_release_train_plan.py index 59cca3c..071dcc1 100644 --- a/tests/test_release_train_plan.py +++ b/tests/test_release_train_plan.py @@ -10,6 +10,7 @@ from scripts.release_train_plan import ( _parse_appinfo, _parse_branches, + _propose_version, build_release_train, render_summary, ) @@ -51,7 +52,13 @@ def commit_release_line(self, repo: Path, branch: str, version: str, nc: int) -> git(repo, "checkout", "-B", branch) self.write_appinfo(repo, version, nc) git(repo, "add", "appinfo/info.xml") - git(repo, "commit", "-m", f"{branch} {version}") + git(repo, "commit", "-m", f"chore: prepare {branch} {version}") + + def commit_change(self, repo: Path, message: str, filename: str = "change.txt") -> None: + path = repo / filename + path.write_text(message + "\n", encoding="utf-8") + git(repo, "add", filename) + git(repo, "commit", "-m", message) def test_orders_stable_lines_oldest_first(self) -> None: with tempfile.TemporaryDirectory() as directory: @@ -76,11 +83,65 @@ def test_orders_stable_lines_oldest_first(self) -> None: plan["releases"][0]["proposed_version"], "14.4.9", ) + self.assertEqual(plan["releases"][0]["release_kind"], "patch") self.assertEqual( plan["releases"][1]["milestone"], "Next Patch (35)", ) + def test_feature_commit_proposes_minor_release(self) -> None: + with tempfile.TemporaryDirectory() as directory: + repo = self.init_repo(directory) + self.commit_release_line(repo, "stable35", "15.2.3", 35) + git(repo, "tag", "v15.2.3") + self.commit_change(repo, "feat: add signing policy") + + plan = build_release_train(repo, ("stable35",), {}) + + release = plan["releases"][0] + self.assertEqual(release["release_kind"], "minor") + self.assertEqual(release["proposed_version"], "15.3.0") + self.assertIn("feat", release["conventional_types"]) + + def test_fix_commit_proposes_patch_release(self) -> None: + with tempfile.TemporaryDirectory() as directory: + repo = self.init_repo(directory) + self.commit_release_line(repo, "stable35", "15.2.3", 35) + git(repo, "tag", "v15.2.3") + self.commit_change(repo, "fix: handle empty signature") + + plan = build_release_train(repo, ("stable35",), {}) + + release = plan["releases"][0] + self.assertEqual(release["release_kind"], "patch") + self.assertEqual(release["proposed_version"], "15.2.4") + + def test_first_release_of_new_line_uses_declared_major_version(self) -> None: + with tempfile.TemporaryDirectory() as directory: + repo = self.init_repo(directory) + self.commit_release_line(repo, "stable36", "16.0.0", 36) + + plan = build_release_train(repo, ("stable36",), {}) + + release = plan["releases"][0] + self.assertEqual(release["release_kind"], "major") + self.assertEqual(release["previous_tag"], None) + self.assertEqual(release["proposed_version"], "16.0.0") + + def test_breaking_marker_does_not_auto_increment_major(self) -> None: + with tempfile.TemporaryDirectory() as directory: + repo = self.init_repo(directory) + self.commit_release_line(repo, "stable35", "15.2.3", 35) + git(repo, "tag", "v15.2.3") + self.commit_change(repo, "feat!: replace signing workflow") + + plan = build_release_train(repo, ("stable35",), {}) + + release = plan["releases"][0] + self.assertEqual(release["release_kind"], "minor") + self.assertEqual(release["proposed_version"], "15.3.0") + self.assertTrue(any("breaking" in item for item in release["warnings"])) + def test_explicit_version_override_is_visible(self) -> None: with tempfile.TemporaryDirectory() as directory: repo = self.init_repo(directory) @@ -162,6 +223,8 @@ def test_summary_contains_publication_order_rows(self) -> None: "current_version": "14.4.8", "nextcloud_major": 34, "previous_tag": "v14.4.8", + "release_kind": "patch", + "conventional_types": ["fix"], "proposed_version": "14.4.9", "requested_version": "14.4.9", "milestone": "Next Patch (34)", @@ -172,7 +235,7 @@ def test_summary_contains_publication_order_rows(self) -> None: ], } ) - self.assertIn("| stable34 | 14.4.8 | v14.4.8 |", summary) + self.assertIn("| stable34 | 14.4.8 | v14.4.8 | patch |", summary) if __name__ == "__main__": From ad7ef13802cc9bdf4b01ee1f76697427dfa1f99f Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sun, 20 Sep 2026 22:21:39 -0300 Subject: [PATCH 6/6] test: keep translations and dependency bumps as patch releases --- tests/test_release_train_plan.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/test_release_train_plan.py b/tests/test_release_train_plan.py index 071dcc1..8c19c57 100644 --- a/tests/test_release_train_plan.py +++ b/tests/test_release_train_plan.py @@ -159,6 +159,35 @@ def test_explicit_version_override_is_visible(self) -> None: self.assertEqual(release["requested_version"], "15.3.0") self.assertIn("version override selected", release["warnings"][0]) + def test_translation_commit_remains_patch_release(self) -> None: + with tempfile.TemporaryDirectory() as directory: + repo = self.init_repo(directory) + self.commit_release_line(repo, "stable35", "15.2.3", 35) + git(repo, "tag", "v15.2.3") + self.commit_change(repo, "fix(l10n): Update translations from Transifex") + + plan = build_release_train(repo, ("stable35",), {}) + + release = plan["releases"][0] + self.assertEqual(release["release_kind"], "patch") + self.assertEqual(release["proposed_version"], "15.2.4") + + def test_dependency_bump_remains_patch_even_if_dependency_major_changes(self) -> None: + with tempfile.TemporaryDirectory() as directory: + repo = self.init_repo(directory) + self.commit_release_line(repo, "stable35", "15.2.3", 35) + git(repo, "tag", "v15.2.3") + self.commit_change( + repo, + "chore(deps-dev): Bump example/library from 1.0.0 to 2.0.0", + ) + + plan = build_release_train(repo, ("stable35",), {}) + + release = plan["releases"][0] + self.assertEqual(release["release_kind"], "patch") + self.assertEqual(release["proposed_version"], "15.2.4") + def test_existing_tag_blocks_release(self) -> None: with tempfile.TemporaryDirectory() as directory: repo = self.init_repo(directory)