From 6fad3f594206b3fdf94f2f93f14b75dd8eeabe30 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 21 Sep 2026 17:17:14 -0300 Subject: [PATCH 1/3] feat: add post-merge release stage --- actions/release-post-merge/action.yml | 215 ++++++++++++++++++++ actions/restore-release-artifact/action.yml | 12 ++ scripts/restore_release_artifact.py | 25 ++- tests/test_release_post_merge_action.py | 47 +++++ tests/test_restore_release_artifact.py | 13 ++ 5 files changed, 310 insertions(+), 2 deletions(-) create mode 100644 actions/release-post-merge/action.yml create mode 100644 tests/test_release_post_merge_action.py diff --git a/actions/release-post-merge/action.yml b/actions/release-post-merge/action.yml new file mode 100644 index 0000000..ab480c5 --- /dev/null +++ b/actions/release-post-merge/action.yml @@ -0,0 +1,215 @@ +# 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 + 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: `${{ steps.finalize.outputs.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 diff --git a/actions/restore-release-artifact/action.yml b/actions/restore-release-artifact/action.yml index 9e98a1a..d0a156e 100644 --- a/actions/restore-release-artifact/action.yml +++ b/actions/restore-release-artifact/action.yml @@ -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 @@ -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" @@ -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}")" diff --git a/scripts/restore_release_artifact.py b/scripts/restore_release_artifact.py index 58d79f0..fa7d16b 100755 --- a/scripts/restore_release_artifact.py +++ b/scripts/restore_release_artifact.py @@ -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() @@ -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, diff --git a/tests/test_release_post_merge_action.py b/tests/test_release_post_merge_action.py new file mode 100644 index 0000000..1925271 --- /dev/null +++ b/tests/test_release_post_merge_action.py @@ -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() diff --git a/tests/test_restore_release_artifact.py b/tests/test_restore_release_artifact.py index 4f7363c..92b846b 100644 --- a/tests/test_restore_release_artifact.py +++ b/tests/test_restore_release_artifact.py @@ -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() From 3e1e29e22c778c1aa2904564779c8f235247c9a6 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 21 Sep 2026 17:18:02 -0300 Subject: [PATCH 2/3] fix: avoid release summary template injection --- actions/release-post-merge/action.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/actions/release-post-merge/action.yml b/actions/release-post-merge/action.yml index ab480c5..99a1f5b 100644 --- a/actions/release-post-merge/action.yml +++ b/actions/release-post-merge/action.yml @@ -114,6 +114,7 @@ runs: 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:finalize \ @@ -201,7 +202,7 @@ runs: { echo "## Release draft" echo - echo "- Prepared release: `${{ steps.finalize.outputs.prepared-release-id }}`" + echo "- Prepared release: `${PREPARED_RELEASE_ID}`" echo "- Draft: `${draft_id}`" echo "- GitHub Release: ${release_url}" echo "- State artifact: `${artifact_name}`" From ab369240887e2498f0b8916acefae7a9880805aa Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 21 Sep 2026 17:18:28 -0300 Subject: [PATCH 3/3] fix: scope prepared release summary value --- actions/release-post-merge/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actions/release-post-merge/action.yml b/actions/release-post-merge/action.yml index 99a1f5b..402c88f 100644 --- a/actions/release-post-merge/action.yml +++ b/actions/release-post-merge/action.yml @@ -114,7 +114,6 @@ runs: 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:finalize \ @@ -180,6 +179,7 @@ runs: 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 \