#!/usr/bin/env bash set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" apps_dir="${repo_root}/nextcloud/custom_apps" nextcloud_image="${NEXTCLOUD_IMAGE:-}" list_blocking_apps="0" filter_mode="all" usage() { cat <<'USAGE' Usage: ./scripts/check-nextcloud-custom-app-compat.sh [options] Checks custom app appinfo/info.xml Nextcloud dependency ranges against the target Nextcloud Docker image major. Options: --image Target Nextcloud image, for example nextcloud:34.0.1-apache --apps-dir Custom apps directory (default: ./nextcloud/custom_apps) --list-blocking-apps Print only incompatible app IDs, one per line --tracked-only Check only app manifests tracked by this git repo --untracked-only Check only app manifests not tracked by this git repo -h, --help Show this help USAGE } while [[ $# -gt 0 ]]; do case "$1" in --image) nextcloud_image="${2:-}" shift 2 ;; --image=*) nextcloud_image="${1#*=}" shift ;; --apps-dir) apps_dir="${2:-}" shift 2 ;; --apps-dir=*) apps_dir="${1#*=}" shift ;; --list-blocking-apps) list_blocking_apps="1" shift ;; --tracked-only) filter_mode="tracked" shift ;; --untracked-only) filter_mode="untracked" shift ;; -h|--help) usage exit 0 ;; *) echo "Unknown option: $1" usage exit 1 ;; esac done if [[ -z "${nextcloud_image}" ]]; then echo "[nextcloud-app-compat] Skipped: --image or NEXTCLOUD_IMAGE is required." exit 0 fi if [[ ! -d "${apps_dir}" ]]; then echo "[nextcloud-app-compat] Skipped: custom apps directory not found: ${apps_dir}" exit 0 fi if ! command -v python3 >/dev/null 2>&1; then echo "[nextcloud-app-compat] Skipped: python3 is required for app compatibility checks." exit 0 fi python3 - "${nextcloud_image}" "${apps_dir}" "${list_blocking_apps}" "${repo_root}" "${filter_mode}" <<'PY' import os import re import subprocess import sys import xml.etree.ElementTree as ET image = sys.argv[1].strip() apps_dir = sys.argv[2] list_blocking_apps = sys.argv[3] == "1" repo_root = sys.argv[4] filter_mode = sys.argv[5] prefix = "[nextcloud-app-compat]" def log(message): if list_blocking_apps: return print(f"{prefix} {message}") def parse_major_from_image(value): tag = "latest" last_slash = value.rfind("/") last_colon = value.rfind(":") if last_colon > last_slash: tag = value[last_colon + 1 :] match = re.match(r"^(\d+)(?:[.-]|$)", tag) if not match: return None return int(match.group(1)) def is_tracked_manifest(manifest): try: rel_path = os.path.relpath(manifest, repo_root) result = subprocess.run( ["git", "-C", repo_root, "ls-files", "--error-unmatch", "--", rel_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, ) return result.returncode == 0 except Exception: return False target_major = parse_major_from_image(image) if target_major is None: log(f"Skipped: cannot determine target Nextcloud major from image tag: {image}") sys.exit(0) manifests = [] for root, _dirs, files in os.walk(apps_dir): if "info.xml" in files and os.path.basename(root) == "appinfo": manifest = os.path.join(root, "info.xml") tracked = is_tracked_manifest(manifest) if filter_mode == "tracked" and not tracked: continue if filter_mode == "untracked" and tracked: continue manifests.append(manifest) if not manifests: log(f"Skipped: no appinfo/info.xml files found under {apps_dir}") sys.exit(0) failures = [] checked = 0 for manifest in sorted(manifests): try: tree = ET.parse(manifest) except ET.ParseError as exc: failures.append((os.path.basename(os.path.dirname(os.path.dirname(manifest))), f"{manifest}: invalid XML ({exc})")) continue root = tree.getroot() app_id = root.findtext("id") or os.path.basename(os.path.dirname(os.path.dirname(manifest))) dependency = root.find("./dependencies/nextcloud") if dependency is None: failures.append((app_id, f"{app_id}: missing ")) continue checked += 1 min_version = dependency.attrib.get("min-version", "").strip() max_version = dependency.attrib.get("max-version", "").strip() try: min_major = int(min_version.split(".", 1)[0]) if min_version else None max_major = int(max_version.split(".", 1)[0]) if max_version else None except ValueError: failures.append((app_id, f"{app_id}: invalid Nextcloud dependency range min={min_version!r} max={max_version!r}")) continue if min_major is not None and target_major < min_major: failures.append((app_id, f"{app_id}: target major {target_major} is below min-version {min_version}")) if max_major is not None and target_major > max_major: failures.append((app_id, f"{app_id}: target major {target_major} exceeds max-version {max_version}")) if failures: if list_blocking_apps: for app_id in sorted({app_id for app_id, _message in failures if app_id}): print(app_id) sys.exit(1) log(f"FAILED for target Nextcloud major {target_major} ({image})") for _app_id, failure in failures: log(f" - {failure}") sys.exit(1) log(f"OK: {checked} custom app manifest(s) declare compatibility with Nextcloud major {target_major}.") PY