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
216 changes: 216 additions & 0 deletions actions/release-post-merge/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later

name: Finalize merged release
description: Restore ReleasePreparation v1, authorize the merger, finalize the release, transition milestones and create the GitHub Release draft.

inputs:
pull-request-number:
description: Merged generated release PR number.
required: true
merger:
description: GitHub login reported by pull_request.merged_by.
required: true
config-path:
description: Consumer release configuration path.
required: false
default: .nextcloud-release.yml
prepare-workflow-path:
description: Consumer workflow path that produced the release preparation artifact.
required: false
default: .github/workflows/prepare-release.yml
github-token:
description: Caller token used for artifact restore and permission lookup.
required: true
app-id:
description: GitHub App id used for short-lived mutation tokens.
required: true
app-private-key:
description: GitHub App private key used for short-lived mutation tokens.
required: true

outputs:
prepared-release-id:
description: PreparedRelease v1 id.
value: ${{ steps.finalize.outputs.prepared-release-id }}
release-draft-id:
description: ReleaseDraft v1 id.
value: ${{ steps.draft.outputs.release-draft-id }}
github-release-id:
description: GitHub Release id.
value: ${{ steps.draft.outputs.github-release-id }}
github-release-url:
description: GitHub Release draft URL.
value: ${{ steps.draft.outputs.github-release-url }}
state-artifact-name:
description: Deterministic artifact carrying finalized release state.
value: ${{ steps.draft.outputs.state-artifact-name }}

runs:
using: composite
steps:
- id: restore
name: Restore preparation contracts
uses: $/actions/restore-release-artifact
with:
repository: ${{ github.repository }}
artifact-name: release-preparation-pr-${{ inputs.pull-request-number }}
destination: ${{ runner.temp }}/release-post-merge-state
github-token: ${{ inputs.github-token }}
expected-event: workflow_dispatch
expected-workflow-path: ${{ inputs.prepare-workflow-path }}

- id: setup
name: Setup release-tool
uses: $/actions/setup-release-tool

- id: authorization-config
name: Read merge authorization policy
shell: bash
env:
RELEASE_TOOL_PATH: ${{ steps.setup.outputs.path }}
RELEASE_CONFIG_PATH: ${{ inputs.config-path }}
run: |
set -euo pipefail
config_file="${RUNNER_TEMP}/release-post-merge-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"]["merge_min_permission"];' "${config_file}")"
echo "minimum-permission=${minimum_permission}" >> "${GITHUB_OUTPUT}"

- name: Authorize release PR merger
uses: $/actions/check-release-authorization
with:
repository: ${{ github.repository }}
actor: ${{ inputs.merger }}
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: finalization-token
name: Create finalization 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: finalize
name: Revalidate merged release and synchronize aggregate history
shell: bash
env:
GITHUB_TOKEN: ${{ steps.finalization-token.outputs.token }}
RELEASE_TOOL_PATH: ${{ steps.setup.outputs.path }}
RELEASE_CONFIG_PATH: ${{ inputs.config-path }}
RELEASE_STATE_DIR: ${{ runner.temp }}/release-post-merge-state
run: |
set -euo pipefail
php "${RELEASE_TOOL_PATH}" release:finalize \
--preparation "${RELEASE_STATE_DIR}/release-preparation.json" \
--config "${RELEASE_CONFIG_PATH}" \
--root . \
--apply-history-sync \
--json \
> "${RELEASE_STATE_DIR}/prepared-release.json"
prepared_id="$(php -r '$p=json_decode(file_get_contents($argv[1]),true,512,JSON_THROW_ON_ERROR); echo $p["id"];' "${RELEASE_STATE_DIR}/prepared-release.json")"
echo "prepared-release-id=${prepared_id}" >> "${GITHUB_OUTPUT}"

- id: milestone-token
name: Create milestone 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-issues: write

- id: milestone
name: Apply milestone transition
shell: bash
env:
GITHUB_TOKEN: ${{ steps.milestone-token.outputs.token }}
RELEASE_TOOL_PATH: ${{ steps.setup.outputs.path }}
RELEASE_CONFIG_PATH: ${{ inputs.config-path }}
RELEASE_STATE_DIR: ${{ runner.temp }}/release-post-merge-state
run: |
set -euo pipefail
create_follow_up="$(php -r '$p=json_decode(file_get_contents($argv[1]),true,512,JSON_THROW_ON_ERROR); echo !empty($p["create_follow_up_milestone"]) ? "true" : "false";' "${RELEASE_STATE_DIR}/release-plan.json")"
args=(
milestone:transition
--prepared "${RELEASE_STATE_DIR}/prepared-release.json"
--config "${RELEASE_CONFIG_PATH}"
--root .
--apply
--json
)
if [[ "${create_follow_up}" == "true" ]]; then
args+=(--create-follow-up)
fi
php "${RELEASE_TOOL_PATH}" "${args[@]}" > "${RELEASE_STATE_DIR}/milestone-transition.json"

