diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..9ee6ae1 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: gradle + directory: /android + schedule: + interval: weekly + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..0a77e40 --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,82 @@ +name: Android validation +on: + push: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: android-${{ github.ref }} + cancel-in-progress: true +jobs: + build: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4 + with: + distribution: temurin + java-version: '17' + - name: Build, test and lint (release remains unsigned) + run: bash android/gradlew -p android testDebugUnitTest lintDebug lintRelease assembleDebug assembleRelease bundleRelease + - name: Source formatting and repository invariants + run: | + bash scripts/check-format.sh + python3 scripts/check-repository.py + python3 -m py_compile scripts/*.py + python3 scripts/test-verifier.py + - name: Check dependencies against OSV + run: python3 scripts/check-dependencies.py + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + if: always() + with: + name: android-builds-and-reports + path: | + android/app/build/outputs/apk/debug/*.apk + android/app/build/outputs/apk/release/*unsigned.apk + android/app/build/outputs/bundle/release/*.aab + android/app/build/reports/ + retention-days: 14 + emulator: + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + api: ['35', '37.0'] + timeout-minutes: 35 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + - uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 + with: + distribution: temurin + java-version: '17' + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '22.22.1' + - name: Prepare pinned, unmodified upstream + run: | + git clone --filter=blob:none https://github.com/ulsklyc/yuvomi.git "$RUNNER_TEMP/upstream" + git -C "$RUNNER_TEMP/upstream" checkout 10bfef7686ed7303b4c74c7fcc55a5eb16356c9d + npm ci --omit=dev --prefix "$RUNNER_TEMP/upstream" + - name: Enable KVM for the emulator + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + - uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2 + with: + api-level: ${{ matrix.api }} + arch: x86_64 + target: google_apis + disable-animations: true + script: | + bash android/gradlew -p android connectedDebugAndroidTest + python3 scripts/verify-test-results.py android/app/build/outputs/androidTest-results/connected/debug --minimum 6 + python3 scripts/test-integration.py "$RUNNER_TEMP/upstream" --android + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + if: always() + with: + name: android-device-test-reports-${{ matrix.api }} + path: android/app/build/reports/androidTests/ + retention-days: 14 diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml new file mode 100644 index 0000000..968f610 --- /dev/null +++ b/.github/workflows/ios.yml @@ -0,0 +1,39 @@ +name: iOS validation +on: + push: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: ios-${{ github.ref }} + cancel-in-progress: true +jobs: + simulator: + runs-on: macos-15 + timeout-minutes: 35 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + - name: Install verified XcodeGen and generate project + run: | + bash scripts/install-xcodegen.sh "$RUNNER_TEMP/xcodegen" + "$RUNNER_TEMP/xcodegen/xcodegen/bin/xcodegen" generate --spec ios/project.yml + xcodebuild -version + - name: Portable URL policy tests and Swift syntax + run: | + swift test --package-path ios + swiftc -frontend -parse ios/Sources/*.swift ios/Tests/*.swift ios/UITests/*.swift + - name: Simulator build and XCTest (no production signing) + run: | + device=$(python3 scripts/select-simulator.py) + xcodebuild -project ios/Yuvomi.xcodeproj -scheme Yuvomi -destination "platform=iOS Simulator,id=$device" -derivedDataPath "$RUNNER_TEMP/DerivedData" -resultBundlePath "$RUNNER_TEMP/Simulator.xcresult" CODE_SIGNING_ALLOWED=NO test + - name: Unsigned device archive validation + run: xcodebuild -project ios/Yuvomi.xcodeproj -scheme Yuvomi -destination 'generic/platform=iOS' -archivePath "$RUNNER_TEMP/Yuvomi-unsigned.xcarchive" CODE_SIGNING_ALLOWED=NO archive + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + if: always() + with: + name: ios-unsigned-archive-and-test-results + path: | + ${{ runner.temp }}/Simulator.xcresult + ${{ runner.temp }}/Yuvomi-unsigned.xcarchive + retention-days: 14 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..105a994 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,48 @@ +name: Maintainer release build (never publishes) +on: + workflow_dispatch: + inputs: + sign_android: + description: Sign APK and AAB using the protected release environment + type: boolean + default: false +permissions: + contents: read +jobs: + android: + runs-on: ubuntu-24.04 + environment: release + timeout-minutes: 30 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + - uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 + with: + distribution: temurin + java-version: '17' + - run: bash android/gradlew -p android testDebugUnitTest lintRelease assembleRelease bundleRelease + - name: Sign with maintainer-supplied credentials + if: inputs.sign_android + env: + KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }} + KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + run: | + set -euo pipefail + umask 077 + test -n "$KEYSTORE_BASE64" && test -n "$STORE_PASSWORD" && test -n "$KEY_PASSWORD" && test -n "$KEY_ALIAS" + trap 'rm -f "$RUNNER_TEMP/release.jks"' EXIT + printf '%s' "$KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/release.jks" + tools="$ANDROID_HOME/build-tools/36.0.0" + "$tools/zipalign" -p -f 4 android/app/build/outputs/apk/release/app-release-unsigned.apk "$RUNNER_TEMP/aligned.apk" + "$tools/apksigner" sign --ks "$RUNNER_TEMP/release.jks" --ks-key-alias "$KEY_ALIAS" --ks-pass env:STORE_PASSWORD --key-pass env:KEY_PASSWORD --out android/app/build/outputs/apk/release/yuvomi-release-signed.apk "$RUNNER_TEMP/aligned.apk" + "$tools/apksigner" verify --verbose android/app/build/outputs/apk/release/yuvomi-release-signed.apk + jarsigner -keystore "$RUNNER_TEMP/release.jks" -storepass:env STORE_PASSWORD -keypass:env KEY_PASSWORD android/app/build/outputs/bundle/release/app-release.aab "$KEY_ALIAS" + jarsigner -verify android/app/build/outputs/bundle/release/app-release.aab + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: maintainer-android-release-builds + path: | + android/app/build/outputs/apk/release/*.apk + android/app/build/outputs/bundle/release/*.aab + retention-days: 7 diff --git a/scripts/check-dependencies.py b/scripts/check-dependencies.py new file mode 100644 index 0000000..ec2e153 --- /dev/null +++ b/scripts/check-dependencies.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""Fail closed on OSV findings or unavailable advisory service. No app runtime use.""" +import json +from pathlib import Path +import re +import urllib.request + +ROOT = Path(__file__).resolve().parents[1] +coordinates = set() +lockfile = ROOT / "android/app/gradle.lockfile" +assert lockfile.is_file(), "Generate the reviewed Gradle dependency lockfile before scanning." +for source in [ROOT / "android/app/build.gradle.kts", ROOT / "android/build.gradle.kts", lockfile, + ROOT / "android/buildscript-gradle.lockfile"]: + if source.exists(): + coordinates.update(re.findall(r'([\w.-]+:[\w.-]+:[\w.+-]+)(?:["=])', source.read_text())) +agp = re.search(r'id\("com.android.application"\) version "([^"]+)"', (ROOT / "android/build.gradle.kts").read_text()) +assert agp, "Cannot determine Android build plugin version" +coordinates.add("com.android.tools.build:gradle:" + agp.group(1)) +queries = [{"package": {"ecosystem": "Maven", "name": coordinate.rsplit(":", 1)[0]}, + "version": coordinate.rsplit(":", 1)[1]} for coordinate in sorted(coordinates)] +request = urllib.request.Request("https://api.osv.dev/v1/querybatch", method="POST", + headers={"Content-Type": "application/json"}, + data=json.dumps({"queries": queries}).encode()) +with urllib.request.urlopen(request, timeout=60) as response: + results = json.load(response)["results"] +assert len(results) == len(queries) +findings = [(coordinate, [item["id"] for item in result.get("vulns", [])]) + for coordinate, result in zip(sorted(coordinates), results) if result.get("vulns")] +if findings: + for coordinate, identifiers in findings: + print(coordinate, ", ".join(identifiers)) + raise SystemExit("Dependency advisories require review and remediation before release.") +print(f"OSV: no listed advisories for {len(coordinates)} locked/direct Maven coordinates and build plugins.") diff --git a/scripts/check-format.sh b/scripts/check-format.sh new file mode 100644 index 0000000..5419ed8 --- /dev/null +++ b/scripts/check-format.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail +directory="${TMPDIR:-/tmp}/yuvomi-format-tools" +mkdir -p "$directory" +jar="$directory/ktlint-1.7.1.jar" +if [ ! -f "$jar" ]; then + curl --fail --location https://repo.maven.apache.org/maven2/com/pinterest/ktlint/ktlint-cli/1.7.1/ktlint-cli-1.7.1-all.jar -o "$jar" +fi +printf '%s %s\n' 5f3de4ee4014137b05c72ed68eb46fe686aeb11fbdef27faf77f55d51c1b2536 "$jar" | sha256sum -c - +java -jar "$jar" 'android/**/*.kt' 'android/**/*.kts' diff --git a/scripts/check-repository.py b/scripts/check-repository.py new file mode 100644 index 0000000..342190e --- /dev/null +++ b/scripts/check-repository.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Static release invariants; these checks do not replace platform/runtime tests.""" +import json +from pathlib import Path +import plistlib +import re +import subprocess +import xml.etree.ElementTree as ET + +ROOT = Path(__file__).resolve().parents[1] +required = ["README.md", "LICENSE", "THIRD_PARTY_NOTICES.md", "CONTRIBUTING.md", "SECURITY.md", + "CHANGELOG.md", "docs/architecture.md", "docs/build-android.md", "docs/build-ios.md", + "docs/server-compatibility.md", "docs/release-process.md", "docs/validation.md"] +for name in required: + assert (ROOT / name).is_file(), f"Missing {name}" + +paths = subprocess.check_output(["git", "ls-files", "--cached", "--others", "--exclude-standard"], cwd=ROOT, text=True).splitlines() +for name in paths: + path = ROOT / name + if not path.is_file(): + continue + assert not any(part in {"node_modules", "build", ".gradle", "DerivedData"} for part in path.relative_to(ROOT).parts), name + assert path.suffix not in {".jks", ".keystore", ".p12", ".mobileprovision", ".apk", ".aab"}, name + if path.suffix == ".xml": + ET.parse(path) + if path.suffix == ".xcprivacy": + with path.open("rb") as source: + assert plistlib.load(source)["NSPrivacyTracking"] is False + if path.suffix == ".json": + json.loads(path.read_text()) + if path.suffix in {".swift", ".kt", ".kts", ".py", ".md", ".yml", ".xml", ".properties", ".mjs", ".sh"}: + text = path.read_text() + assert not re.search(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----", text), f"Private key in {name}" + if path.suffix == ".md": + for target in re.findall(r"\]\(([^)]+)\)", text): + if ":" not in target and not target.startswith("#"): + assert (path.parent / target.split("#")[0]).exists(), f"Broken link in {name}: {target}" + +android = "{http://schemas.android.com/apk/res/android}" +manifest = ET.parse(ROOT / "android/app/src/main/AndroidManifest.xml").getroot() +app = manifest.find("application") +assert app.get(android + "usesCleartextTraffic") == "false" +assert app.get(android + "allowBackup") == "false" +assert app.get(android + "debuggable") != "true" +assert len(manifest.findall("application/activity/intent-filter")) == 1, "Unreviewed deep-link entry point" +network = ET.parse(ROOT / "android/app/src/main/res/xml/network_security_config.xml").getroot() +assert network.find("base-config").get("cleartextTrafficPermitted") == "false" +assert [node.get("src") for node in network.findall(".//certificates")] == ["system"] +assert "distributionSha256Sum=" in (ROOT / "android/gradle/wrapper/gradle-wrapper.properties").read_text() +for workflow in (ROOT / ".github/workflows").glob("*.yml"): + for action in re.findall(r"uses:\s*([^\s#]+)", workflow.read_text()): + assert re.fullmatch(r"[\w.-]+/[\w./-]+@[0-9a-f]{40}", action), f"Unpinned action: {action}" +assert "NSAllowsArbitraryLoads" not in (ROOT / "ios/project.yml").read_text() +print(f"Repository invariants: {len(paths)} source/documentation paths checked.") diff --git a/scripts/install-xcodegen.sh b/scripts/install-xcodegen.sh new file mode 100644 index 0000000..1d4193b --- /dev/null +++ b/scripts/install-xcodegen.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail +version=2.44.1 +destination="${1:-${TMPDIR:-/tmp}/yuvomi-xcodegen}" +mkdir -p "$destination" +curl --fail --location "https://github.com/yonaskolb/XcodeGen/releases/download/$version/xcodegen.zip" -o "$destination/xcodegen.zip" +printf '%s %s\n' a2e905fb68446e9bb4008cdfe2e13e3f176d0cbcca828b71770f8e53fca91b73 "$destination/xcodegen.zip" | shasum -a 256 -c - +unzip -qo "$destination/xcodegen.zip" -d "$destination" +"$destination/xcodegen/bin/xcodegen" --version diff --git a/scripts/select-simulator.py b/scripts/select-simulator.py new file mode 100644 index 0000000..3ac650d --- /dev/null +++ b/scripts/select-simulator.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +"""Select an available iPhone instead of depending on a runner-specific device name.""" +import json +import re +import subprocess + +devices = json.loads(subprocess.check_output(["xcrun", "simctl", "list", "devices", "available", "--json"])) +phones = [] +for runtime, entries in devices["devices"].items(): + match = re.search(r"\.iOS-(\d+)-(\d+)", runtime) + if not match: + continue + version = tuple(map(int, match.groups())) + if version < (18, 4): + continue + phones.extend((version, device) for device in entries + if device.get("isAvailable") and device["name"].startswith("iPhone")) +phones.sort(key=lambda entry: entry[0], reverse=True) +if not phones: + raise SystemExit("No available iPhone simulator; install an iOS simulator runtime in Xcode.") +print(phones[0][1]["udid"])