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
136 changes: 136 additions & 0 deletions actions/release-publication/action.yml
Original file line number Diff line number Diff line change
@@ -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
35 changes: 35 additions & 0 deletions tests/test_release_publication_action.py
Original file line number Diff line number Diff line change
@@ -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()
Loading