From 1433e1670ac2fde76e61bed63c75c7cecbd1d7b9d30ed2cf2b15d67705c1f8b0 Mon Sep 17 00:00:00 2001 From: Yuvomi Mobile Contributors Date: Wed, 21 Oct 2026 09:12:56 +0200 Subject: [PATCH] ci: collect native APK and screenshot evidence on Gitea Bootstrap checksum-pinned ARM64 Node before Mac checkout, upload a dedicated installable native debug APK, collect AGP test output before UTP uninstall and reject stale/missing screenshots. Add five screenshot-output regressions and enforce native shipping-source invariants. Workflow lint, 10 runner and 8 JUnit verifier regressions pass; remote execution is not yet verified. --- .gitea/workflows/android.yml | 12 +++++- .gitea/workflows/ios.yml | 21 +++++++++- scripts/check-repository.py | 4 ++ scripts/collect-android-artifacts.py | 11 ++++- scripts/test-integration-evidence.py | 62 ++++++++++++++++++++++++++++ scripts/test-integration.py | 28 +++++++++++-- 6 files changed, 131 insertions(+), 7 deletions(-) create mode 100644 scripts/test-integration-evidence.py diff --git a/.gitea/workflows/android.yml b/.gitea/workflows/android.yml index 3a37fc8..c3d915e 100644 --- a/.gitea/workflows/android.yml +++ b/.gitea/workflows/android.yml @@ -37,8 +37,16 @@ jobs: python3 -m py_compile scripts/*.py python3 scripts/test-verifier.py python3 scripts/test-ci-runner.py + python3 scripts/test-integration-evidence.py - name: Check dependencies against OSV run: python3 scripts/check-dependencies.py + - name: Upload installable native debug preview + uses: https://gitea.com/actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de + with: + name: android-native-debug-apk + path: android/app/build/outputs/apk/debug/app-debug.apk + if-no-files-found: error + retention-days: 14 - uses: https://gitea.com/actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de # v3.2.2-node20 (Gitea artifact protocol) if: always() with: @@ -96,7 +104,9 @@ jobs: if: always() with: name: android-device-test-reports-${{ matrix.api }} - path: android/app/build/reports/androidTests/ + path: | + android/app/build/reports/androidTests/ + android/app/build/outputs/native-screenshots/ retention-days: 14 - name: Remove only this job's allocated upstream fixture if: always() diff --git a/.gitea/workflows/ios.yml b/.gitea/workflows/ios.yml index 605587a..0e55179 100644 --- a/.gitea/workflows/ios.yml +++ b/.gitea/workflows/ios.yml @@ -14,6 +14,25 @@ jobs: runs-on: macos-arm64 timeout-minutes: 60 steps: + - name: Bootstrap verified native Node before JavaScript actions + run: | + set -euo pipefail + test "$(uname -s)" = Darwin && test "$(uname -m)" = arm64 + cache="${RUNNER_TOOL_CACHE:-$HOME/.cache/yuvomi-tools}" + target="$cache/yuvomi-node-22.22.1-darwin-arm64" + mkdir -p "$cache" + if [ ! -x "$target/bin/node" ]; then + test ! -e "$target" + temporary=$(mktemp -d "$cache/yuvomi-node-bootstrap.XXXXXX") + trap 'rm -rf -- "$temporary"' EXIT + curl --fail --location https://nodejs.org/dist/v22.22.1/node-v22.22.1-darwin-arm64.tar.gz -o "$temporary/node.tar.gz" + printf '%s %s\n' 679ad4966339e4ef4900f57996714864e4211b898825bb840c3086c419fbcef2 "$temporary/node.tar.gz" | shasum -a 256 -c - + tar -xzf "$temporary/node.tar.gz" -C "$temporary" + mv "$temporary/node-v22.22.1-darwin-arm64" "$target" + fi + test "$("$target/bin/node" -p 'process.arch')" = arm64 + echo "$target/bin" >> "$GITHUB_PATH" + "$target/bin/node" --version - uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262 env: GIT_DEFAULT_HASH: sha256 @@ -29,7 +48,7 @@ jobs: bash scripts/install-xcodegen.sh "$YUVOMI_CI_TEMP/xcodegen" "$YUVOMI_CI_TEMP/xcodegen/xcodegen/bin/xcodegen" generate --spec ios/project.yml xcodebuild -version - - name: Portable URL policy tests and Swift syntax + - name: Portable native API/contract tests and Swift syntax run: | swift test --package-path ios --scratch-path "$YUVOMI_CI_TEMP/SwiftPolicy" swiftc -frontend -parse ios/Sources/*.swift ios/Tests/*.swift ios/UITests/*.swift diff --git a/scripts/check-repository.py b/scripts/check-repository.py index ccc86d4..43d344e 100644 --- a/scripts/check-repository.py +++ b/scripts/check-repository.py @@ -62,4 +62,8 @@ for workflow in workflows: release = (ROOT / ".gitea/workflows/release.yml").read_text() assert "secrets." not in release and "environment:" not in release, "Gitea signing needs a separately reviewed approval mechanism" assert "NSAllowsArbitraryLoads" not in (ROOT / "ios/project.yml").read_text() +for path in list((ROOT / "android/app/src/main/java").rglob("*.kt")) + list((ROOT / "ios/Sources").rglob("*.swift")): + assert not re.search(r"android\.webkit\.|import WebKit|\b(?:WKWebView|WebView)\s*\(", path.read_text()), f"Web wrapper in native shipping source: {path}" +assert "NativeRootView()" in (ROOT / "ios/Sources/YuvomiApp.swift").read_text() +assert "setContent" in (ROOT / "android/app/src/main/java/community/yuvomi/mobile/MainActivity.kt").read_text() print(f"Repository invariants: {len(paths)} source/documentation paths checked.") diff --git a/scripts/collect-android-artifacts.py b/scripts/collect-android-artifacts.py index 3d47647..a862e9e 100644 --- a/scripts/collect-android-artifacts.py +++ b/scripts/collect-android-artifacts.py @@ -23,6 +23,10 @@ def tool(name, *arguments): release = APP / "outputs/apk/release/app-release-unsigned.apk" +configuration = (ROOT / "android/app/build.gradle.kts").read_text() +version_name = re.search(r'versionName = "([^"]+)"', configuration).group(1) +version_code = re.search(r'versionCode = (\d+)', configuration).group(1) +assert "native" in version_name, "Collector expects the native preview, not the archived wrapper" manifest = tool("aapt2", "dump", "xmltree", release, "--file", "AndroidManifest.xml") assert 'package="community.yuvomi.mobile"' in manifest assert re.search(r":allowBackup\([^\n]*=false", manifest) @@ -38,6 +42,9 @@ assert policy.count("E: certificates") == 1 and 'src="system"' in policy (DESTINATION / "release-network-policy.txt").write_text(policy) debug = APP / "outputs/apk/debug/app-debug.apk" +for apk in [debug, release]: + identity = tool("aapt2", "dump", "badging", apk).splitlines()[0] + assert f"versionCode='{version_code}'" in identity and f"versionName='{version_name}'" in identity, identity (DESTINATION / "debug-signature.txt").write_text(tool("apksigner", "verify", "--verbose", debug)) unsigned = subprocess.run([str(TOOLS / "apksigner"), "verify", str(release)], capture_output=True, text=True) assert unsigned.returncode != 0 and "Missing META-INF/MANIFEST.MF" in unsigned.stderr, unsigned.stderr @@ -61,7 +68,9 @@ dirty = bool(subprocess.check_output(["git", "status", "--porcelain"], cwd=ROOT, (DESTINATION / "build-info.json").write_text(json.dumps({ "source_revision": revision, "worktree_dirty_at_collection": dirty, "upstream_revision": "10bfef7686ed7303b4c74c7fcc55a5eb16356c9d", - "status": "Development debug APK; unsigned release APK/AAB. Not production-ready.", + "version_name": version_name, "version_code": int(version_code), + "renderer": "native Jetpack Compose", + "status": "Incomplete native preview: development debug APK; unsigned release APK/AAB. No full PWA parity or production approval.", "artifacts": entries, }, indent=2) + "\n") print(f"Inspected and collected {len(entries)} artifacts in {DESTINATION}") diff --git a/scripts/test-integration-evidence.py b/scripts/test-integration-evidence.py new file mode 100644 index 0000000..3a077bc --- /dev/null +++ b/scripts/test-integration-evidence.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Regressions for AGP screenshot output collection, not visual parity tests.""" +import base64 +import importlib.util +from pathlib import Path +import tempfile +import unittest + +SPEC = importlib.util.spec_from_file_location("integration", Path(__file__).with_name("test-integration.py")) +HARNESS = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(HARNESS) +PNG = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jJ1sAAAAASUVORK5CYII=") + + +class ScreenshotEvidenceTests(unittest.TestCase): + def setUp(self): + base = "/tmp/opencode" if Path("/tmp/opencode").exists() else None + self.temporary = tempfile.TemporaryDirectory(prefix="yuvomi-evidence-", dir=base) + self.addCleanup(self.temporary.cleanup) + self.output = Path(self.temporary.name) + self.additional, self.screenshots = HARNESS.prepare_android_screenshots(self.output) + + def fixture(self, device="fixture(AVD) - 15", contents=PNG): + image = self.additional / "connected" / device / "native-dashboard.png" + image.parent.mkdir(parents=True, exist_ok=True) + image.write_bytes(contents) + return image + + def test_collects_actual_agp_variant_path_without_adb_or_installed_app(self): + image = self.fixture() + HARNESS.collect_native_screenshot(self.additional, self.screenshots) + self.assertEqual((self.screenshots / image.name).read_bytes(), PNG) + self.assertEqual(self.additional.name, "integrationAndroidTest") + + def test_missing_screenshot_is_not_success(self): + with self.assertRaisesRegex(AssertionError, "Missing or ambiguous"): + HARNESS.collect_native_screenshot(self.additional, self.screenshots) + + def test_rejects_run_as_error_even_when_command_reported_zero_exit(self): + self.fixture(contents=b"run-as: unknown package: community.yuvomi.mobile.integration\n") + with self.assertRaisesRegex(AssertionError, "Invalid native"): + HARNESS.collect_native_screenshot(self.additional, self.screenshots) + + def test_rejects_ambiguous_device_evidence(self): + self.fixture("first") + self.fixture("second") + with self.assertRaisesRegex(AssertionError, "ambiguous"): + HARNESS.collect_native_screenshot(self.additional, self.screenshots) + + def test_preparation_removes_only_known_stale_capture(self): + image = self.fixture() + HARNESS.collect_native_screenshot(self.additional, self.screenshots) + unrelated = image.with_name("other-output.txt") + unrelated.write_text("preserve") + HARNESS.prepare_android_screenshots(self.output) + self.assertFalse(image.exists()) + self.assertFalse((self.screenshots / image.name).exists()) + self.assertEqual(unrelated.read_text(), "preserve") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test-integration.py b/scripts/test-integration.py index bb71a05..114e7c7 100644 --- a/scripts/test-integration.py +++ b/scripts/test-integration.py @@ -22,6 +22,25 @@ ROOT = Path(__file__).resolve().parents[1] REVISION = "10bfef7686ed7303b4c74c7fcc55a5eb16356c9d" +def prepare_android_screenshots(app_output): + additional = app_output / "connected_android_test_additional_output/integrationAndroidTest" + screenshots = app_output / "native-screenshots" + # Never accept a previous run's screenshot as current evidence. + for stale in additional.rglob("native-dashboard.png"): + stale.unlink() + (screenshots / "native-dashboard.png").unlink(missing_ok=True) + return additional, screenshots + + +def collect_native_screenshot(additional, screenshots): + captures = list(additional.rglob("native-dashboard.png")) + assert len(captures) == 1, "Missing or ambiguous native fixture screenshot" + capture = captures[0].read_bytes() + assert capture.startswith(b"\x89PNG\r\n\x1a\n"), "Invalid native fixture screenshot" + screenshots.mkdir(parents=True, exist_ok=True) + (screenshots / "native-dashboard.png").write_bytes(capture) + + def run(upstream, android): revision = subprocess.check_output(["git", "-C", str(upstream), "rev-parse", "HEAD"], text=True).strip() assert revision == REVISION, f"Expected pinned upstream {REVISION}, got {revision}" @@ -120,18 +139,19 @@ def run(upstream, android): subprocess.run([str(adb), "reverse", f"tcp:{port}", f"tcp:{port}"], check=True) subprocess.run([str(adb), "reverse", f"tcp:{ready['idpPort']}", f"tcp:{ready['idpPort']}"], check=True) try: + app_output = (Path(env["YUVOMI_BUILD_DIR"]) / "app" if "YUVOMI_BUILD_DIR" in env + else ROOT / "android/app/build") / "outputs" + additional, screenshots = prepare_android_screenshots(app_output) # The ephemeral password is passed through the local test runner only, # never written into a source file or printed by this harness. result = subprocess.run(["bash", str(ROOT / "android/gradlew"), "-p", str(ROOT / "android"), "-Pintegration", "connectedIntegrationAndroidTest"], env=env, check=False) if result.returncode: raise RuntimeError("Android integration tests failed. Inspect the Gradle test report.") - output = Path(env.get("YUVOMI_BUILD_DIR", ROOT / "android")) - reports = output / "app/outputs/androidTest-results/connected/integration" - if "YUVOMI_BUILD_DIR" not in env: - reports = ROOT / "android/app/build/outputs/androidTest-results/connected/integration" + reports = app_output / "androidTest-results/connected/integration" subprocess.run(["python3", str(ROOT / "scripts/verify-test-results.py"), str(reports), "--minimum", "7"], check=True) + collect_native_screenshot(additional, screenshots) finally: subprocess.run([str(adb), "reverse", "--remove", f"tcp:{port}"], check=False) subprocess.run([str(adb), "reverse", "--remove", f"tcp:{ready['idpPort']}"], check=False)