diff --git a/actions/release-plan/action.yml b/actions/release-plan/action.yml index 2afa86b..934dcf2 100644 --- a/actions/release-plan/action.yml +++ b/actions/release-plan/action.yml @@ -54,6 +54,12 @@ outputs: tool-version: description: Exact release-tool version used for planning. value: ${{ steps.setup.outputs.version }} + tool-path: + description: Verified release-tool PHAR path for later steps in the same job. + value: ${{ steps.setup.outputs.path }} + plan-path: + description: ReleasePlan v1 JSON path for later steps in the same job. + value: ${{ steps.plan.outputs.plan-path }} runs: using: composite @@ -129,6 +135,7 @@ runs: cat "${plan_file}" echo "${delimiter}" echo "ready=${ready}" + echo "plan-path=${plan_file}" } >> "${GITHUB_OUTPUT}" { diff --git a/actions/release-prepare/action.yml b/actions/release-prepare/action.yml new file mode 100644 index 0000000..3e97ae0 --- /dev/null +++ b/actions/release-prepare/action.yml @@ -0,0 +1,179 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: Prepare release +description: Build a ReleasePlan v1, authorize the actor, create/reuse the deterministic release PR and persist its contracts. + +inputs: + branch: + description: Release branch to prepare. + required: true + ref: + description: Optional planning ref or commit SHA. + required: false + default: '' + version: + description: Optional explicit release version override. + required: false + default: '' + channel: + description: Release channel (alpha, beta, rc or final). + required: false + default: final + mode: + description: Release mode (normal or security). + required: false + default: normal + safe-public-text: + description: Optional explicitly public-safe release text. + required: false + default: '' + ignore-open-backport: + description: Explicitly override matching open backport blockers. + required: false + default: 'false' + create-follow-up-milestone: + description: Preserve the maintainer decision for post-merge milestone transition. + required: false + default: 'false' + config-path: + description: Consumer release configuration path. + required: false + default: .nextcloud-release.yml + actor: + description: GitHub login that requested mutating release preparation. + required: true + github-token: + description: Read-only caller token used for planning and permission lookup. + required: true + app-id: + description: GitHub App id used for the short-lived mutation token. + required: true + app-private-key: + description: GitHub App private key used for the short-lived mutation token. + required: true + +outputs: + preparation-id: + description: ReleasePreparation v1 id. + value: ${{ steps.prepare.outputs.preparation-id }} + pull-request-number: + description: Generated release pull request number. + value: ${{ steps.prepare.outputs.pull-request-number }} + pull-request-url: + description: Generated release pull request URL. + value: ${{ steps.prepare.outputs.pull-request-url }} + artifact-name: + description: Deterministic Actions artifact containing ReleasePlan v1 and ReleasePreparation v1. + value: ${{ steps.prepare.outputs.artifact-name }} + tool-version: + description: Exact release-tool version used. + value: ${{ steps.plan.outputs.tool-version }} + +runs: + using: composite + steps: + - id: plan + name: Build release plan + uses: $/actions/release-plan + with: + branch: ${{ inputs.branch }} + ref: ${{ inputs.ref }} + version: ${{ inputs.version }} + 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: ${{ inputs.github-token }} + + - id: authorization-config + name: Read preparation authorization policy + shell: bash + env: + RELEASE_TOOL_PATH: ${{ steps.plan.outputs.tool-path }} + RELEASE_CONFIG_PATH: ${{ inputs.config-path }} + run: | + set -euo pipefail + config_file="${RUNNER_TEMP}/release-consumer-config.json" + php "${RELEASE_TOOL_PATH}" config:validate --config "${RELEASE_CONFIG_PATH}" --root . --json > "${config_file}" + minimum_permission="$(php -r '$c=json_decode(file_get_contents($argv[1]),true,512,JSON_THROW_ON_ERROR); echo $c["authorization"]["prepare_min_permission"];' "${config_file}")" + echo "minimum-permission=${minimum_permission}" >> "${GITHUB_OUTPUT}" + + - name: Authorize preparation actor + uses: $/actions/check-release-authorization + with: + repository: ${{ github.repository }} + actor: ${{ inputs.actor }} + minimum-permission: ${{ steps.authorization-config.outputs.minimum-permission }} + github-token: ${{ inputs.github-token }} + + - id: repository + name: Resolve repository identity + shell: bash + env: + RELEASE_REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + echo "owner=${RELEASE_REPOSITORY%%/*}" >> "${GITHUB_OUTPUT}" + echo "name=${RELEASE_REPOSITORY#*/}" >> "${GITHUB_OUTPUT}" + + - id: app-token + name: Create scoped GitHub App token + uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 + with: + app-id: ${{ inputs.app-id }} + private-key: ${{ inputs.app-private-key }} + owner: ${{ steps.repository.outputs.owner }} + repositories: ${{ steps.repository.outputs.name }} + permission-contents: write + permission-pull-requests: write + + - id: prepare + name: Create or reuse release preparation pull request + shell: bash + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + RELEASE_TOOL_PATH: ${{ steps.plan.outputs.tool-path }} + RELEASE_PLAN_PATH: ${{ steps.plan.outputs.plan-path }} + RELEASE_CONFIG_PATH: ${{ inputs.config-path }} + run: | + set -euo pipefail + state_dir="${RUNNER_TEMP}/release-preparation-state" + mkdir -p "${state_dir}" + cp "${RELEASE_PLAN_PATH}" "${state_dir}/release-plan.json" + + php "${RELEASE_TOOL_PATH}" release:prepare \ + --plan "${RELEASE_PLAN_PATH}" \ + --config "${RELEASE_CONFIG_PATH}" \ + --root . \ + --apply \ + --json \ + > "${state_dir}/release-preparation.json" + + preparation_id="$(php -r '$p=json_decode(file_get_contents($argv[1]),true,512,JSON_THROW_ON_ERROR); echo $p["id"];' "${state_dir}/release-preparation.json")" + pr_number="$(php -r '$p=json_decode(file_get_contents($argv[1]),true,512,JSON_THROW_ON_ERROR); echo $p["pull_request"]["number"];' "${state_dir}/release-preparation.json")" + pr_url="$(php -r '$p=json_decode(file_get_contents($argv[1]),true,512,JSON_THROW_ON_ERROR); echo $p["pull_request"]["url"];' "${state_dir}/release-preparation.json")" + artifact_name="release-preparation-pr-${pr_number}" + + echo "preparation-id=${preparation_id}" >> "${GITHUB_OUTPUT}" + echo "pull-request-number=${pr_number}" >> "${GITHUB_OUTPUT}" + echo "pull-request-url=${pr_url}" >> "${GITHUB_OUTPUT}" + echo "artifact-name=${artifact_name}" >> "${GITHUB_OUTPUT}" + echo "state-dir=${state_dir}" >> "${GITHUB_OUTPUT}" + + { + echo "## Release preparation" + echo + echo "- Preparation: `${preparation_id}`" + echo "- Pull request: ${pr_url}" + echo "- State artifact: `${artifact_name}`" + } >> "${GITHUB_STEP_SUMMARY}" + + - name: Persist release preparation contracts + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: ${{ steps.prepare.outputs.artifact-name }} + path: ${{ steps.prepare.outputs.state-dir }} + if-no-files-found: error diff --git a/tests/test_release_prepare_action.py b/tests/test_release_prepare_action.py new file mode 100644 index 0000000..fb7bc69 --- /dev/null +++ b/tests/test_release_prepare_action.py @@ -0,0 +1,40 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +from pathlib import Path +import unittest + +ROOT = Path(__file__).resolve().parents[1] +ACTION = ROOT / "actions" / "release-prepare" / "action.yml" +PLAN = ROOT / "actions" / "release-plan" / "action.yml" + + +class ReleasePrepareActionTest(unittest.TestCase): + def test_plan_exposes_same_job_contract_paths(self) -> None: + content = PLAN.read_text(encoding="utf-8") + self.assertIn("tool-path:", content) + self.assertIn("plan-path:", content) + self.assertIn('echo "plan-path=${plan_file}"', content) + + def test_prepare_composes_policy_contracts_and_scoped_mutation(self) -> None: + content = ACTION.read_text(encoding="utf-8") + self.assertIn("$/actions/release-plan", content) + self.assertIn("config:validate", content) + self.assertIn("prepare_min_permission", content) + self.assertIn("$/actions/check-release-authorization", content) + self.assertIn("actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42", content) + self.assertIn("permission-contents: write", content) + self.assertIn("permission-pull-requests: write", content) + self.assertNotIn("permission-workflows: write", content) + self.assertIn("release:prepare", content) + + def test_prepare_persists_plan_and_preparation_by_pr_number(self) -> None: + content = ACTION.read_text(encoding="utf-8") + self.assertIn("release-plan.json", content) + self.assertIn("release-preparation.json", content) + self.assertIn('artifact_name="release-preparation-pr-${pr_number}"', content) + self.assertIn("actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02", content) + + +if __name__ == "__main__": + unittest.main()