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/tests/test_release_stable_select.py b/tests/test_release_stable_select.py new file mode 100644 index 0000000..3ec756b --- /dev/null +++ b/tests/test_release_stable_select.py @@ -0,0 +1,78 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +import importlib.util +import sys +import unittest +from pathlib import Path + +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) +sys.modules[SPEC.name] = MODULE +SPEC.loader.exec_module(MODULE) + +parse_stable_refs = MODULE.parse_stable_refs +resolve_state = MODULE.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()