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
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.1.0
0.2.0
31 changes: 31 additions & 0 deletions actions/validate-release-artifact/action.yml
Original file line number Diff line number Diff line change
@@ -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}"
43 changes: 43 additions & 0 deletions actions/verify-appstore-publication/action.yml
Original file line number Diff line number Diff line change
@@ -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"
44 changes: 44 additions & 0 deletions patches/nextcloud/appstore-build-publish.yml.patch
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
75 changes: 75 additions & 0 deletions scripts/validate_release_artifact.py
Original file line number Diff line number Diff line change
@@ -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())
145 changes: 145 additions & 0 deletions scripts/verify_appstore_publication.py
Original file line number Diff line number Diff line change
@@ -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())
Loading
Loading