From c6ece60b47b450251a78f9b8a34cd37f996fe7d1 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 21 Sep 2026 11:33:26 -0300 Subject: [PATCH 1/6] feat: run release planning through release-tool PHAR Signed-off-by: Vitor Mattos --- actions/release-plan/action.yml | 160 ++++++++++++++++++++++++++------ 1 file changed, 134 insertions(+), 26 deletions(-) diff --git a/actions/release-plan/action.yml b/actions/release-plan/action.yml index a61d808..0cff89f 100644 --- a/actions/release-plan/action.yml +++ b/actions/release-plan/action.yml @@ -2,46 +2,154 @@ # SPDX-License-Identifier: AGPL-3.0-or-later name: Nextcloud release plan -description: Validate whether a Nextcloud app release is ready +description: Build a read-only ReleasePlan v1 with the verified PHP release-tool. inputs: - version: - description: Release version in MAJOR.MINOR.PATCH form - required: true - stable-branch: - description: Stable branch that is allowed to release + branch: + description: Release branch to plan. required: true - milestone: - description: Milestone title that must be closed with no open issues + ref: + description: Optional planning ref or commit SHA. + required: false + default: '' + version: + description: Optional explicit release version override, with or without the configured tag prefix. required: false default: '' - blocker-queries: - description: JSON array of GitHub issue search fragments that must return zero open items + channel: + description: Release channel (alpha, beta, rc or final). required: false - default: '[]' - appinfo-path: - description: Path to the Nextcloud app info.xml + default: final + mode: + description: Release mode (normal or security). required: false - default: appinfo/info.xml - changelog-path: - description: Path to the changelog + default: normal + safe-public-text: + description: Optional explicitly public-safe release text. required: false - default: CHANGELOG.md + default: '' + ignore-open-backport: + description: Explicitly override matching open backport blockers. + required: false + default: 'false' + create-follow-up-milestone: + description: Request follow-up milestone creation in a later mutating stage. + required: false + default: 'false' + config-path: + description: Consumer release configuration path. + required: false + default: .nextcloud-release.yml github-token: - description: GitHub token used for milestone and blocker checks + description: GitHub token used for read-only planning API calls. required: true +outputs: + plan: + description: ReleasePlan v1 JSON. + value: ${{ steps.plan.outputs.plan }} + ready: + description: Whether the plan is ready for the next stage. + value: ${{ steps.plan.outputs.ready }} + tool-version: + description: Exact release-tool version used for planning. + value: ${{ steps.setup.outputs.version }} + runs: using: composite steps: - - name: Build release plan + - id: setup + name: Setup release-tool + uses: $/actions/setup-release-tool + with: + version: '0.2.0' + + - id: plan + name: Build ReleasePlan v1 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" + RELEASE_TOOL_PATH: ${{ steps.setup.outputs.path }} + RELEASE_BRANCH: ${{ inputs.branch }} + RELEASE_REF: ${{ inputs.ref }} + RELEASE_VERSION: ${{ inputs.version }} + RELEASE_CHANNEL: ${{ inputs.channel }} + RELEASE_MODE: ${{ inputs.mode }} + RELEASE_SAFE_PUBLIC_TEXT: ${{ inputs.safe-public-text }} + RELEASE_IGNORE_OPEN_BACKPORT: ${{ inputs.ignore-open-backport }} + RELEASE_CREATE_FOLLOW_UP_MILESTONE: ${{ inputs.create-follow-up-milestone }} + RELEASE_CONFIG_PATH: ${{ inputs.config-path }} + run: | + set -euo pipefail + + args=( + release:plan + --config "${RELEASE_CONFIG_PATH}" + --root . + --branch "${RELEASE_BRANCH}" + --channel "${RELEASE_CHANNEL}" + --mode "${RELEASE_MODE}" + --json + ) + + if [[ -n "${RELEASE_REF}" ]]; then + args+=(--ref "${RELEASE_REF}") + fi + if [[ -n "${RELEASE_VERSION}" ]]; then + args+=(--release-version "${RELEASE_VERSION}") + fi + if [[ -n "${RELEASE_SAFE_PUBLIC_TEXT}" ]]; then + args+=(--safe-public-text "${RELEASE_SAFE_PUBLIC_TEXT}") + fi + if [[ "${RELEASE_IGNORE_OPEN_BACKPORT}" == "true" ]]; then + args+=(--ignore-open-backport) + fi + if [[ "${RELEASE_CREATE_FOLLOW_UP_MILESTONE}" == "true" ]]; then + args+=(--create-follow-up-milestone) + fi + + plan_file="${RUNNER_TEMP}/release-plan.json" + set +e + php "${RELEASE_TOOL_PATH}" "${args[@]}" > "${plan_file}" + exit_code=$? + set -e + + if [[ "${exit_code}" -ne 0 && "${exit_code}" -ne 3 ]]; then + cat "${plan_file}" + exit "${exit_code}" + fi + + ready="$(php -r ' + $plan = json_decode(file_get_contents($argv[1]), true, 512, JSON_THROW_ON_ERROR); + echo !empty($plan["ready"]) ? "true" : "false"; + ' "${plan_file}")" + + delimiter="release-plan-${RANDOM}-${RANDOM}" + { + echo "plan<<${delimiter}" + cat "${plan_file}" + echo "${delimiter}" + echo "ready=${ready}" + } >> "${GITHUB_OUTPUT}" + + { + echo "## Release plan" + echo + php -r ' + $plan = json_decode(file_get_contents($argv[1]), true, 512, JSON_THROW_ON_ERROR); + printf("- Tool: release-tool %s\n", $argv[2]); + printf("- Plan: `%s`\n", $plan["id"]); + printf("- Branch: `%s`\n", $plan["branch"]); + printf("- Planning base: `%s`\n", $plan["planning_base_sha"]); + printf("- Version: `%s` -> `%s`\n", $plan["current_version"], $plan["proposed_version"]); + printf("- Channel: `%s`\n", $plan["channel"]); + printf("- Ready: **%s**\n", !empty($plan["ready"]) ? "yes" : "no"); + foreach ($plan["warnings"] as $warning) { + printf("- Warning: %s\n", str_replace(["\r", "\n"], " ", (string) $warning)); + } + ' "${plan_file}" "${{ steps.setup.outputs.version }}" + } >> "${GITHUB_STEP_SUMMARY}" + + cat "${plan_file}" + + exit "${exit_code}" From cc5b3e1ec50b13fceb8cc5a82a1cc9bdf261fdb0 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 21 Sep 2026 11:33:42 -0300 Subject: [PATCH 2/6] feat: expose ReleasePlan v1 workflow contract Signed-off-by: Vitor Mattos --- .github/workflows/release-plan.yml | 81 +++++++++++++++++++++--------- 1 file changed, 58 insertions(+), 23 deletions(-) diff --git a/.github/workflows/release-plan.yml b/.github/workflows/release-plan.yml index f25fcf3..0123419 100644 --- a/.github/workflows/release-plan.yml +++ b/.github/workflows/release-plan.yml @@ -6,34 +6,60 @@ name: Nextcloud release plan on: workflow_call: inputs: - version: - description: Release version in MAJOR.MINOR.PATCH form + branch: + description: Release branch to plan. required: true type: string - stable_branch: - description: Stable branch that is allowed to release - required: true + ref: + description: Optional planning ref or commit SHA. + required: false type: string - milestone: - description: Milestone title that must be closed with no open issues + default: '' + version: + description: Optional explicit release version override. required: false type: string default: '' - blocker_queries: - description: JSON array of GitHub issue search fragments that must return zero open items + channel: + description: Release channel (alpha, beta, rc or final). required: false type: string - default: '[]' - appinfo_path: - description: Path to the Nextcloud app info.xml + default: final + mode: + description: Release mode (normal or security). required: false type: string - default: appinfo/info.xml - changelog_path: - description: Path to the changelog + default: normal + safe_public_text: + description: Optional explicitly public-safe release text. + required: false + type: string + default: '' + ignore_open_backport: + description: Explicitly override matching open backport blockers. + required: false + type: boolean + default: false + create_follow_up_milestone: + description: Request follow-up milestone creation in a later stage. + required: false + type: boolean + default: false + config_path: + description: Consumer release configuration path. required: false type: string - default: CHANGELOG.md + default: .nextcloud-release.yml + outputs: + plan: + description: ReleasePlan v1 JSON. + value: ${{ jobs.plan.outputs.plan }} + ready: + description: Whether the release is ready for the next stage. + value: ${{ jobs.plan.outputs.ready }} + tool_version: + description: Exact release-tool version used for planning. + value: ${{ jobs.plan.outputs.tool_version }} permissions: contents: read @@ -45,19 +71,28 @@ jobs: name: Release plan runs-on: ubuntu-latest timeout-minutes: 10 + outputs: + plan: ${{ steps.release-plan.outputs.plan }} + ready: ${{ steps.release-plan.outputs.ready }} + tool_version: ${{ steps.release-plan.outputs.tool-version }} steps: - - name: Checkout caller + - name: Checkout caller with release history uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + fetch-depth: 0 - - name: Build release plan + - id: release-plan + name: Build release plan uses: $/actions/release-plan with: + branch: ${{ inputs.branch }} + ref: ${{ inputs.ref }} 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 }} + channel: ${{ inputs.channel }} + mode: ${{ inputs.mode }} + safe-public-text: ${{ inputs.safe_public_text }} + ignore-open-backport: ${{ inputs.ignore_open_backport }} + create-follow-up-milestone: ${{ inputs.create_follow_up_milestone }} + config-path: ${{ inputs.config_path }} github-token: ${{ github.token }} From aed8b811eb95e90015057fb5ac0c505f0e38141f Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 21 Sep 2026 11:34:02 -0300 Subject: [PATCH 3/6] refactor: retire transitional Python release planner Signed-off-by: Vitor Mattos --- scripts/release_plan.py | 278 ---------------------------------------- 1 file changed, 278 deletions(-) delete mode 100644 scripts/release_plan.py diff --git a/scripts/release_plan.py b/scripts/release_plan.py deleted file mode 100644 index f1623a7..0000000 --- a/scripts/release_plan.py +++ /dev/null @@ -1,278 +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 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()) From 5d673b1f63d2e41d76d3fc0f2a57e898235b6b5b Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 21 Sep 2026 11:34:23 -0300 Subject: [PATCH 4/6] refactor: retire transitional Python release planner Signed-off-by: Vitor Mattos --- tests/test_release_plan.py | 143 ------------------------------------- 1 file changed, 143 deletions(-) delete mode 100644 tests/test_release_plan.py diff --git a/tests/test_release_plan.py b/tests/test_release_plan.py deleted file mode 100644 index 5fc9bdb..0000000 --- a/tests/test_release_plan.py +++ /dev/null @@ -1,143 +0,0 @@ -# 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() From e5990e5def1815e16be6f4b9bf41cda221b27720 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 21 Sep 2026 11:34:40 -0300 Subject: [PATCH 5/6] test: enforce PHP release-plan orchestration contract Signed-off-by: Vitor Mattos --- tests/test_release_plan_action.py | 66 +++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 tests/test_release_plan_action.py diff --git a/tests/test_release_plan_action.py b/tests/test_release_plan_action.py new file mode 100644 index 0000000..82264a8 --- /dev/null +++ b/tests/test_release_plan_action.py @@ -0,0 +1,66 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +ACTION = ROOT / "actions" / "release-plan" / "action.yml" +WORKFLOW = ROOT / ".github" / "workflows" / "release-plan.yml" + + +class ReleasePlanActionTest(unittest.TestCase): + def test_action_uses_exact_verified_release_tool(self) -> None: + content = ACTION.read_text(encoding="utf-8") + + self.assertIn("uses: $/actions/setup-release-tool", content) + self.assertIn("version: '0.2.0'", content) + self.assertIn("release:plan", content) + self.assertIn("--release-version", content) + self.assertNotIn("python3", content) + self.assertNotIn("release_plan.py", content) + + def test_action_exposes_release_plan_v1_inputs_and_outputs(self) -> None: + content = ACTION.read_text(encoding="utf-8") + + for expected in ( + "branch:", + "ref:", + "version:", + "channel:", + "mode:", + "safe-public-text:", + "ignore-open-backport:", + "create-follow-up-milestone:", + "config-path:", + "plan:", + "ready:", + "tool-version:", + ): + self.assertIn(expected, content) + + for transitional in ( + "blocker-queries:", + "appinfo-path:", + "changelog-path:", + ): + self.assertNotIn(transitional, content) + + def test_reusable_workflow_checks_out_full_release_history(self) -> None: + content = WORKFLOW.read_text(encoding="utf-8") + + self.assertIn("fetch-depth: 0", content) + self.assertIn("uses: $/actions/release-plan", content) + self.assertIn("plan:", content) + self.assertIn("ready:", content) + self.assertIn("tool_version:", content) + + def test_step_summary_does_not_print_public_release_text(self) -> None: + content = ACTION.read_text(encoding="utf-8") + + summary = content.split('echo "## Release plan"', 1)[1] + self.assertNotIn('public_release_text', summary) + + +if __name__ == "__main__": + unittest.main() From e78aff2c2ceb04a72d2fbdc60dae14a3963c4f72 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 21 Sep 2026 11:36:04 -0300 Subject: [PATCH 6/6] ci: keep release-tool output out of shell templates Signed-off-by: Vitor Mattos --- actions/release-plan/action.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/actions/release-plan/action.yml b/actions/release-plan/action.yml index 0cff89f..d61f518 100644 --- a/actions/release-plan/action.yml +++ b/actions/release-plan/action.yml @@ -70,6 +70,7 @@ runs: env: GITHUB_TOKEN: ${{ inputs.github-token }} RELEASE_TOOL_PATH: ${{ steps.setup.outputs.path }} + RELEASE_TOOL_VERSION: ${{ steps.setup.outputs.version }} RELEASE_BRANCH: ${{ inputs.branch }} RELEASE_REF: ${{ inputs.ref }} RELEASE_VERSION: ${{ inputs.version }} @@ -147,7 +148,7 @@ runs: foreach ($plan["warnings"] as $warning) { printf("- Warning: %s\n", str_replace(["\r", "\n"], " ", (string) $warning)); } - ' "${plan_file}" "${{ steps.setup.outputs.version }}" + ' "${plan_file}" "${RELEASE_TOOL_VERSION}" } >> "${GITHUB_STEP_SUMMARY}" cat "${plan_file}"