Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.5.0
0.6.0
43 changes: 43 additions & 0 deletions actions/release-stable-select/action.yml
Original file line number Diff line number Diff line change
@@ -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<N> 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<N>.
value: ${{ steps.resolve.outputs.current_major }}
latest-branch:
description: Highest exact stable<N> 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"
130 changes: 130 additions & 0 deletions actions/release-stable-select/resolve.py
Original file line number Diff line number Diff line change
@@ -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())
78 changes: 78 additions & 0 deletions tests/test_release_stable_select.py
Original file line number Diff line number Diff line change
@@ -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()
Loading