From bb1ebcaf2ae8218a331b54e8e3d6c524d924d0ef Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 21 Sep 2026 17:20:16 -0300 Subject: [PATCH] feat: add publication verification stage --- actions/release-publication/action.yml | 136 +++++++++++++++++++++++ tests/test_release_publication_action.py | 35 ++++++ 2 files changed, 171 insertions(+) create mode 100644 actions/release-publication/action.yml create mode 100644 tests/test_release_publication_action.py diff --git a/actions/release-publication/action.yml b/actions/release-publication/action.yml new file mode 100644 index 0000000..2d4cfb6 --- /dev/null +++ b/actions/release-publication/action.yml @@ -0,0 +1,136 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: Verify published release +description: Restore finalized release contracts and wait for the existing publisher/App Store handoff to satisfy PublicationVerification v1. + +inputs: + github-release-id: + description: Published GitHub Release id from the release event. + required: true + config-path: + description: Consumer release configuration path. + required: false + default: .nextcloud-release.yml + post-merge-workflow-path: + description: Consumer workflow path that produced the finalized release-state artifact. + required: false + default: .github/workflows/prepare-release.yml + attempts: + description: Maximum PublicationVerification attempts while publisher/App Store state converges. + required: false + default: '30' + delay-seconds: + description: Delay between verification attempts. + required: false + default: '20' + github-token: + description: Token with read access to Actions, releases and release assets. + required: true + +outputs: + verification-id: + description: PublicationVerification v1 id. + value: ${{ steps.verify.outputs.verification-id }} + verification-artifact-name: + description: Artifact containing PublicationVerification v1 and its upstream release state. + value: ${{ steps.verify.outputs.verification-artifact-name }} + +runs: + using: composite + steps: + - name: Restore finalized release state + uses: $/actions/restore-release-artifact + with: + repository: ${{ github.repository }} + artifact-name: release-state-${{ inputs.github-release-id }} + destination: ${{ runner.temp }}/release-publication-state + github-token: ${{ inputs.github-token }} + expected-event: pull_request + expected-workflow-path: ${{ inputs.post-merge-workflow-path }} + + - id: setup + name: Setup release-tool + uses: $/actions/setup-release-tool + + - id: verify + name: Wait for publication verification + shell: bash + env: + GITHUB_TOKEN: ${{ inputs.github-token }} + RELEASE_TOOL_PATH: ${{ steps.setup.outputs.path }} + RELEASE_CONFIG_PATH: ${{ inputs.config-path }} + RELEASE_STATE_DIR: ${{ runner.temp }}/release-publication-state + RELEASE_ATTEMPTS: ${{ inputs.attempts }} + RELEASE_DELAY_SECONDS: ${{ inputs.delay-seconds }} + RELEASE_ID: ${{ inputs.github-release-id }} + run: | + set -euo pipefail + + if ! [[ "${RELEASE_ATTEMPTS}" =~ ^[1-9][0-9]*$ ]] || (( RELEASE_ATTEMPTS > 120 )); then + echo "::error::attempts must be an integer between 1 and 120" + exit 2 + fi + if ! [[ "${RELEASE_DELAY_SECONDS}" =~ ^[0-9]+$ ]] || (( RELEASE_DELAY_SECONDS > 300 )); then + echo "::error::delay-seconds must be an integer between 0 and 300" + exit 2 + fi + + verification_file="${RELEASE_STATE_DIR}/publication-verification.json" + verified=false + for ((attempt=1; attempt<=RELEASE_ATTEMPTS; attempt++)); do + set +e + php "${RELEASE_TOOL_PATH}" publication:verify \ + --draft "${RELEASE_STATE_DIR}/release-draft.json" \ + --prepared "${RELEASE_STATE_DIR}/prepared-release.json" \ + --config "${RELEASE_CONFIG_PATH}" \ + --root . \ + --format json \ + > "${verification_file}" + exit_code=$? + set -e + + if [[ "${exit_code}" -eq 0 ]]; then + verified=true + break + fi + + if [[ "${exit_code}" -eq 2 ]]; then + cat "${verification_file}" + exit "${exit_code}" + fi + + if (( attempt == RELEASE_ATTEMPTS )); then + cat "${verification_file}" + echo "::error::Publication verification did not succeed after ${RELEASE_ATTEMPTS} attempt(s)." + exit "${exit_code}" + fi + + echo "Publication not complete yet (attempt ${attempt}/${RELEASE_ATTEMPTS}); retrying after ${RELEASE_DELAY_SECONDS}s." + sleep "${RELEASE_DELAY_SECONDS}" + done + + if [[ "${verified}" != "true" ]]; then + echo "::error::Publication verification did not complete." + exit 1 + fi + + verification_id="$(php -r '$v=json_decode(file_get_contents($argv[1]),true,512,JSON_THROW_ON_ERROR); echo $v["id"];' "${verification_file}")" + artifact_name="publication-verification-${RELEASE_ID}" + echo "verification-id=${verification_id}" >> "${GITHUB_OUTPUT}" + echo "verification-artifact-name=${artifact_name}" >> "${GITHUB_OUTPUT}" + + { + echo "## Publication verification" + echo + echo "- Verification: `${verification_id}`" + echo "- GitHub Release id: `${RELEASE_ID}`" + echo "- Result: **success**" + } >> "${GITHUB_STEP_SUMMARY}" + + - name: Persist publication verification + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: ${{ steps.verify.outputs.verification-artifact-name }} + path: ${{ runner.temp }}/release-publication-state + if-no-files-found: error diff --git a/tests/test_release_publication_action.py b/tests/test_release_publication_action.py new file mode 100644 index 0000000..5a1d31e --- /dev/null +++ b/tests/test_release_publication_action.py @@ -0,0 +1,35 @@ +# 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-publication" / "action.yml" + + +class ReleasePublicationActionTest(unittest.TestCase): + def test_restores_state_from_post_merge_run(self) -> None: + content = ACTION.read_text(encoding="utf-8") + self.assertIn("release-state-${{ inputs.github-release-id }}", content) + self.assertIn("expected-event: pull_request", content) + self.assertIn("expected-workflow-path:", content) + + def test_retry_loop_reuses_publication_verify_contract(self) -> None: + content = ACTION.read_text(encoding="utf-8") + self.assertIn("publication:verify", content) + self.assertIn("--draft", content) + self.assertIn("--prepared", content) + self.assertIn("RELEASE_ATTEMPTS", content) + self.assertIn("RELEASE_DELAY_SECONDS", content) + self.assertNotIn("apps.nextcloud.com", content) + self.assertNotIn("actions/workflows", content) + + def test_persists_publication_verification_for_downstream_sync(self) -> None: + content = ACTION.read_text(encoding="utf-8") + self.assertIn('artifact_name="publication-verification-${RELEASE_ID}"', content) + self.assertIn("actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02", content) + + +if __name__ == "__main__": + unittest.main()