SHA256
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.
This commit is contained in:
1 parent
8c480ddca3
commit
1433e1670a
6 files changed
+131
-7
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.")
|
||||
@@ -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}")
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user