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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions actions/release-plan/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -129,6 +135,7 @@ runs:
cat "${plan_file}"
echo "${delimiter}"
echo "ready=${ready}"
echo "plan-path=${plan_file}"
} >> "${GITHUB_OUTPUT}"

{
Expand Down
179 changes: 179 additions & 0 deletions actions/release-prepare/action.yml
Original file line number Diff line number Diff line change
@@ -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
40 changes: 40 additions & 0 deletions tests/test_release_prepare_action.py
Original file line number Diff line number Diff line change
@@ -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()
Loading