- id: draft-token
name: Create release draft 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: read

- id: draft
name: Create or update GitHub Release draft
shell: bash
env:
GITHUB_TOKEN: ${{ steps.draft-token.outputs.token }}
RELEASE_TOOL_PATH: ${{ steps.setup.outputs.path }}
RELEASE_CONFIG_PATH: ${{ inputs.config-path }}
RELEASE_STATE_DIR: ${{ runner.temp }}/release-post-merge-state
PREPARED_RELEASE_ID: ${{ steps.finalize.outputs.prepared-release-id }}
run: |
set -euo pipefail
php "${RELEASE_TOOL_PATH}" release:draft \
--prepared "${RELEASE_STATE_DIR}/prepared-release.json" \
--milestone "${RELEASE_STATE_DIR}/milestone-transition.json" \
--config "${RELEASE_CONFIG_PATH}" \
--root . \
--json \
> "${RELEASE_STATE_DIR}/release-draft.json"

draft_id="$(php -r '$d=json_decode(file_get_contents($argv[1]),true,512,JSON_THROW_ON_ERROR); echo $d["id"];' "${RELEASE_STATE_DIR}/release-draft.json")"
release_id="$(php -r '$d=json_decode(file_get_contents($argv[1]),true,512,JSON_THROW_ON_ERROR); echo $d["github_release"]["id"];' "${RELEASE_STATE_DIR}/release-draft.json")"
release_url="$(php -r '$d=json_decode(file_get_contents($argv[1]),true,512,JSON_THROW_ON_ERROR); echo $d["github_release"]["url"];' "${RELEASE_STATE_DIR}/release-draft.json")"
artifact_name="release-state-${release_id}"
echo "release-draft-id=${draft_id}" >> "${GITHUB_OUTPUT}"
echo "github-release-id=${release_id}" >> "${GITHUB_OUTPUT}"
echo "github-release-url=${release_url}" >> "${GITHUB_OUTPUT}"
echo "state-artifact-name=${artifact_name}" >> "${GITHUB_OUTPUT}"

{
echo "## Release draft"
echo
echo "- Prepared release: `${PREPARED_RELEASE_ID}`"
echo "- Draft: `${draft_id}`"
echo "- GitHub Release: ${release_url}"
echo "- State artifact: `${artifact_name}`"
} >> "${GITHUB_STEP_SUMMARY}"

- name: Persist finalized release contracts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: ${{ steps.draft.outputs.state-artifact-name }}
path: ${{ runner.temp }}/release-post-merge-state
if-no-files-found: error
12 changes: 12 additions & 0 deletions actions/restore-release-artifact/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ inputs:
destination:
description: Destination directory.
required: true
expected-event:
description: Optional originating workflow event that must match.
required: false
default: ''
expected-workflow-path:
description: Optional originating workflow path that must match.
required: false
default: ''
github-token:
description: Token with Actions artifact read access.
required: true
Expand All @@ -42,6 +50,8 @@ runs:
RELEASE_ARTIFACT_NAME: ${{ inputs.artifact-name }}
RELEASE_EXPECTED_HEAD_SHA: ${{ inputs.expected-head-sha }}
RELEASE_ARTIFACT_DESTINATION: ${{ inputs.destination }}
RELEASE_EXPECTED_EVENT: ${{ inputs.expected-event }}
RELEASE_EXPECTED_WORKFLOW_PATH: ${{ inputs.expected-workflow-path }}
run: |
set -euo pipefail
result_file="${RUNNER_TEMP}/release-artifact-restore.json"
Expand All @@ -50,6 +60,8 @@ runs:
--name "${RELEASE_ARTIFACT_NAME}" \
--expected-head-sha "${RELEASE_EXPECTED_HEAD_SHA}" \
--destination "${RELEASE_ARTIFACT_DESTINATION}" \
--expected-event "${RELEASE_EXPECTED_EVENT}" \
--expected-workflow-path "${RELEASE_EXPECTED_WORKFLOW_PATH}" \
> "${result_file}"

artifact_id="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["artifact_id"])' "${result_file}")"
Expand Down
25 changes: 23 additions & 2 deletions scripts/restore_release_artifact.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,23 @@ def request_bytes(url: str, token: str) -> bytes:
return response.read()


