From ab862219abbf7363062de4f0e42975cb5dd66ec1 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sat, 19 Sep 2026 22:57:25 -0300 Subject: [PATCH 01/16] feat: add testable Nextcloud release planner Signed-off-by: Vitor Mattos --- scripts/release_plan.py | 226 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 scripts/release_plan.py diff --git a/scripts/release_plan.py b/scripts/release_plan.py new file mode 100644 index 0000000..02dfd27 --- /dev/null +++ b/scripts/release_plan.py @@ -0,0 +1,226 @@ +#!/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 sys +from dataclasses import dataclass +from pathlib import Path +from urllib.parse import quote +from urllib.request import Request, urlopen +from xml.etree import ElementTree + + +SEMVER = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+$") + + +@dataclass(frozen=True) +class PlanInput: + version: str + stable_branch: str + current_ref: str + repository: str | None + appinfo: Path + changelog: Path + milestone: str | None + blocker_queries: tuple[str, ...] + + +def build_plan(config: PlanInput, token: str | None = None) -> dict[str, object]: + checks: list[dict[str, object]] = [ + _check_version(config.version), + _check_branch(config.stable_branch, config.current_ref), + _check_appinfo(config.appinfo, config.version), + _check_changelog(config.changelog, config.version), + ] + + if config.milestone: + checks.append( + _check_milestone( + repository=_required(config.repository, "repository"), + milestone=config.milestone, + token=_required(token, "GitHub token"), + ) + ) + + for query in config.blocker_queries: + checks.append( + _check_blocker_query( + repository=_required(config.repository, "repository"), + query=query, + token=_required(token, "GitHub token"), + ) + ) + + return { + "version": config.version, + "stable_branch": config.stable_branch, + "repository": config.repository, + "ready": all(bool(check["ok"]) for check in checks), + "checks": checks, + } + + +def _check_version(version: str) -> dict[str, object]: + return _result( + "version", + bool(SEMVER.fullmatch(version)), + f"release version is {version}", + "version must use MAJOR.MINOR.PATCH", + ) + + +def _check_branch(stable_branch: str, current_ref: str) -> dict[str, object]: + return _result( + "branch", + current_ref == stable_branch, + f"current ref matches stable branch {stable_branch}", + f"current ref {current_ref!r} does not match stable branch {stable_branch!r}", + ) + + +def _check_appinfo(path: Path, version: str) -> dict[str, object]: + if not path.is_file(): + return _result("appinfo", False, "", f"{path} does not exist") + + try: + root = ElementTree.parse(path).getroot() + except (ElementTree.ParseError, OSError) as error: + return _result("appinfo", False, "", f"cannot parse {path}: {error}") + + declared = root.findtext("version") + return _result( + "appinfo", + declared == version, + f"{path} declares version {version}", + f"{path} declares version {declared!r}, expected {version!r}", + ) + + +def _check_changelog(path: Path, version: str) -> dict[str, object]: + if not path.is_file(): + return _result("changelog", False, "", f"{path} does not exist") + + content = path.read_text(encoding="utf-8") + pattern = re.compile(rf"^##\s+{re.escape(version)}(?:\s+-\s+.+)?\s*$", re.MULTILINE) + return _result( + "changelog", + bool(pattern.search(content)), + f"{path} contains a section for {version}", + f"{path} does not contain a level-2 section for {version}", + ) + + +def _check_milestone(repository: str, milestone: str, token: str) -> dict[str, object]: + owner, name = _split_repository(repository) + milestones = _github_json( + f"https://api.github.com/repos/{owner}/{name}/milestones?state=all&per_page=100", + token, + ) + match = next((item for item in milestones if item.get("title") == milestone), None) + if match is None: + return _result("milestone", False, "", f"milestone {milestone!r} does not exist") + + open_issues = int(match.get("open_issues", 0)) + state = match.get("state") + ok = state == "closed" and open_issues == 0 + return _result( + "milestone", + ok, + f"milestone {milestone!r} is closed with no open issues", + f"milestone {milestone!r} has state={state!r} and open_issues={open_issues}", + ) + + +def _check_blocker_query(repository: str, query: str, token: str) -> dict[str, object]: + search = f"repo:{repository} is:open {query}".strip() + payload = _github_json( + "https://api.github.com/search/issues?q=" + quote(search), + token, + ) + count = int(payload.get("total_count", 0)) + return _result( + f"blocker:{query}", + count == 0, + f"no open items match {query!r}", + f"{count} open item(s) match {query!r}", + ) + + +def _github_json(url: str, token: str) -> object: + request = Request( + url, + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "User-Agent": "LibreCodeCoop/github-workflows", + "X-GitHub-Api-Version": "2022-11-28", + }, + ) + with urlopen(request, timeout=30) as response: + return json.load(response) + + +def _result(name: str, ok: bool, success: str, failure: str) -> dict[str, object]: + return {"name": name, "ok": ok, "message": success if ok else failure} + + +def _split_repository(repository: str) -> tuple[str, str]: + parts = repository.split("/", 1) + if len(parts) != 2 or not all(parts): + raise ValueError("repository must use OWNER/REPO format") + return parts[0], parts[1] + + +def _required(value: str | None, name: str) -> str: + if not value: + raise ValueError(f"{name} is required for GitHub checks") + return value + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--version", required=True) + parser.add_argument("--stable-branch", required=True) + parser.add_argument("--current-ref", required=True) + parser.add_argument("--repository") + parser.add_argument("--appinfo", type=Path, default=Path("appinfo/info.xml")) + parser.add_argument("--changelog", type=Path, default=Path("CHANGELOG.md")) + parser.add_argument("--milestone") + parser.add_argument("--blocker-query", action="append", default=[]) + parser.add_argument("--output", type=Path) + args = parser.parse_args() + + try: + plan = build_plan( + PlanInput( + version=args.version, + stable_branch=args.stable_branch, + current_ref=args.current_ref, + repository=args.repository, + appinfo=args.appinfo, + changelog=args.changelog, + milestone=args.milestone, + blocker_queries=tuple(args.blocker_query), + ), + token=os.environ.get("GITHUB_TOKEN"), + ) + except (ValueError, OSError) as error: + print(f"release-plan: {error}", file=sys.stderr) + return 2 + + rendered = json.dumps(plan, indent=2, sort_keys=True) + print(rendered) + if args.output: + args.output.write_text(rendered + "\n", encoding="utf-8") + + return 0 if plan["ready"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) From 231d9fb9b2085a25312b312a2ebcc9440d458a68 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sat, 19 Sep 2026 22:57:29 -0300 Subject: [PATCH 02/16] test: cover Nextcloud release planning Signed-off-by: Vitor Mattos --- tests/test_release_plan.py | 86 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 tests/test_release_plan.py diff --git a/tests/test_release_plan.py b/tests/test_release_plan.py new file mode 100644 index 0000000..0f36603 --- /dev/null +++ b/tests/test_release_plan.py @@ -0,0 +1,86 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +import tempfile +import unittest +from dataclasses import replace +from pathlib import Path +from unittest.mock import patch + +from scripts.release_plan import PlanInput, build_plan + + +class ReleasePlanTest(unittest.TestCase): + def fixture(self, directory: str, version: str = "16.0.0") -> PlanInput: + root = Path(directory) + appinfo = root / "appinfo/info.xml" + appinfo.parent.mkdir(parents=True) + appinfo.write_text( + f"{version}", + encoding="utf-8", + ) + changelog = root / "CHANGELOG.md" + changelog.write_text( + f"# Changelog\n\n## {version} - 2026-09-20\n\n### Fixed\n- Example\n", + encoding="utf-8", + ) + return PlanInput( + version=version, + stable_branch="stable36", + current_ref="stable36", + repository=None, + appinfo=appinfo, + changelog=changelog, + milestone=None, + blocker_queries=(), + ) + + def test_local_plan_is_ready(self) -> None: + with tempfile.TemporaryDirectory() as directory: + self.assertTrue(build_plan(self.fixture(directory))["ready"]) + + def test_rejects_version_mismatch(self) -> None: + with tempfile.TemporaryDirectory() as directory: + config = replace(self.fixture(directory), version="16.0.1") + plan = build_plan(config) + self.assertFalse(plan["ready"]) + self.assertIn("expected '16.0.1'", str(plan["checks"])) + + def test_rejects_wrong_branch(self) -> None: + with tempfile.TemporaryDirectory() as directory: + config = replace(self.fixture(directory), current_ref="main") + self.assertFalse(build_plan(config)["ready"]) + + def test_rejects_missing_changelog_entry(self) -> None: + with tempfile.TemporaryDirectory() as directory: + config = self.fixture(directory) + config.changelog.write_text("# Changelog\n", encoding="utf-8") + self.assertFalse(build_plan(config)["ready"]) + + @patch("scripts.release_plan._github_json") + def test_blocks_open_milestone(self, github_json) -> None: + github_json.return_value = [ + {"title": "16.0.0", "state": "open", "open_issues": 2} + ] + with tempfile.TemporaryDirectory() as directory: + config = replace( + self.fixture(directory), + repository="Example/app", + milestone="16.0.0", + ) + self.assertFalse(build_plan(config, token="token")["ready"]) + + @patch("scripts.release_plan._github_json") + def test_blocks_matching_open_items(self, github_json) -> None: + github_json.return_value = {"total_count": 1, "items": [{}]} + with tempfile.TemporaryDirectory() as directory: + config = replace( + self.fixture(directory), + repository="Example/app", + blocker_queries=('label:"backport pending"',), + ) + self.assertFalse(build_plan(config, token="token")["ready"]) + + +if __name__ == "__main__": + unittest.main() From a544732090eeb1b4bc99945303c8e9d11badcb7b Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sat, 19 Sep 2026 22:57:34 -0300 Subject: [PATCH 03/16] feat: add reusable Nextcloud release plan workflow Signed-off-by: Vitor Mattos --- .github/workflows/release-plan.yml | 111 +++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 .github/workflows/release-plan.yml diff --git a/.github/workflows/release-plan.yml b/.github/workflows/release-plan.yml new file mode 100644 index 0000000..86bca8f --- /dev/null +++ b/.github/workflows/release-plan.yml @@ -0,0 +1,111 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: Nextcloud release plan + +on: + workflow_call: + inputs: + version: + description: Release version in MAJOR.MINOR.PATCH form + required: true + type: string + stable_branch: + description: Stable branch that is allowed to release + required: true + type: string + milestone: + description: Milestone title that must be closed with no open issues + required: false + type: string + default: '' + blocker_queries: + description: JSON array of GitHub issue search fragments that must return zero open items + required: false + type: string + default: '[]' + appinfo_path: + description: Path to the Nextcloud app info.xml + required: false + type: string + default: appinfo/info.xml + changelog_path: + description: Path to the changelog + required: false + type: string + default: CHANGELOG.md + +permissions: + contents: read + issues: read + pull-requests: read + +jobs: + plan: + name: Release plan + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout caller + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Checkout workflow tooling + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: LibreCodeCoop/github-workflows + ref: ${{ github.workflow_sha }} + path: .github-workflows + persist-credentials: false + + - name: Validate blocker query input + env: + BLOCKER_QUERIES: ${{ inputs.blocker_queries }} + run: | + python3 - <<'PY' + import json + import os + + queries = json.loads(os.environ["BLOCKER_QUERIES"]) + if not isinstance(queries, list) or not all(isinstance(item, str) for item in queries): + raise SystemExit("blocker_queries must be a JSON array of strings") + PY + + - name: Build release plan + env: + GITHUB_TOKEN: ${{ github.token }} + BLOCKER_QUERIES: ${{ inputs.blocker_queries }} + run: | + args=( + --version "${{ inputs.version }}" + --stable-branch "${{ inputs.stable_branch }}" + --current-ref "${GITHUB_REF_NAME}" + --repository "${GITHUB_REPOSITORY}" + --appinfo "${{ inputs.appinfo_path }}" + --changelog "${{ inputs.changelog_path }}" + --output release-plan.json + ) + + if [[ -n "${{ inputs.milestone }}" ]]; then + args+=(--milestone "${{ inputs.milestone }}") + fi + + while IFS= read -r query; do + args+=(--blocker-query "$query") + done < <(python3 -c 'import json, os; [print(item) for item in json.loads(os.environ["BLOCKER_QUERIES"])]') + + python3 .github-workflows/scripts/release_plan.py "${args[@]}" + + - name: Add plan summary + if: always() + run: | + if [[ -f release-plan.json ]]; then + { + echo '## Release plan' + echo + echo '~~~json' + cat release-plan.json + echo '~~~' + } >> "$GITHUB_STEP_SUMMARY" + fi From 6568a598da9eb52549e8b6b450519be745682360 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sat, 19 Sep 2026 22:57:40 -0300 Subject: [PATCH 04/16] docs: document Nextcloud release planning Signed-off-by: Vitor Mattos --- docs/nextcloud-release.md | 44 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 docs/nextcloud-release.md diff --git a/docs/nextcloud-release.md b/docs/nextcloud-release.md new file mode 100644 index 0000000..6bf32f5 --- /dev/null +++ b/docs/nextcloud-release.md @@ -0,0 +1,44 @@ + + +# Nextcloud release planning + +The reusable release-plan workflow is intentionally non-mutating. It validates +release prerequisites before any tag, GitHub Release, signing or App Store +publication occurs. + +## Checks + +The first implementation validates: + +- semantic release version in `MAJOR.MINOR.PATCH` form; +- execution from the declared stable branch; +- `appinfo/info.xml` version matches the requested release; +- changelog contains a level-2 section for the requested version; +- optional milestone exists, is closed and has zero open issues; +- optional GitHub blocker queries return zero open issues or pull requests. + +Blocker queries are caller-owned. This keeps project conventions out of the +shared workflow. A caller can model pending backports with a label query without +making that label part of the reusable workflow contract. + +## Example caller + +```yaml +jobs: + release-plan: + uses: LibreCodeCoop/github-workflows/.github/workflows/release-plan.yml@ # v0.1.0 + with: + version: 16.0.0 + stable_branch: stable36 + milestone: 16.0.0 + blocker_queries: '["label:\"backport pending\""]' +``` + +The workflow only needs read permissions. Signing keys and App Store tokens are +deliberately not accepted by the planning stage. + +Publication will be implemented as a separate privileged workflow after the +planning contract is proven with LibreSign and at least one additional app. From eae68162d0a05a7b3b3ad1df16fabe0e1aa95d45 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sat, 19 Sep 2026 23:23:20 -0300 Subject: [PATCH 05/16] fix: avoid workflow input template injection --- .github/workflows/release-plan.yml | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release-plan.yml b/.github/workflows/release-plan.yml index 86bca8f..502a0e0 100644 --- a/.github/workflows/release-plan.yml +++ b/.github/workflows/release-plan.yml @@ -76,19 +76,24 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} BLOCKER_QUERIES: ${{ inputs.blocker_queries }} + RELEASE_VERSION: ${{ inputs.version }} + STABLE_BRANCH: ${{ inputs.stable_branch }} + APPINFO_PATH: ${{ inputs.appinfo_path }} + CHANGELOG_PATH: ${{ inputs.changelog_path }} + MILESTONE: ${{ inputs.milestone }} run: | args=( - --version "${{ inputs.version }}" - --stable-branch "${{ inputs.stable_branch }}" - --current-ref "${GITHUB_REF_NAME}" - --repository "${GITHUB_REPOSITORY}" - --appinfo "${{ inputs.appinfo_path }}" - --changelog "${{ inputs.changelog_path }}" + --version "$RELEASE_VERSION" + --stable-branch "$STABLE_BRANCH" + --current-ref "$GITHUB_REF_NAME" + --repository "$GITHUB_REPOSITORY" + --appinfo "$APPINFO_PATH" + --changelog "$CHANGELOG_PATH" --output release-plan.json ) - if [[ -n "${{ inputs.milestone }}" ]]; then - args+=(--milestone "${{ inputs.milestone }}") + if [[ -n "$MILESTONE" ]]; then + args+=(--milestone "$MILESTONE") fi while IFS= read -r query; do From 148cb1cfc5f3c26fab6bb7d2e9ce812457848881 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sat, 19 Sep 2026 23:37:14 -0300 Subject: [PATCH 06/16] refactor: move release input parsing into Python --- scripts/release_plan.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/scripts/release_plan.py b/scripts/release_plan.py index 02dfd27..348203b 100644 --- a/scripts/release_plan.py +++ b/scripts/release_plan.py @@ -183,6 +183,18 @@ def _required(value: str | None, name: str) -> str: return value +def parse_blocker_queries(value: str) -> tuple[str, ...]: + try: + queries = json.loads(value) + except json.JSONDecodeError as error: + raise ValueError(f"blocker queries must be valid JSON: {error.msg}") from error + + if not isinstance(queries, list) or not all(isinstance(item, str) for item in queries): + raise ValueError("blocker queries must be a JSON array of strings") + + return tuple(queries) + + def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--version", required=True) @@ -191,8 +203,8 @@ def main() -> int: parser.add_argument("--repository") parser.add_argument("--appinfo", type=Path, default=Path("appinfo/info.xml")) parser.add_argument("--changelog", type=Path, default=Path("CHANGELOG.md")) - parser.add_argument("--milestone") - parser.add_argument("--blocker-query", action="append", default=[]) + parser.add_argument("--milestone", default="") + parser.add_argument("--blocker-queries-json", default="[]") parser.add_argument("--output", type=Path) args = parser.parse_args() @@ -205,8 +217,8 @@ def main() -> int: repository=args.repository, appinfo=args.appinfo, changelog=args.changelog, - milestone=args.milestone, - blocker_queries=tuple(args.blocker_query), + milestone=args.milestone or None, + blocker_queries=parse_blocker_queries(args.blocker_queries_json), ), token=os.environ.get("GITHUB_TOKEN"), ) From 3e21b28c4d485b12205bb87483cab12c1e73186a Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sat, 19 Sep 2026 23:37:23 -0300 Subject: [PATCH 07/16] refactor: keep release workflow declarative --- .github/workflows/release-plan.yml | 40 +++++++----------------------- 1 file changed, 9 insertions(+), 31 deletions(-) diff --git a/.github/workflows/release-plan.yml b/.github/workflows/release-plan.yml index 502a0e0..ced8831 100644 --- a/.github/workflows/release-plan.yml +++ b/.github/workflows/release-plan.yml @@ -59,19 +59,6 @@ jobs: path: .github-workflows persist-credentials: false - - name: Validate blocker query input - env: - BLOCKER_QUERIES: ${{ inputs.blocker_queries }} - run: | - python3 - <<'PY' - import json - import os - - queries = json.loads(os.environ["BLOCKER_QUERIES"]) - if not isinstance(queries, list) or not all(isinstance(item, str) for item in queries): - raise SystemExit("blocker_queries must be a JSON array of strings") - PY - - name: Build release plan env: GITHUB_TOKEN: ${{ github.token }} @@ -82,25 +69,16 @@ jobs: CHANGELOG_PATH: ${{ inputs.changelog_path }} MILESTONE: ${{ inputs.milestone }} run: | - args=( - --version "$RELEASE_VERSION" - --stable-branch "$STABLE_BRANCH" - --current-ref "$GITHUB_REF_NAME" - --repository "$GITHUB_REPOSITORY" - --appinfo "$APPINFO_PATH" - --changelog "$CHANGELOG_PATH" + python3 .github-workflows/scripts/release_plan.py \ + --version "$RELEASE_VERSION" \ + --stable-branch "$STABLE_BRANCH" \ + --current-ref "$GITHUB_REF_NAME" \ + --repository "$GITHUB_REPOSITORY" \ + --appinfo "$APPINFO_PATH" \ + --changelog "$CHANGELOG_PATH" \ + --milestone "$MILESTONE" \ + --blocker-queries-json "$BLOCKER_QUERIES" \ --output release-plan.json - ) - - if [[ -n "$MILESTONE" ]]; then - args+=(--milestone "$MILESTONE") - fi - - while IFS= read -r query; do - args+=(--blocker-query "$query") - done < <(python3 -c 'import json, os; [print(item) for item in json.loads(os.environ["BLOCKER_QUERIES"])]') - - python3 .github-workflows/scripts/release_plan.py "${args[@]}" - name: Add plan summary if: always() From abbc587042cf5862ef9a54011dbeb4dc6a93436e Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sat, 19 Sep 2026 23:37:29 -0300 Subject: [PATCH 08/16] test: cover release blocker input parsing --- tests/test_release_plan.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/test_release_plan.py b/tests/test_release_plan.py index 0f36603..7f3f73c 100644 --- a/tests/test_release_plan.py +++ b/tests/test_release_plan.py @@ -7,7 +7,7 @@ from pathlib import Path from unittest.mock import patch -from scripts.release_plan import PlanInput, build_plan +from scripts.release_plan import PlanInput, build_plan, parse_blocker_queries class ReleasePlanTest(unittest.TestCase): @@ -81,6 +81,24 @@ def test_blocks_matching_open_items(self, github_json) -> None: ) self.assertFalse(build_plan(config, token="token")["ready"]) + def test_parses_blocker_queries_json(self) -> None: + self.assertEqual( + parse_blocker_queries('["label:backport", "is:pr label:blocker"]'), + ("label:backport", "is:pr label:blocker"), + ) + + def test_rejects_non_array_blocker_queries_json(self) -> None: + with self.assertRaisesRegex(ValueError, "JSON array of strings"): + parse_blocker_queries('{"query": "label:backport"}') + + def test_rejects_non_string_blocker_query(self) -> None: + with self.assertRaisesRegex(ValueError, "JSON array of strings"): + parse_blocker_queries('["label:backport", 42]') + + def test_rejects_invalid_blocker_queries_json(self) -> None: + with self.assertRaisesRegex(ValueError, "valid JSON"): + parse_blocker_queries('["unterminated"') + if __name__ == "__main__": unittest.main() From 0796386186ba37634e8c1f758e7e034ac7338182 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sat, 19 Sep 2026 23:42:26 -0300 Subject: [PATCH 09/16] refactor: encapsulate release plan as composite action --- actions/release-plan/action.yml | 71 +++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 actions/release-plan/action.yml diff --git a/actions/release-plan/action.yml b/actions/release-plan/action.yml new file mode 100644 index 0000000..927d5a3 --- /dev/null +++ b/actions/release-plan/action.yml @@ -0,0 +1,71 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: Nextcloud release plan +description: Validate whether a Nextcloud app release is ready + +inputs: + version: + description: Release version in MAJOR.MINOR.PATCH form + required: true + stable-branch: + description: Stable branch that is allowed to release + required: true + milestone: + description: Milestone title that must be closed with no open issues + required: false + default: '' + blocker-queries: + description: JSON array of GitHub issue search fragments that must return zero open items + required: false + default: '[]' + appinfo-path: + description: Path to the Nextcloud app info.xml + required: false + default: appinfo/info.xml + changelog-path: + description: Path to the changelog + required: false + default: CHANGELOG.md + github-token: + description: GitHub token used for milestone and blocker checks + required: true + +runs: + using: composite + steps: + - name: Build release plan + env: + GITHUB_TOKEN: ${{ inputs.github-token }} + RELEASE_VERSION: ${{ inputs.version }} + STABLE_BRANCH: ${{ inputs.stable-branch }} + MILESTONE: ${{ inputs.milestone }} + BLOCKER_QUERIES: ${{ inputs.blocker-queries }} + APPINFO_PATH: ${{ inputs.appinfo-path }} + CHANGELOG_PATH: ${{ inputs.changelog-path }} + shell: bash + run: | + python3 "$GITHUB_ACTION_PATH/../../scripts/release_plan.py" \ + --version "$RELEASE_VERSION" \ + --stable-branch "$STABLE_BRANCH" \ + --current-ref "$GITHUB_REF_NAME" \ + --repository "$GITHUB_REPOSITORY" \ + --appinfo "$APPINFO_PATH" \ + --changelog "$CHANGELOG_PATH" \ + --milestone "$MILESTONE" \ + --blocker-queries-json "$BLOCKER_QUERIES" \ + --output release-plan.json + + - name: Add plan summary + if: always() + shell: bash + run: | + if [[ -f release-plan.json ]]; then + { + echo '## Release plan' + echo + echo '~~~json' + cat release-plan.json + echo '~~~' + } >> "$GITHUB_STEP_SUMMARY" + fi From acc2b858724c7912d5d0cce3ac43eb5ab3cc8070 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sat, 19 Sep 2026 23:43:00 -0300 Subject: [PATCH 10/16] refactor: make release planner action-native --- scripts/release_plan.py | 68 ++++++++++++++++++++++++++++++++--------- 1 file changed, 54 insertions(+), 14 deletions(-) diff --git a/scripts/release_plan.py b/scripts/release_plan.py index 348203b..f1623a7 100644 --- a/scripts/release_plan.py +++ b/scripts/release_plan.py @@ -195,25 +195,65 @@ def parse_blocker_queries(value: str) -> tuple[str, ...]: return tuple(queries) +def _required_input(value: str | None, name: str) -> str: + if not value: + raise ValueError(f"{name} is required") + return value + + +def _write_summary(output: Path) -> None: + summary_path = os.environ.get("GITHUB_STEP_SUMMARY") + if not summary_path or not output.is_file(): + return + + summary = Path(summary_path) + with summary.open("a", encoding="utf-8") as stream: + stream.write("## Release plan\n\n") + stream.write("~~~json\n") + stream.write(output.read_text(encoding="utf-8")) + stream.write("~~~\n") + + def main() -> int: parser = argparse.ArgumentParser() - parser.add_argument("--version", required=True) - parser.add_argument("--stable-branch", required=True) - parser.add_argument("--current-ref", required=True) - parser.add_argument("--repository") - parser.add_argument("--appinfo", type=Path, default=Path("appinfo/info.xml")) - parser.add_argument("--changelog", type=Path, default=Path("CHANGELOG.md")) - parser.add_argument("--milestone", default="") - parser.add_argument("--blocker-queries-json", default="[]") - parser.add_argument("--output", type=Path) + parser.add_argument("--version", default=os.environ.get("RELEASE_PLAN_VERSION")) + parser.add_argument( + "--stable-branch", + default=os.environ.get("RELEASE_PLAN_STABLE_BRANCH"), + ) + parser.add_argument("--current-ref", default=os.environ.get("GITHUB_REF_NAME")) + parser.add_argument("--repository", default=os.environ.get("GITHUB_REPOSITORY")) + parser.add_argument( + "--appinfo", + type=Path, + default=Path(os.environ.get("RELEASE_PLAN_APPINFO_PATH", "appinfo/info.xml")), + ) + parser.add_argument( + "--changelog", + type=Path, + default=Path(os.environ.get("RELEASE_PLAN_CHANGELOG_PATH", "CHANGELOG.md")), + ) + parser.add_argument("--milestone", default=os.environ.get("RELEASE_PLAN_MILESTONE", "")) + parser.add_argument( + "--blocker-queries-json", + default=os.environ.get("RELEASE_PLAN_BLOCKER_QUERIES", "[]"), + ) + parser.add_argument( + "--output", + type=Path, + default=Path(os.environ.get("RELEASE_PLAN_OUTPUT", "release-plan.json")), + ) args = parser.parse_args() try: + version = _required_input(args.version, "version") + stable_branch = _required_input(args.stable_branch, "stable branch") + current_ref = _required_input(args.current_ref, "current ref") plan = build_plan( PlanInput( - version=args.version, - stable_branch=args.stable_branch, - current_ref=args.current_ref, + version=version, + stable_branch=stable_branch, + current_ref=current_ref, repository=args.repository, appinfo=args.appinfo, changelog=args.changelog, @@ -228,8 +268,8 @@ def main() -> int: rendered = json.dumps(plan, indent=2, sort_keys=True) print(rendered) - if args.output: - args.output.write_text(rendered + "\n", encoding="utf-8") + args.output.write_text(rendered + "\n", encoding="utf-8") + _write_summary(args.output) return 0 if plan["ready"] else 1 From c69c1dd7fdb7fc36710498aff270714c49381802 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sat, 19 Sep 2026 23:43:06 -0300 Subject: [PATCH 11/16] refactor: keep release action wrapper minimal --- actions/release-plan/action.yml | 40 +++++++-------------------------- 1 file changed, 8 insertions(+), 32 deletions(-) diff --git a/actions/release-plan/action.yml b/actions/release-plan/action.yml index 927d5a3..a61d808 100644 --- a/actions/release-plan/action.yml +++ b/actions/release-plan/action.yml @@ -35,37 +35,13 @@ runs: using: composite steps: - name: Build release plan + shell: bash env: GITHUB_TOKEN: ${{ inputs.github-token }} - RELEASE_VERSION: ${{ inputs.version }} - STABLE_BRANCH: ${{ inputs.stable-branch }} - MILESTONE: ${{ inputs.milestone }} - BLOCKER_QUERIES: ${{ inputs.blocker-queries }} - APPINFO_PATH: ${{ inputs.appinfo-path }} - CHANGELOG_PATH: ${{ inputs.changelog-path }} - shell: bash - run: | - python3 "$GITHUB_ACTION_PATH/../../scripts/release_plan.py" \ - --version "$RELEASE_VERSION" \ - --stable-branch "$STABLE_BRANCH" \ - --current-ref "$GITHUB_REF_NAME" \ - --repository "$GITHUB_REPOSITORY" \ - --appinfo "$APPINFO_PATH" \ - --changelog "$CHANGELOG_PATH" \ - --milestone "$MILESTONE" \ - --blocker-queries-json "$BLOCKER_QUERIES" \ - --output release-plan.json - - - name: Add plan summary - if: always() - shell: bash - run: | - if [[ -f release-plan.json ]]; then - { - echo '## Release plan' - echo - echo '~~~json' - cat release-plan.json - echo '~~~' - } >> "$GITHUB_STEP_SUMMARY" - fi + RELEASE_PLAN_VERSION: ${{ inputs.version }} + RELEASE_PLAN_STABLE_BRANCH: ${{ inputs.stable-branch }} + RELEASE_PLAN_MILESTONE: ${{ inputs.milestone }} + RELEASE_PLAN_BLOCKER_QUERIES: ${{ inputs.blocker-queries }} + RELEASE_PLAN_APPINFO_PATH: ${{ inputs.appinfo-path }} + RELEASE_PLAN_CHANGELOG_PATH: ${{ inputs.changelog-path }} + run: python3 "$GITHUB_ACTION_PATH/../../scripts/release_plan.py" From bbbaaf57b7803f2bbc8c9e0bb3981cf6e5fa79fc Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sat, 19 Sep 2026 23:43:10 -0300 Subject: [PATCH 12/16] refactor: delegate release planning to composite action --- .github/workflows/release-plan.yml | 41 +++++++----------------------- 1 file changed, 9 insertions(+), 32 deletions(-) diff --git a/.github/workflows/release-plan.yml b/.github/workflows/release-plan.yml index ced8831..02c9c66 100644 --- a/.github/workflows/release-plan.yml +++ b/.github/workflows/release-plan.yml @@ -60,35 +60,12 @@ jobs: persist-credentials: false - name: Build release plan - env: - GITHUB_TOKEN: ${{ github.token }} - BLOCKER_QUERIES: ${{ inputs.blocker_queries }} - RELEASE_VERSION: ${{ inputs.version }} - STABLE_BRANCH: ${{ inputs.stable_branch }} - APPINFO_PATH: ${{ inputs.appinfo_path }} - CHANGELOG_PATH: ${{ inputs.changelog_path }} - MILESTONE: ${{ inputs.milestone }} - run: | - python3 .github-workflows/scripts/release_plan.py \ - --version "$RELEASE_VERSION" \ - --stable-branch "$STABLE_BRANCH" \ - --current-ref "$GITHUB_REF_NAME" \ - --repository "$GITHUB_REPOSITORY" \ - --appinfo "$APPINFO_PATH" \ - --changelog "$CHANGELOG_PATH" \ - --milestone "$MILESTONE" \ - --blocker-queries-json "$BLOCKER_QUERIES" \ - --output release-plan.json - - - name: Add plan summary - if: always() - run: | - if [[ -f release-plan.json ]]; then - { - echo '## Release plan' - echo - echo '~~~json' - cat release-plan.json - echo '~~~' - } >> "$GITHUB_STEP_SUMMARY" - fi + uses: ./.github-workflows/actions/release-plan + with: + version: ${{ inputs.version }} + stable-branch: ${{ inputs.stable_branch }} + milestone: ${{ inputs.milestone }} + blocker-queries: ${{ inputs.blocker_queries }} + appinfo-path: ${{ inputs.appinfo_path }} + changelog-path: ${{ inputs.changelog_path }} + github-token: ${{ github.token }} From d34f21b5d02965a925fac064b1935b4548c8b6f6 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sat, 19 Sep 2026 23:43:20 -0300 Subject: [PATCH 13/16] test: cover release action environment contract --- tests/test_release_plan.py | 41 +++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/tests/test_release_plan.py b/tests/test_release_plan.py index 7f3f73c..5fc9bdb 100644 --- a/tests/test_release_plan.py +++ b/tests/test_release_plan.py @@ -1,13 +1,16 @@ # SPDX-FileCopyrightText: 2026 LibreCode coop and contributors # SPDX-License-Identifier: AGPL-3.0-or-later +import json +import os +import sys import tempfile import unittest from dataclasses import replace from pathlib import Path from unittest.mock import patch -from scripts.release_plan import PlanInput, build_plan, parse_blocker_queries +from scripts.release_plan import PlanInput, build_plan, main, parse_blocker_queries class ReleasePlanTest(unittest.TestCase): @@ -99,6 +102,42 @@ def test_rejects_invalid_blocker_queries_json(self) -> None: with self.assertRaisesRegex(ValueError, "valid JSON"): parse_blocker_queries('["unterminated"') + def test_main_reads_action_environment_and_writes_summary(self) -> None: + with tempfile.TemporaryDirectory() as directory: + config = self.fixture(directory) + output = Path(directory) / "release-plan.json" + summary = Path(directory) / "summary.md" + env = { + "RELEASE_PLAN_VERSION": config.version, + "RELEASE_PLAN_STABLE_BRANCH": config.stable_branch, + "RELEASE_PLAN_APPINFO_PATH": str(config.appinfo), + "RELEASE_PLAN_CHANGELOG_PATH": str(config.changelog), + "RELEASE_PLAN_BLOCKER_QUERIES": "[]", + "RELEASE_PLAN_OUTPUT": str(output), + "GITHUB_REF_NAME": config.current_ref, + "GITHUB_REPOSITORY": "Example/app", + "GITHUB_STEP_SUMMARY": str(summary), + } + + with ( + patch.dict(os.environ, env, clear=True), + patch.object(sys, "argv", ["release_plan.py"]), + ): + self.assertEqual(main(), 0) + + plan = json.loads(output.read_text(encoding="utf-8")) + self.assertTrue(plan["ready"]) + summary_content = summary.read_text(encoding="utf-8") + self.assertIn("## Release plan", summary_content) + self.assertIn('"ready": true', summary_content) + + def test_main_rejects_missing_required_action_environment(self) -> None: + with ( + patch.dict(os.environ, {}, clear=True), + patch.object(sys, "argv", ["release_plan.py"]), + ): + self.assertEqual(main(), 2) + if __name__ == "__main__": unittest.main() From 95038fd2dd00f3ec35b0d1acaad47e5c548af52e Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sat, 19 Sep 2026 23:43:28 -0300 Subject: [PATCH 14/16] docs: explain release planner action boundary --- docs/nextcloud-release.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/nextcloud-release.md b/docs/nextcloud-release.md index 6bf32f5..b39fd29 100644 --- a/docs/nextcloud-release.md +++ b/docs/nextcloud-release.md @@ -9,6 +9,23 @@ The reusable release-plan workflow is intentionally non-mutating. It validates release prerequisites before any tag, GitHub Release, signing or App Store publication occurs. +## Architecture + +The reusable workflow owns orchestration concerns: permissions, runner selection +and checking out the caller plus the workflow tooling repository. + +The release-plan operation itself is exposed as the local composite action +`actions/release-plan`. The action maps its declared inputs to a small, +namespaced environment contract and invokes `scripts/release_plan.py`. + +Business rules, input parsing, GitHub API checks, exit status and step-summary +rendering live in the Python script and are covered by unit tests. The workflow +does not contain release decision logic. + +This follows GitHub's distinction between reusable workflows, which reuse whole +workflow/job structures, and composite actions, which encapsulate a reusable +sequence of steps within a job. + ## Checks The first implementation validates: From d92d475ebd6205f584649a98edd30718c22b7ec4 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sat, 19 Sep 2026 23:49:20 -0300 Subject: [PATCH 15/16] fix: use self-repository action syntax --- .github/workflows/release-plan.yml | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/.github/workflows/release-plan.yml b/.github/workflows/release-plan.yml index 02c9c66..f25fcf3 100644 --- a/.github/workflows/release-plan.yml +++ b/.github/workflows/release-plan.yml @@ -51,16 +51,8 @@ jobs: with: persist-credentials: false - - name: Checkout workflow tooling - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: LibreCodeCoop/github-workflows - ref: ${{ github.workflow_sha }} - path: .github-workflows - persist-credentials: false - - name: Build release plan - uses: ./.github-workflows/actions/release-plan + uses: $/actions/release-plan with: version: ${{ inputs.version }} stable-branch: ${{ inputs.stable_branch }} From 4b0c5285b41649f54fca255dd6a8529132ad1a5e Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Sat, 19 Sep 2026 23:52:48 -0300 Subject: [PATCH 16/16] ci: ignore actionlint self-repository false positive --- .github/workflows/actionlint.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/actionlint.yml b/.github/workflows/actionlint.yml index 964d2ab..a709bf8 100644 --- a/.github/workflows/actionlint.yml +++ b/.github/workflows/actionlint.yml @@ -28,3 +28,8 @@ jobs: with: version: 1.7.12 shellcheck: true + # actionlint does not support GitHub's $/ self-repository syntax yet. + # Remove this ignore when https://github.com/rhysd/actionlint/issues/711 is fixed. + flags: >- + -ignore + specifying.action.*\$/.*invalid.format.because.ref.is.missing