diff --git a/VERSION b/VERSION index 6e8bf73..0ea3a94 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0 +0.2.0 diff --git a/actions/validate-release-artifact/action.yml b/actions/validate-release-artifact/action.yml new file mode 100644 index 0000000..55f925f --- /dev/null +++ b/actions/validate-release-artifact/action.yml @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: Validate Nextcloud release artifact +description: Validate the structure and declared version of a Nextcloud app release tarball + +inputs: + artifact: + description: Path to the app release tar.gz + required: true + app-name: + description: Nextcloud app id and expected top-level archive directory + required: true + version: + description: Expected app version, with or without the v prefix + required: true + +runs: + using: composite + steps: + - name: Validate release artifact + shell: bash + env: + ARTIFACT: ${{ inputs.artifact }} + APP_NAME: ${{ inputs.app-name }} + APP_VERSION: ${{ inputs.version }} + run: | + python3 "$GITHUB_ACTION_PATH/../../scripts/validate_release_artifact.py" \ + --artifact "$ARTIFACT" \ + --app-name "$APP_NAME" \ + --version "${APP_VERSION#v}" diff --git a/actions/verify-appstore-publication/action.yml b/actions/verify-appstore-publication/action.yml new file mode 100644 index 0000000..9bcf5e3 --- /dev/null +++ b/actions/verify-appstore-publication/action.yml @@ -0,0 +1,43 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: Verify Nextcloud App Store publication +description: Wait until a published app release is visible in the Nextcloud App Store API + +inputs: + app-name: + description: Nextcloud app id + required: true + version: + description: Release version, with or without the v prefix + required: true + nextcloud-platform: + description: Minimum supported Nextcloud platform version + required: true + attempts: + description: Maximum number of App Store API checks + required: false + default: '12' + delay-seconds: + description: Delay between checks + required: false + default: '10' + +runs: + using: composite + steps: + - name: Verify App Store publication + shell: bash + env: + APP_NAME: ${{ inputs.app-name }} + APP_VERSION: ${{ inputs.version }} + NEXTCLOUD_PLATFORM: ${{ inputs.nextcloud-platform }} + ATTEMPTS: ${{ inputs.attempts }} + DELAY_SECONDS: ${{ inputs.delay-seconds }} + run: | + python3 "$GITHUB_ACTION_PATH/../../scripts/verify_appstore_publication.py" \ + --app-name "$APP_NAME" \ + --version "$APP_VERSION" \ + --platform "$NEXTCLOUD_PLATFORM" \ + --attempts "$ATTEMPTS" \ + --delay-seconds "$DELAY_SECONDS" diff --git a/patches/nextcloud/appstore-build-publish.yml.patch b/patches/nextcloud/appstore-build-publish.yml.patch index 562eb80..ad42a9b 100644 --- a/patches/nextcloud/appstore-build-publish.yml.patch +++ b/patches/nextcloud/appstore-build-publish.yml.patch @@ -18,3 +18,47 @@ steps: - name: Check actor permission uses: skjnldsv/check-actor-permission@69e92a3c4711150929bca9fcf34448c5bf5526e7 # v3.0 +@@ -143,6 +140,14 @@ + cd ${{ env.APP_NAME }} + make appstore + ++ - name: Verify app store package ++ if: steps.krankerl.outputs.files_exists != 'true' ++ working-directory: ${{ env.APP_NAME }} ++ run: | ++ if make -qp 2>/dev/null | grep -q '^verify-appstore-package:'; then ++ make verify-appstore-package ++ fi ++ + - name: Check server download link for ${{ fromJSON(steps.appinfo.outputs.result).nextcloud.min-version }} + run: | + NCVERSION='${{ fromJSON(steps.appinfo.outputs.result).nextcloud.min-version }}' +@@ -183,6 +188,13 @@ + cd ${{ env.APP_NAME }}/build/artifacts + tar -zcvf ${{ env.APP_NAME }}.tar.gz ${{ env.APP_NAME }} + ++ - name: Validate release artifact ++ uses: LibreCodeCoop/github-workflows/actions/validate-release-artifact@eabf9eeacf26f8ef8c01a72641bf8e2ed1ba3637 # v0.2.0 ++ with: ++ artifact: ${{ env.APP_NAME }}/build/artifacts/${{ env.APP_NAME }}.tar.gz ++ app-name: ${{ env.APP_NAME }} ++ version: ${{ env.APP_VERSION }} ++ + - name: Attach tarball to github release + uses: svenstaro/upload-release-action@29e53e917877a24fad85510ded594ab3c9ca12de # 2.11.5 + id: attach_to_release +@@ -196,7 +208,14 @@ + - name: Upload app to Nextcloud appstore + uses: nextcloud-libraries/nextcloud-appstore-push-action@a011fe619bcf6e77ddebc96f9908e1af4071b9c1 # v1.0.3 + with: + app_name: ${{ env.APP_NAME }} + appstore_token: ${{ secrets.APPSTORE_TOKEN }} + download_url: ${{ steps.attach_to_release.outputs.browser_download_url }} + app_private_key: ${{ secrets.APP_PRIVATE_KEY }} ++ ++ - name: Verify App Store publication ++ uses: LibreCodeCoop/github-workflows/actions/verify-appstore-publication@0bb30136a7fcb7e70b7ae579addeeda882f4c457 # v0.2.0 ++ with: ++ app-name: ${{ env.APP_NAME }} ++ version: ${{ env.APP_VERSION }} ++ nextcloud-platform: ${{ fromJSON(steps.appinfo.outputs.result).nextcloud.min-version }} diff --git a/scripts/validate_release_artifact.py b/scripts/validate_release_artifact.py new file mode 100644 index 0000000..a54566f --- /dev/null +++ b/scripts/validate_release_artifact.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +from __future__ import annotations + +import argparse +import tarfile +from pathlib import Path, PurePosixPath +from xml.etree import ElementTree + + +def validate_artifact(artifact: Path, app_name: str, version: str) -> None: + if not artifact.is_file(): + raise ValueError(f"artifact does not exist: {artifact}") + + with tarfile.open(artifact, "r:gz") as archive: + members = archive.getmembers() + if not members: + raise ValueError("artifact is empty") + + top_levels: set[str] = set() + for member in members: + path = PurePosixPath(member.name) + if path.is_absolute() or ".." in path.parts: + raise ValueError(f"unsafe archive path: {member.name}") + if path.parts: + top_levels.add(path.parts[0]) + + if top_levels != {app_name}: + raise ValueError( + f"artifact must contain only the top-level directory {app_name!r}; " + f"found {sorted(top_levels)!r}" + ) + + info_path = f"{app_name}/appinfo/info.xml" + try: + info_member = archive.getmember(info_path) + except KeyError as error: + raise ValueError(f"artifact is missing {info_path}") from error + + stream = archive.extractfile(info_member) + if stream is None: + raise ValueError(f"artifact entry is not a file: {info_path}") + + try: + root = ElementTree.parse(stream).getroot() + except ElementTree.ParseError as error: + raise ValueError(f"cannot parse {info_path}: {error}") from error + + declared = root.findtext("version") + if declared != version: + raise ValueError( + f"{info_path} declares version {declared!r}, expected {version!r}" + ) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--artifact", required=True, type=Path) + parser.add_argument("--app-name", required=True) + parser.add_argument("--version", required=True) + args = parser.parse_args() + + try: + validate_artifact(args.artifact, args.app_name, args.version) + except (OSError, ValueError, tarfile.TarError) as error: + parser.error(str(error)) + + print(f"Validated release artifact: {args.artifact}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_appstore_publication.py b/scripts/verify_appstore_publication.py new file mode 100644 index 0000000..c6a5acb --- /dev/null +++ b/scripts/verify_appstore_publication.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +from __future__ import annotations + +import argparse +import json +import time +from collections.abc import Callable +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + + +FetchJson = Callable[[str], object] +Sleep = Callable[[float], None] + + +def normalize_version(version: str) -> str: + return version[1:] if version.startswith("v") else version + + +def normalize_platform(platform: str) -> str: + parts = platform.split(".") + if not all(part.isdigit() for part in parts): + raise ValueError(f"invalid Nextcloud platform version: {platform!r}") + if len(parts) > 3: + raise ValueError(f"invalid Nextcloud platform version: {platform!r}") + return ".".join(parts + ["0"] * (3 - len(parts))) + + +def contains_release(payload: object, app_name: str, version: str) -> bool: + if not isinstance(payload, dict): + return False + + data = payload.get("data") + if not isinstance(data, list): + return False + + for app in data: + if not isinstance(app, dict) or app.get("id") != app_name: + continue + releases = app.get("releases") + if not isinstance(releases, list): + continue + for release in releases: + if isinstance(release, dict) and release.get("version") == version: + return True + + return False + + +def fetch_json(url: str) -> object: + request = Request( + url, + headers={ + "Accept": "application/json", + "User-Agent": "LibreCodeCoop/github-workflows", + }, + ) + with urlopen(request, timeout=30) as response: + return json.load(response) + + +def wait_for_publication( + *, + app_name: str, + version: str, + platform: str, + attempts: int, + delay_seconds: float, + fetch: FetchJson = fetch_json, + sleep: Sleep = time.sleep, +) -> None: + if attempts < 1: + raise ValueError("attempts must be at least 1") + if delay_seconds < 0: + raise ValueError("delay seconds must not be negative") + + normalized_version = normalize_version(version) + normalized_platform = normalize_platform(platform) + url = ( + "https://apps.nextcloud.com/api/v1/platform/" + f"{normalized_platform}/apps.json" + ) + + last_error: Exception | None = None + + for attempt in range(1, attempts + 1): + try: + payload = fetch(url) + last_error = None + if contains_release(payload, app_name, normalized_version): + print( + f"Verified {app_name} {normalized_version} " + "in the Nextcloud App Store" + ) + return + except (HTTPError, URLError, TimeoutError, OSError, json.JSONDecodeError) as error: + last_error = error + + if attempt == attempts: + break + + message = ( + f"App Store has not exposed {app_name} {normalized_version} yet " + f"(attempt {attempt}/{attempts})" + ) + if last_error is not None: + message += f": {last_error}" + print(message) + sleep(delay_seconds) + + detail = f": {last_error}" if last_error is not None else "" + raise RuntimeError( + f"App Store publication could not be verified for " + f"{app_name} {normalized_version}{detail}" + ) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--app-name", required=True) + parser.add_argument("--version", required=True) + parser.add_argument("--platform", required=True) + parser.add_argument("--attempts", type=int, default=12) + parser.add_argument("--delay-seconds", type=float, default=10) + args = parser.parse_args() + + try: + wait_for_publication( + app_name=args.app_name, + version=args.version, + platform=args.platform, + attempts=args.attempts, + delay_seconds=args.delay_seconds, + ) + except (RuntimeError, ValueError) as error: + parser.error(str(error)) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_validate_release_artifact.py b/tests/test_validate_release_artifact.py new file mode 100644 index 0000000..1211542 --- /dev/null +++ b/tests/test_validate_release_artifact.py @@ -0,0 +1,81 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +import io +import tarfile +import tempfile +import unittest +from pathlib import Path + +from scripts.validate_release_artifact import validate_artifact + + +class ValidateReleaseArtifactTest(unittest.TestCase): + def create_archive( + self, + directory: str, + *, + app_name: str = "example_app", + version: str = "1.2.3", + info_path: str | None = None, + extra_name: str | None = None, + ) -> Path: + artifact = Path(directory) / "artifact.tar.gz" + info = ( + f"{app_name}{version}" + ).encode() + with tarfile.open(artifact, "w:gz") as archive: + member = tarfile.TarInfo( + info_path or f"{app_name}/appinfo/info.xml" + ) + member.size = len(info) + archive.addfile(member, io.BytesIO(info)) + + if extra_name is not None: + extra = tarfile.TarInfo(extra_name) + extra.size = 1 + archive.addfile(extra, io.BytesIO(b"x")) + + return artifact + + def test_accepts_expected_app_and_version(self) -> None: + with tempfile.TemporaryDirectory() as directory: + artifact = self.create_archive(directory) + validate_artifact(artifact, "example_app", "1.2.3") + + def test_rejects_version_mismatch(self) -> None: + with tempfile.TemporaryDirectory() as directory: + artifact = self.create_archive(directory, version="1.2.2") + with self.assertRaisesRegex(ValueError, "expected '1.2.3'"): + validate_artifact(artifact, "example_app", "1.2.3") + + def test_rejects_multiple_top_level_directories(self) -> None: + with tempfile.TemporaryDirectory() as directory: + artifact = self.create_archive( + directory, + extra_name="other/file.txt", + ) + with self.assertRaisesRegex(ValueError, "top-level directory"): + validate_artifact(artifact, "example_app", "1.2.3") + + def test_rejects_missing_info_xml(self) -> None: + with tempfile.TemporaryDirectory() as directory: + artifact = self.create_archive( + directory, + info_path="example_app/not-appinfo/info.xml", + ) + with self.assertRaisesRegex(ValueError, "missing"): + validate_artifact(artifact, "example_app", "1.2.3") + + def test_rejects_unsafe_archive_path(self) -> None: + with tempfile.TemporaryDirectory() as directory: + artifact = self.create_archive( + directory, + extra_name="../escape", + ) + with self.assertRaisesRegex(ValueError, "unsafe archive path"): + validate_artifact(artifact, "example_app", "1.2.3") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_verify_appstore_publication.py b/tests/test_verify_appstore_publication.py new file mode 100644 index 0000000..caf5676 --- /dev/null +++ b/tests/test_verify_appstore_publication.py @@ -0,0 +1,108 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +import unittest +from urllib.error import URLError + +from scripts.verify_appstore_publication import ( + contains_release, + normalize_platform, + normalize_version, + wait_for_publication, +) + + +class VerifyAppStorePublicationTest(unittest.TestCase): + def test_normalizes_version_prefix(self) -> None: + self.assertEqual(normalize_version("v12.3.4"), "12.3.4") + self.assertEqual(normalize_version("12.3.4"), "12.3.4") + + def test_normalizes_platform(self) -> None: + self.assertEqual(normalize_platform("35"), "35.0.0") + self.assertEqual(normalize_platform("35.1"), "35.1.0") + self.assertEqual(normalize_platform("35.1.2"), "35.1.2") + + def test_rejects_invalid_platform(self) -> None: + with self.assertRaises(ValueError): + normalize_platform("stable35") + + def test_detects_release(self) -> None: + payload = { + "data": [ + { + "id": "example", + "releases": [{"version": "1.2.3"}], + } + ] + } + self.assertTrue(contains_release(payload, "example", "1.2.3")) + self.assertFalse(contains_release(payload, "example", "1.2.4")) + + def test_retries_until_release_is_visible(self) -> None: + responses = iter([ + {"data": [{"id": "example", "releases": []}]}, + {"data": [{"id": "example", "releases": [{"version": "1.2.3"}]}]}, + ]) + sleeps: list[float] = [] + + wait_for_publication( + app_name="example", + version="v1.2.3", + platform="35", + attempts=3, + delay_seconds=7, + fetch=lambda _url: next(responses), + sleep=sleeps.append, + ) + + self.assertEqual(sleeps, [7]) + + def test_retries_transient_request_error(self) -> None: + calls = 0 + sleeps: list[float] = [] + + def fetch(_url: str) -> object: + nonlocal calls + calls += 1 + if calls == 1: + raise URLError("temporary") + return { + "data": [ + { + "id": "example", + "releases": [{"version": "1.2.3"}], + } + ] + } + + wait_for_publication( + app_name="example", + version="1.2.3", + platform="35.0.0", + attempts=2, + delay_seconds=2, + fetch=fetch, + sleep=sleeps.append, + ) + + self.assertEqual(sleeps, [2]) + + def test_fails_after_attempt_limit_without_extra_sleep(self) -> None: + sleeps: list[float] = [] + + with self.assertRaisesRegex(RuntimeError, "could not be verified"): + wait_for_publication( + app_name="example", + version="1.2.3", + platform="35", + attempts=3, + delay_seconds=5, + fetch=lambda _url: {"data": []}, + sleep=sleeps.append, + ) + + self.assertEqual(sleeps, [5, 5]) + + +if __name__ == "__main__": + unittest.main() diff --git a/workflow-templates/appstore-build-publish.yml b/workflow-templates/appstore-build-publish.yml index 213676b..fa16f41 100644 --- a/workflow-templates/appstore-build-publish.yml +++ b/workflow-templates/appstore-build-publish.yml @@ -140,6 +140,14 @@ jobs: cd ${{ env.APP_NAME }} make appstore + - name: Verify app store package + if: steps.krankerl.outputs.files_exists != 'true' + working-directory: ${{ env.APP_NAME }} + run: | + if make -qp 2>/dev/null | grep -q '^verify-appstore-package:'; then + make verify-appstore-package + fi + - name: Check server download link for ${{ fromJSON(steps.appinfo.outputs.result).nextcloud.min-version }} run: | NCVERSION='${{ fromJSON(steps.appinfo.outputs.result).nextcloud.min-version }}' @@ -180,6 +188,13 @@ jobs: cd ${{ env.APP_NAME }}/build/artifacts tar -zcvf ${{ env.APP_NAME }}.tar.gz ${{ env.APP_NAME }} + - name: Validate release artifact + uses: LibreCodeCoop/github-workflows/actions/validate-release-artifact@eabf9eeacf26f8ef8c01a72641bf8e2ed1ba3637 # v0.2.0 + with: + artifact: ${{ env.APP_NAME }}/build/artifacts/${{ env.APP_NAME }}.tar.gz + app-name: ${{ env.APP_NAME }} + version: ${{ env.APP_VERSION }} + - name: Attach tarball to github release uses: svenstaro/upload-release-action@29e53e917877a24fad85510ded594ab3c9ca12de # 2.11.5 id: attach_to_release @@ -197,3 +212,10 @@ jobs: appstore_token: ${{ secrets.APPSTORE_TOKEN }} download_url: ${{ steps.attach_to_release.outputs.browser_download_url }} app_private_key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Verify App Store publication + uses: LibreCodeCoop/github-workflows/actions/verify-appstore-publication@0bb30136a7fcb7e70b7ae579addeeda882f4c457 # v0.2.0 + with: + app-name: ${{ env.APP_NAME }} + version: ${{ env.APP_VERSION }} + nextcloud-platform: ${{ fromJSON(steps.appinfo.outputs.result).nextcloud.min-version }}