def validate_workflow_run(payload: object, expected_event: str | None, expected_workflow_path: str | None) -> None:
if not isinstance(payload, dict):
raise RuntimeError("GitHub returned an invalid workflow run")
if expected_event and payload.get("event") != expected_event:
raise RuntimeError(f"artifact workflow event {payload.get('event')!r} does not match {expected_event!r}")
if expected_workflow_path and payload.get("path") != expected_workflow_path:
raise RuntimeError(f"artifact workflow path {payload.get('path')!r} does not match {expected_workflow_path!r}")


def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--repository", required=True)
parser.add_argument("--name", required=True)
parser.add_argument("--expected-head-sha", default="")
parser.add_argument("--destination", required=True, type=Path)
parser.add_argument("--expected-event", default="")
parser.add_argument("--expected-workflow-path", default="")
parser.add_argument("--api-url", default=os.environ.get("GITHUB_API_URL", "https://api.github.com"))
args = parser.parse_args()

Expand All @@ -103,14 +114,24 @@ def main() -> int:
args.name,
args.expected_head_sha or None,
)
workflow_run = artifact.get("workflow_run")
run_id = workflow_run.get("id") if isinstance(workflow_run, dict) else None
if args.expected_event or args.expected_workflow_path:
if not isinstance(run_id, int):
parser.error("GitHub returned an artifact without workflow run identity")
run_url = f"{args.api_url.rstrip('/')}/repos/{args.repository}/actions/runs/{run_id}"
validate_workflow_run(
request_json(run_url, token),
args.expected_event or None,
args.expected_workflow_path or None,
)

archive_url = artifact.get("archive_download_url")
if not isinstance(archive_url, str) or archive_url == "":
parser.error("GitHub returned an artifact without archive_download_url")

safe_extract_zip(request_bytes(archive_url, token), args.destination)

workflow_run = artifact.get("workflow_run")
run_id = workflow_run.get("id") if isinstance(workflow_run, dict) else None
print(json.dumps({
"artifact_id": artifact.get("id"),
"workflow_run_id": run_id,
Expand Down
47 changes: 47 additions & 0 deletions tests/test_release_post_merge_action.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# 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-post-merge" / "action.yml"
RESTORE = ROOT / "actions" / "restore-release-artifact" / "action.yml"


class ReleasePostMergeActionTest(unittest.TestCase):
def test_authorization_happens_before_mutation(self) -> None:
content = ACTION.read_text(encoding="utf-8")
authorize = content.index("Authorize release PR merger")
finalize = content.index("Revalidate merged release")
milestone = content.index("Apply milestone transition")
draft = content.index("Create or update GitHub Release draft")
self.assertLess(authorize, finalize)
self.assertLess(finalize, milestone)
self.assertLess(milestone, draft)
self.assertIn("merge_min_permission", content)

def test_each_mutating_stage_uses_scoped_app_token(self) -> None:
content = ACTION.read_text(encoding="utf-8")
self.assertEqual(3, content.count("actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42"))
self.assertIn("permission-contents: write", content)
self.assertIn("permission-pull-requests: write", content)
self.assertIn("permission-issues: write", content)
self.assertIn("permission-pull-requests: read", content)

def test_contract_chain_is_persisted_for_publication(self) -> None:
content = ACTION.read_text(encoding="utf-8")
self.assertIn("release:finalize", content)
self.assertIn("milestone:transition", content)
self.assertIn("release:draft", content)
self.assertIn('artifact_name="release-state-${release_id}"', content)
self.assertIn("actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02", content)

def test_restore_action_can_bind_artifact_to_origin_workflow(self) -> None:
content = RESTORE.read_text(encoding="utf-8")
self.assertIn("expected-event:", content)
self.assertIn("expected-workflow-path:", content)


if __name__ == "__main__":
unittest.main()
13 changes: 13 additions & 0 deletions tests/test_restore_release_artifact.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,19 @@ def test_extracts_safe_archive(self) -> None:
module.safe_extract_zip(buffer.getvalue(), destination)
self.assertEqual("{}", (destination / "release-plan.json").read_text(encoding="utf-8"))

def test_validates_originating_workflow(self) -> None:
module.validate_workflow_run(
{"event": "workflow_dispatch", "path": ".github/workflows/prepare-release.yml"},
"workflow_dispatch",
".github/workflows/prepare-release.yml",
)
with self.assertRaisesRegex(RuntimeError, "does not match"):
module.validate_workflow_run(
{"event": "pull_request", "path": ".github/workflows/tests.yml"},
"workflow_dispatch",
".github/workflows/prepare-release.yml",
)


if __name__ == "__main__":
unittest.main()
Loading