From a2260d63aa4e8ab085e6c708694432f207f5c2f3 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 21 Sep 2026 20:53:51 -0300 Subject: [PATCH 1/3] feat: add tested latest stable branch selector Signed-off-by: Vitor Mattos --- VERSION | 2 +- actions/release-stable-select/action.yml | 43 +++++++ actions/release-stable-select/resolve.py | 130 ++++++++++++++++++++++ actions/release_stable_select/__init__.py | 2 + actions/release_stable_select/resolve.py | 130 ++++++++++++++++++++++ tests/test_release_stable_select.py | 66 +++++++++++ 6 files changed, 372 insertions(+), 1 deletion(-) create mode 100644 actions/release-stable-select/action.yml create mode 100644 actions/release-stable-select/resolve.py create mode 100644 actions/release_stable_select/__init__.py create mode 100644 actions/release_stable_select/resolve.py create mode 100644 tests/test_release_stable_select.py diff --git a/VERSION b/VERSION index 8f0916f..a918a2a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.0 +0.6.0 diff --git a/actions/release-stable-select/action.yml b/actions/release-stable-select/action.yml new file mode 100644 index 0000000..6806f30 --- /dev/null +++ b/actions/release-stable-select/action.yml @@ -0,0 +1,43 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: Select latest stable release branch +description: Resolve exact stable branches and report whether the current branch is the highest maintained stable. + +inputs: + repository: + description: Repository in owner/name form. Defaults to the current repository. + required: false + default: '' + branch: + description: Branch to compare. Defaults to the current GitHub ref name. + required: false + default: '' + +outputs: + current-branch: + description: Branch being evaluated. + value: ${{ steps.resolve.outputs.current_branch }} + current-major: + description: Numeric stable release line for the current branch, or empty when it is not stable. + value: ${{ steps.resolve.outputs.current_major }} + latest-branch: + description: Highest exact stable branch found in the repository. + value: ${{ steps.resolve.outputs.latest_branch }} + latest-major: + description: Numeric release line of the latest stable branch. + value: ${{ steps.resolve.outputs.latest_major }} + is-latest: + description: Whether the current branch is exactly the latest stable branch. + value: ${{ steps.resolve.outputs.is_latest }} + +runs: + using: composite + steps: + - id: resolve + name: Resolve latest stable branch + shell: bash + env: + INPUT_REPOSITORY: ${{ inputs.repository }} + INPUT_BRANCH: ${{ inputs.branch }} + run: python3 "$GITHUB_ACTION_PATH/resolve.py" diff --git a/actions/release-stable-select/resolve.py b/actions/release-stable-select/resolve.py new file mode 100644 index 0000000..fda3161 --- /dev/null +++ b/actions/release-stable-select/resolve.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +from __future__ import annotations + +import os +import re +import subprocess +from dataclasses import dataclass +from pathlib import Path + +REPOSITORY_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") +STABLE_REF_RE = re.compile(r"^refs/heads/(stable([1-9][0-9]*))$") +STABLE_BRANCH_RE = re.compile(r"^stable([1-9][0-9]*)$") + + +@dataclass(frozen=True) +class StableState: + current_branch: str + current_major: int | None + latest_branch: str | None + latest_major: int | None + + @property + def is_latest(self) -> bool: + return self.latest_branch is not None and self.current_branch == self.latest_branch + + +def parse_stable_refs(output: str) -> dict[int, str]: + branches: dict[int, str] = {} + for raw_line in output.splitlines(): + parts = raw_line.strip().split() + if len(parts) != 2: + continue + match = STABLE_REF_RE.fullmatch(parts[1]) + if match is None: + continue + branch = match.group(1) + major = int(match.group(2)) + branches[major] = branch + return branches + + +def resolve_state(current_branch: str, branches: dict[int, str]) -> StableState: + current_match = STABLE_BRANCH_RE.fullmatch(current_branch) + current_major = int(current_match.group(1)) if current_match else None + + if not branches: + return StableState(current_branch, current_major, None, None) + + latest_major = max(branches) + return StableState( + current_branch=current_branch, + current_major=current_major, + latest_branch=branches[latest_major], + latest_major=latest_major, + ) + + +def list_remote_stable_refs(repository: str) -> str: + if REPOSITORY_RE.fullmatch(repository) is None: + raise ValueError(f"invalid repository: {repository!r}") + + result = subprocess.run( + [ + "git", + "ls-remote", + "--heads", + f"https://github.com/{repository}.git", + "refs/heads/stable*", + ], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + return result.stdout + + +def write_output(name: str, value: str) -> None: + output = os.environ.get("GITHUB_OUTPUT") + if output: + with Path(output).open("a", encoding="utf-8") as handle: + handle.write(f"{name}={value}\n") + + +def write_summary(state: StableState) -> None: + summary = os.environ.get("GITHUB_STEP_SUMMARY") + if not summary: + return + + current_major = str(state.current_major) if state.current_major is not None else "n/a" + latest_branch = state.latest_branch or "none" + latest_major = str(state.latest_major) if state.latest_major is not None else "n/a" + with Path(summary).open("a", encoding="utf-8") as handle: + handle.write( + "### Stable release branch selection\n\n" + f"- Current branch: `{state.current_branch}`\n" + f"- Current release line: `{current_major}`\n" + f"- Latest stable branch: `{latest_branch}`\n" + f"- Latest release line: `{latest_major}`\n" + f"- Publish nightly: `{str(state.is_latest).lower()}`\n" + ) + + +def main() -> int: + repository = os.environ.get("INPUT_REPOSITORY") or os.environ.get("GITHUB_REPOSITORY", "") + branch = os.environ.get("INPUT_BRANCH") or os.environ.get("GITHUB_REF_NAME", "") + + refs = parse_stable_refs(list_remote_stable_refs(repository)) + state = resolve_state(branch, refs) + + print(f"Current branch: {state.current_branch}") + print(f"Current release line: {state.current_major if state.current_major is not None else 'n/a'}") + print(f"Latest stable branch: {state.latest_branch or 'none'}") + print(f"Latest release line: {state.latest_major if state.latest_major is not None else 'n/a'}") + print(f"Publish nightly: {str(state.is_latest).lower()}") + + write_output("current_branch", state.current_branch) + write_output("current_major", "" if state.current_major is None else str(state.current_major)) + write_output("latest_branch", state.latest_branch or "") + write_output("latest_major", "" if state.latest_major is None else str(state.latest_major)) + write_output("is_latest", str(state.is_latest).lower()) + write_summary(state) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/actions/release_stable_select/__init__.py b/actions/release_stable_select/__init__.py new file mode 100644 index 0000000..a7eb745 --- /dev/null +++ b/actions/release_stable_select/__init__.py @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/actions/release_stable_select/resolve.py b/actions/release_stable_select/resolve.py new file mode 100644 index 0000000..fda3161 --- /dev/null +++ b/actions/release_stable_select/resolve.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +from __future__ import annotations + +import os +import re +import subprocess +from dataclasses import dataclass +from pathlib import Path + +REPOSITORY_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") +STABLE_REF_RE = re.compile(r"^refs/heads/(stable([1-9][0-9]*))$") +STABLE_BRANCH_RE = re.compile(r"^stable([1-9][0-9]*)$") + + +@dataclass(frozen=True) +class StableState: + current_branch: str + current_major: int | None + latest_branch: str | None + latest_major: int | None + + @property + def is_latest(self) -> bool: + return self.latest_branch is not None and self.current_branch == self.latest_branch + + +def parse_stable_refs(output: str) -> dict[int, str]: + branches: dict[int, str] = {} + for raw_line in output.splitlines(): + parts = raw_line.strip().split() + if len(parts) != 2: + continue + match = STABLE_REF_RE.fullmatch(parts[1]) + if match is None: + continue + branch = match.group(1) + major = int(match.group(2)) + branches[major] = branch + return branches + + +def resolve_state(current_branch: str, branches: dict[int, str]) -> StableState: + current_match = STABLE_BRANCH_RE.fullmatch(current_branch) + current_major = int(current_match.group(1)) if current_match else None + + if not branches: + return StableState(current_branch, current_major, None, None) + + latest_major = max(branches) + return StableState( + current_branch=current_branch, + current_major=current_major, + latest_branch=branches[latest_major], + latest_major=latest_major, + ) + + +def list_remote_stable_refs(repository: str) -> str: + if REPOSITORY_RE.fullmatch(repository) is None: + raise ValueError(f"invalid repository: {repository!r}") + + result = subprocess.run( + [ + "git", + "ls-remote", + "--heads", + f"https://github.com/{repository}.git", + "refs/heads/stable*", + ], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + return result.stdout + + +def write_output(name: str, value: str) -> None: + output = os.environ.get("GITHUB_OUTPUT") + if output: + with Path(output).open("a", encoding="utf-8") as handle: + handle.write(f"{name}={value}\n") + + +def write_summary(state: StableState) -> None: + summary = os.environ.get("GITHUB_STEP_SUMMARY") + if not summary: + return + + current_major = str(state.current_major) if state.current_major is not None else "n/a" + latest_branch = state.latest_branch or "none" + latest_major = str(state.latest_major) if state.latest_major is not None else "n/a" + with Path(summary).open("a", encoding="utf-8") as handle: + handle.write( + "### Stable release branch selection\n\n" + f"- Current branch: `{state.current_branch}`\n" + f"- Current release line: `{current_major}`\n" + f"- Latest stable branch: `{latest_branch}`\n" + f"- Latest release line: `{latest_major}`\n" + f"- Publish nightly: `{str(state.is_latest).lower()}`\n" + ) + + +def main() -> int: + repository = os.environ.get("INPUT_REPOSITORY") or os.environ.get("GITHUB_REPOSITORY", "") + branch = os.environ.get("INPUT_BRANCH") or os.environ.get("GITHUB_REF_NAME", "") + + refs = parse_stable_refs(list_remote_stable_refs(repository)) + state = resolve_state(branch, refs) + + print(f"Current branch: {state.current_branch}") + print(f"Current release line: {state.current_major if state.current_major is not None else 'n/a'}") + print(f"Latest stable branch: {state.latest_branch or 'none'}") + print(f"Latest release line: {state.latest_major if state.latest_major is not None else 'n/a'}") + print(f"Publish nightly: {str(state.is_latest).lower()}") + + write_output("current_branch", state.current_branch) + write_output("current_major", "" if state.current_major is None else str(state.current_major)) + write_output("latest_branch", state.latest_branch or "") + write_output("latest_major", "" if state.latest_major is None else str(state.latest_major)) + write_output("is_latest", str(state.is_latest).lower()) + write_summary(state) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_release_stable_select.py b/tests/test_release_stable_select.py new file mode 100644 index 0000000..9c707ea --- /dev/null +++ b/tests/test_release_stable_select.py @@ -0,0 +1,66 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +import unittest + +from actions.release_stable_select.resolve import parse_stable_refs, resolve_state + + +class ReleaseStableSelectTest(unittest.TestCase): + def test_selects_highest_numeric_stable_branch(self) -> None: + refs = parse_stable_refs( + "a\trefs/heads/stable9\n" + "b\trefs/heads/stable35\n" + "c\trefs/heads/stable100\n" + "d\trefs/heads/stable34\n" + ) + + state = resolve_state("stable35", refs) + + self.assertEqual(100, state.latest_major) + self.assertEqual("stable100", state.latest_branch) + self.assertEqual(35, state.current_major) + self.assertFalse(state.is_latest) + + def test_current_latest_branch_is_publishable(self) -> None: + refs = parse_stable_refs( + "a\trefs/heads/stable33\n" + "b\trefs/heads/stable34\n" + "c\trefs/heads/stable35\n" + ) + + state = resolve_state("stable35", refs) + + self.assertEqual(35, state.latest_major) + self.assertEqual("stable35", state.latest_branch) + self.assertTrue(state.is_latest) + + def test_ignores_non_exact_stable_names(self) -> None: + refs = parse_stable_refs( + "a\trefs/heads/stable35\n" + "b\trefs/heads/stable36-test\n" + "c\trefs/heads/stable-next\n" + "d\trefs/heads/stable035\n" + "e\trefs/heads/notstable99\n" + ) + + self.assertEqual({35: "stable35"}, refs) + + def test_non_stable_current_branch_never_matches(self) -> None: + state = resolve_state("main", {35: "stable35"}) + + self.assertIsNone(state.current_major) + self.assertEqual("stable35", state.latest_branch) + self.assertFalse(state.is_latest) + + def test_no_stable_branch_fails_closed(self) -> None: + state = resolve_state("stable35", {}) + + self.assertEqual(35, state.current_major) + self.assertIsNone(state.latest_branch) + self.assertIsNone(state.latest_major) + self.assertFalse(state.is_latest) + + +if __name__ == "__main__": + unittest.main() From 521ae101cccc1e931c3f37b84fab8594f98bac34 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 21 Sep 2026 20:54:16 -0300 Subject: [PATCH 2/3] test: exercise stable selector from its action source Signed-off-by: Vitor Mattos --- actions/release_stable_select/__init__.py | 2 - actions/release_stable_select/resolve.py | 130 ---------------------- tests/test_release_stable_select.py | 12 +- 3 files changed, 11 insertions(+), 133 deletions(-) delete mode 100644 actions/release_stable_select/__init__.py delete mode 100644 actions/release_stable_select/resolve.py diff --git a/actions/release_stable_select/__init__.py b/actions/release_stable_select/__init__.py deleted file mode 100644 index a7eb745..0000000 --- a/actions/release_stable_select/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors -# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/actions/release_stable_select/resolve.py b/actions/release_stable_select/resolve.py deleted file mode 100644 index fda3161..0000000 --- a/actions/release_stable_select/resolve.py +++ /dev/null @@ -1,130 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors -# SPDX-License-Identifier: AGPL-3.0-or-later - -from __future__ import annotations - -import os -import re -import subprocess -from dataclasses import dataclass -from pathlib import Path - -REPOSITORY_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") -STABLE_REF_RE = re.compile(r"^refs/heads/(stable([1-9][0-9]*))$") -STABLE_BRANCH_RE = re.compile(r"^stable([1-9][0-9]*)$") - - -@dataclass(frozen=True) -class StableState: - current_branch: str - current_major: int | None - latest_branch: str | None - latest_major: int | None - - @property - def is_latest(self) -> bool: - return self.latest_branch is not None and self.current_branch == self.latest_branch - - -def parse_stable_refs(output: str) -> dict[int, str]: - branches: dict[int, str] = {} - for raw_line in output.splitlines(): - parts = raw_line.strip().split() - if len(parts) != 2: - continue - match = STABLE_REF_RE.fullmatch(parts[1]) - if match is None: - continue - branch = match.group(1) - major = int(match.group(2)) - branches[major] = branch - return branches - - -def resolve_state(current_branch: str, branches: dict[int, str]) -> StableState: - current_match = STABLE_BRANCH_RE.fullmatch(current_branch) - current_major = int(current_match.group(1)) if current_match else None - - if not branches: - return StableState(current_branch, current_major, None, None) - - latest_major = max(branches) - return StableState( - current_branch=current_branch, - current_major=current_major, - latest_branch=branches[latest_major], - latest_major=latest_major, - ) - - -def list_remote_stable_refs(repository: str) -> str: - if REPOSITORY_RE.fullmatch(repository) is None: - raise ValueError(f"invalid repository: {repository!r}") - - result = subprocess.run( - [ - "git", - "ls-remote", - "--heads", - f"https://github.com/{repository}.git", - "refs/heads/stable*", - ], - check=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - return result.stdout - - -def write_output(name: str, value: str) -> None: - output = os.environ.get("GITHUB_OUTPUT") - if output: - with Path(output).open("a", encoding="utf-8") as handle: - handle.write(f"{name}={value}\n") - - -def write_summary(state: StableState) -> None: - summary = os.environ.get("GITHUB_STEP_SUMMARY") - if not summary: - return - - current_major = str(state.current_major) if state.current_major is not None else "n/a" - latest_branch = state.latest_branch or "none" - latest_major = str(state.latest_major) if state.latest_major is not None else "n/a" - with Path(summary).open("a", encoding="utf-8") as handle: - handle.write( - "### Stable release branch selection\n\n" - f"- Current branch: `{state.current_branch}`\n" - f"- Current release line: `{current_major}`\n" - f"- Latest stable branch: `{latest_branch}`\n" - f"- Latest release line: `{latest_major}`\n" - f"- Publish nightly: `{str(state.is_latest).lower()}`\n" - ) - - -def main() -> int: - repository = os.environ.get("INPUT_REPOSITORY") or os.environ.get("GITHUB_REPOSITORY", "") - branch = os.environ.get("INPUT_BRANCH") or os.environ.get("GITHUB_REF_NAME", "") - - refs = parse_stable_refs(list_remote_stable_refs(repository)) - state = resolve_state(branch, refs) - - print(f"Current branch: {state.current_branch}") - print(f"Current release line: {state.current_major if state.current_major is not None else 'n/a'}") - print(f"Latest stable branch: {state.latest_branch or 'none'}") - print(f"Latest release line: {state.latest_major if state.latest_major is not None else 'n/a'}") - print(f"Publish nightly: {str(state.is_latest).lower()}") - - write_output("current_branch", state.current_branch) - write_output("current_major", "" if state.current_major is None else str(state.current_major)) - write_output("latest_branch", state.latest_branch or "") - write_output("latest_major", "" if state.latest_major is None else str(state.latest_major)) - write_output("is_latest", str(state.is_latest).lower()) - write_summary(state) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests/test_release_stable_select.py b/tests/test_release_stable_select.py index 9c707ea..44ba206 100644 --- a/tests/test_release_stable_select.py +++ b/tests/test_release_stable_select.py @@ -1,9 +1,19 @@ # SPDX-FileCopyrightText: 2026 LibreCode coop and contributors # SPDX-License-Identifier: AGPL-3.0-or-later +import importlib.util import unittest +from pathlib import Path -from actions.release_stable_select.resolve import parse_stable_refs, resolve_state +ROOT = Path(__file__).resolve().parents[1] +MODULE_PATH = ROOT / "actions" / "release-stable-select" / "resolve.py" +SPEC = importlib.util.spec_from_file_location("release_stable_select", MODULE_PATH) +assert SPEC is not None and SPEC.loader is not None +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + +parse_stable_refs = MODULE.parse_stable_refs +resolve_state = MODULE.resolve_state class ReleaseStableSelectTest(unittest.TestCase): From 60b2d6c3dbb6d39fde7b52bbc5798e0869322111 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 21 Sep 2026 20:54:59 -0300 Subject: [PATCH 3/3] test: load stable selector module correctly Signed-off-by: Vitor Mattos --- tests/test_release_stable_select.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_release_stable_select.py b/tests/test_release_stable_select.py index 44ba206..3ec756b 100644 --- a/tests/test_release_stable_select.py +++ b/tests/test_release_stable_select.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: AGPL-3.0-or-later import importlib.util +import sys import unittest from pathlib import Path @@ -10,6 +11,7 @@ SPEC = importlib.util.spec_from_file_location("release_stable_select", MODULE_PATH) assert SPEC is not None and SPEC.loader is not None MODULE = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = MODULE SPEC.loader.exec_module(MODULE) parse_stable_refs = MODULE.parse_stable_refs