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 diff --git a/.github/workflows/release-plan.yml b/.github/workflows/release-plan.yml new file mode 100644 index 0000000..f25fcf3 --- /dev/null +++ b/.github/workflows/release-plan.yml @@ -0,0 +1,63 @@ +# 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: Build release plan + uses: $/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 }} diff --git a/actions/release-plan/action.yml b/actions/release-plan/action.yml new file mode 100644 index 0000000..a61d808 --- /dev/null +++ b/actions/release-plan/action.yml @@ -0,0 +1,47 @@ +# 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 + shell: bash + env: + GITHUB_TOKEN: ${{ inputs.github-token }} + 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" diff --git a/docs/nextcloud-release.md b/docs/nextcloud-release.md new file mode 100644 index 0000000..b39fd29 --- /dev/null +++ b/docs/nextcloud-release.md @@ -0,0 +1,61 @@ + + +# 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. + +## 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: + +- 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. diff --git a/scripts/release_plan.py b/scripts/release_plan.py new file mode 100644 index 0000000..f1623a7 --- /dev/null +++ b/scripts/release_plan.py @@ -0,0 +1,278 @@ +#!/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 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 _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", 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=version, + stable_branch=stable_branch, + current_ref=current_ref, + repository=args.repository, + appinfo=args.appinfo, + changelog=args.changelog, + milestone=args.milestone or None, + blocker_queries=parse_blocker_queries(args.blocker_queries_json), + ), + 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) + args.output.write_text(rendered + "\n", encoding="utf-8") + _write_summary(args.output) + + return 0 if plan["ready"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_release_plan.py b/tests/test_release_plan.py new file mode 100644 index 0000000..5fc9bdb --- /dev/null +++ b/tests/test_release_plan.py @@ -0,0 +1,143 @@ +# 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, main, parse_blocker_queries + + +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"]) + + 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"') + + 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()