"""Android source delivery and release-proof boundaries, without device access."""
from __future__ import annotations
import argparse
import importlib.util
import json
import os
import tarfile
from pathlib import Path
import pytest
from ouroboros.tools.release_sync import (
DESKTOP_DOWNLOAD_IDS,
RELEASE_ASSET_TEMPLATES,
VERSION_CARRIER_SPANS,
release_asset_download_url,
release_asset_name,
version_carrier_desyncs,
)
REPO = Path(__file__).resolve().parents[1]
SPEC = importlib.util.spec_from_file_location("android_release", REPO / "scripts/build_android_release.py")
assert SPEC and SPEC.loader
builder = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(builder)
def test_android_assets_share_the_release_registry_without_changing_desktop_carriers():
assert release_asset_name("android-arm64", "7.1.0-rc.1") == "Ouroboros-7.1.0-rc.1-android-arm64.tar.gz"
assert release_asset_name("android-apk", "7.1.0-rc.1") == "Ouroboros-7.1.0-rc.1-android.apk"
assert len(RELEASE_ASSET_TEMPLATES) == 9
assert len(DESKTOP_DOWNLOAD_IDS) == 7
assert not any("android" in span.carrier_id for span in VERSION_CARRIER_SPANS)
references = "".join(
f"[download-{key}]: {release_asset_download_url(key, '7.1.0')}\n"
for key in DESKTOP_DOWNLOAD_IDS
)
assert version_carrier_desyncs("7.1.0", download_readme_text=references) == []
def _archive_fixture(tmp_path, monkeypatch):
source = tmp_path / "source"
stage = tmp_path / "stage"
output = tmp_path / "dist"
for path in (source, stage, output):
path.mkdir()
for name in builder.REQUIRED_FILES:
path = source / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("first-party source\n", encoding="utf-8")
# A local credential/cache is not part of git's tracked source inventory.
(source / "android/private.keystore").write_bytes(b"never publish")
monkeypatch.setattr(builder, "ROOT", source)
monkeypatch.setattr(builder, "run", lambda *_args, **_kwargs: "\0".join(builder.REQUIRED_FILES))
def bundle(_root, bundle_path, manifest_path, **kwargs):
assert kwargs["source_branch"] == "ouroboros"
bundle_path.write_bytes(b"git bundle fixture")
manifest_path.write_text(json.dumps({
"source_sha": "a" * 40, "release_tag": "v7.1.0",
"bundle_sha256": builder.RELEASE["sha256_file"](bundle_path),
}))
monkeypatch.setitem(builder.BUNDLE, "build_bundle", bundle)
apk = output / "Ouroboros-7.1.0-android.apk"
apk.write_bytes(b"verified APK fixture")
identity = {"packageName": "ai.ouroboros.android", "versionName": "7.1.0",
"versionCode": 12, "signerSha256": "b" * 64}
args = argparse.Namespace(out=output, apk=apk, source_branch="ouroboros",
commit="a" * 40, tag="v7.1.0", version_code=12)
archive = builder.create_archive(args, stage, "7.1.0", identity)
return args, archive, identity
def test_archive_carries_only_tracked_source_and_binds_every_delivered_byte(tmp_path, monkeypatch):
args, archive, identity = _archive_fixture(tmp_path, monkeypatch)
with tarfile.open(archive) as handle:
names = handle.getnames()
assert not any("private.keystore" in name for name in names)
assert "Ouroboros-Android/docs/ANDROID_RECOVERY.md" in names
manifest = json.load(handle.extractfile("Ouroboros-Android/android_release_manifest.json"))
assert set(manifest["files"]) == set(builder.REQUIRED_FILES) | {"repo.bundle", "repo_bundle_manifest.json"}
assert manifest["sourceCommit"] == args.commit
assert manifest["referenceApk"] == {"name": args.apk.name, **builder.file_record(args.apk), **identity}
@pytest.mark.parametrize("tamper", [False, True])
def test_final_archive_inspection_checks_bundle_and_installer_before_receipt(tmp_path, monkeypatch, tamper):
args, archive, identity = _archive_fixture(tmp_path, monkeypatch)
calls = []
def run(argv, **kwargs):
calls.append(argv)
return args.commit + "\n" if argv[:2] == ["git", "rev-parse"] else ""
monkeypatch.setattr(builder, "run", run)
monkeypatch.setattr(builder, "apk_identity", lambda *_args: identity)
if tamper:
args.apk.write_bytes(b"different APK after packaging")
with pytest.raises(ValueError, match="Final Android APK differs"):
builder.inspect_archive(args, archive, tmp_path / "extracted")
else:
assert builder.inspect_archive(args, archive, tmp_path / "extracted") == identity
assert any(argv[:2] == ["git", "clone"] for argv in calls)
assert any(
Path(argv[1]).name == "install.py"
and Path(argv[1]).parent.name == "android"
and argv[-1] == "--help"
for argv in calls
)
def test_release_builder_refuses_missing_key_before_source_or_compiler_work(tmp_path, monkeypatch):
monkeypatch.setattr(builder.sys, "argv", [
"build_android_release.py", "--sdk", str(tmp_path), "--java-home", str(tmp_path),
"--keystore", str(tmp_path / "absent.keystore"),
"--keystore-pass-file", str(tmp_path / "absent.password"),
"--version-code", "12", "--source-branch", "ouroboros",
"--out", str(tmp_path / "out"), "--work", str(tmp_path / "work"),
])
monkeypatch.setattr(builder, "run", lambda *_args, **_kwargs: pytest.fail("no build may run without signing input"))
with pytest.raises(SystemExit) as exc:
builder.main()
assert exc.value.code == 2
assert not (tmp_path / "out").exists()
def test_android_ci_is_fork_safe_and_experimental_for_publication():
workflow = (REPO / ".github/workflows/ci.yml").read_text(encoding="utf-8")
validation = workflow.split(" android-test:", 1)[1].split(" android-build:", 1)[0]
release = workflow.split(" android-build:", 1)[1].split(" release-preflight:", 1)[0]
publication = workflow.split(" release:\n", 1)[1]
assert "secrets." not in validation
assert "--create-development-key" in validation
assert "python -m pytest android/tests tests/test_android_release.py" in validation
assert "--create-development-key" not in release
assert "if: startsWith(github.ref, 'refs/tags/v')" in release
assert "needs: [android-test, android-emulator-smoke, release-preflight]" in release
assert "publisher signing credentials are required" in release
assert "android-build" in next(line for line in publication.splitlines() if "needs:" in line)
assert "secrets." not in release.split("- name: Generate Android source", 1)[1]
assert "always() && !cancelled()" in publication
assert "needs.android-build.result == 'success'" not in publication
assert "needs.build.result == 'success'" in publication
assert "needs.skill-smoke.result == 'success'" in publication
assert "fromJSON(steps.release_proof.outputs.files_json)" in publication
assert "--android-build-result" in publication
assert "--android-attestation-result" in publication
assert "continue-on-error" not in publication
assert "draft: true" in publication
def test_android_ci_has_representative_emulator_matrix_without_calling_it_device_qualification():
import yaml
workflow = (REPO / ".github/workflows/ci.yml").read_text(encoding="utf-8")
smoke = workflow.split(" android-emulator-smoke:", 1)[1].split(" # The publisher key", 1)[0]
assert "api-level: [26, 29, 30, 33, 36]" in smoke
assert "needs: android-test" in smoke
assert "needs.android-test.outputs.android_changed == 'true'" in smoke
assert "startsWith(github.ref, 'refs/tags/v')" in smoke
assert "adb install -r" in smoke
assert "dumpsys package ai.ouroboros.android" in smoke
assert "SELinux" not in smoke
steps = yaml.safe_load(workflow)["jobs"]["android-emulator-smoke"]["steps"]
setup = next(step for step in steps if step.get("uses", "").startswith("android-actions/setup-android@"))
assert setup["with"]["packages"].split() == ["platform-tools"]
@pytest.mark.parametrize("job_name", ["android-test", "android-build"])
@pytest.mark.skipif(os.name == "nt", reason="Exercises the Ubuntu workflow's POSIX Bash step")
def test_android_sdk_cache_uses_the_exported_runner_path(tmp_path, job_name):
import shutil
import subprocess
import yaml
bash = shutil.which("bash")
if not bash:
pytest.skip("the workflow's Bash runner is unavailable")
jobs = yaml.safe_load((REPO / ".github/workflows/ci.yml").read_text(encoding="utf-8"))["jobs"]
steps = jobs[job_name]["steps"]
cache_index = next(i for i, step in enumerate(steps) if step.get("uses", "").startswith("actions/cache@"))
exporter = next(step["run"] for step in steps[:cache_index]
if "ANDROID_HOME=" in step.get("run", "") and "GITHUB_ENV" in step["run"])
sdk = tmp_path / "SDK from runner"
for relative in ("platforms/android-36", "build-tools/36.0.0"):
(sdk / relative).mkdir(parents=True)
env_file = tmp_path / "job-env"
result = subprocess.run([bash, "-c", exporter], capture_output=True, text=True,
env={**os.environ, "ANDROID_HOME": str(sdk), "GITHUB_ENV": str(env_file)})
assert result.returncode == 0, result.stderr
exported = dict(line.split("=", 1) for line in env_file.read_text(encoding="utf-8").splitlines())
assert exported["ANDROID_HOME"] == str(sdk)
paths = steps[cache_index]["with"]["path"].splitlines()
assert {path.replace("${{ env.ANDROID_HOME }}", exported["ANDROID_HOME"]) for path in paths} == {
str(sdk / "platforms/android-36"), str(sdk / "build-tools/36.0.0"),
}
def test_android_smoke_requirements_do_not_claim_a_device_was_tested():
checks = builder.RELEASE["REQUIRED_SMOKE_CHECKS"]
assert checks["android-arm64"] == {"embedded_repo_bundle", "android_source_manifest", "usb_installer_help"}
assert checks["android-apk"] == {"apk_signature", "apk_package_version"}
def test_default_host_build_uses_the_shared_root_asset_path():
source = (REPO / "android/host/build.py").read_text(encoding="utf-8")
assert 'source.parents[1] / "assets" / "icon_1024.png"' in source
assert (REPO / "assets/icon_1024.png").is_file()
@pytest.mark.parametrize(("event", "path", "ref", "expected"), [
("pull_request", "docs/readme.md", "refs/pull/1/merge", False),
("pull_request", "ouroboros/core.py", "refs/pull/1/merge", False),
("pull_request", "android/host/change.java", "refs/pull/1/merge", True),
("push", "android/host/change.java", "refs/heads/ouroboros", True),
("push", "docs/readme.md", "refs/tags/v7.0.0", True),
("schedule", "android/host/change.java", "refs/heads/main", False),
])
@pytest.mark.skipif(os.name == "nt", reason="Exercises the Ubuntu workflow's POSIX Bash step")
def test_android_emulator_selection_uses_event_diff(tmp_path, event, path, ref, expected):
import os
import shutil
import subprocess
import yaml
bash = shutil.which("bash")
if not bash:
pytest.skip("the workflow's Bash runner is unavailable")
jobs = yaml.safe_load((REPO / ".github/workflows/ci.yml").read_text(encoding="utf-8"))["jobs"]
script = next(step["run"] for step in jobs["android-test"]["steps"] if step.get("id") == "android_changes")
subprocess.run(["git", "init", "-q", str(tmp_path)], check=True)
(tmp_path / "README").write_text("base", encoding="utf-8")
subprocess.run(["git", "add", "."], cwd=tmp_path, check=True)
subprocess.run(["git", "-c", "user.name=Test", "-c", "user.email=test@example.invalid", "commit", "-qm", "base"], cwd=tmp_path, check=True)
base = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=tmp_path, text=True).strip()
changed = tmp_path / path
changed.parent.mkdir(parents=True, exist_ok=True)
changed.write_text("change", encoding="utf-8")
subprocess.run(["git", "add", "."], cwd=tmp_path, check=True)
subprocess.run(["git", "-c", "user.name=Test", "-c", "user.email=test@example.invalid", "commit", "-qm", "change"], cwd=tmp_path, check=True)
head = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=tmp_path, text=True).strip()
output = tmp_path / "output"
env = {**os.environ, "GITHUB_SHA": head, "GITHUB_OUTPUT": str(output),
"PR_BASE": base if event == "pull_request" else "", "PR_HEAD": head if event == "pull_request" else "",
"PUSH_BASE": base if event == "push" else ""}
result = subprocess.run([bash, "-c", script], cwd=tmp_path, env=env, capture_output=True, text=True)
assert result.returncode == 0, result.stderr
detected = output.read_text(encoding="utf-8").strip().split("=", 1)[1]
expression = jobs["android-emulator-smoke"]["if"]
for key, value in {"github.event_name": event, "github.ref": ref,
"needs.android-test.outputs.android_changed": detected}.items():
expression = expression.replace(key, repr(value))
expression = expression.replace("&&", " and ").replace("||", " or ")
assert eval(expression, {"__builtins__": {}}, {"startsWith": str.startswith}) is expected
@pytest.mark.parametrize(("failed_job", "result", "cancelled", "expected"), [
("android-build", "failure", False, True),
("android-build", "cancelled", False, True),
("android-build", "skipped", False, True),
*[(job, "failure", False, False) for job in ("build", "release-preflight", "marker-guards",
"ui-smoke", "docker-ui-smoke", "docker-portable-test", "skill-smoke")],
(None, "success", True, False),
])
def test_release_requires_desktop_gates_and_respects_workflow_cancel(failed_job, result, cancelled, expected):
import re
import yaml
job = yaml.safe_load((REPO / ".github/workflows/ci.yml").read_text(encoding="utf-8"))["jobs"]["release"]
expression = job["if"].removeprefix("${{").removesuffix("}}")
for name in job["needs"]:
expression = expression.replace(f"needs.{name}.result", repr(result if name == failed_job else "success"))
expression = expression.replace("github.ref", repr("refs/tags/v7.0.0"))
expression = re.sub(r"!(?!=)", "not ", expression).replace("&&", " and ").replace("||", " or ")
assert eval(f"({expression.strip()})", {"__builtins__": {}}, {
"always": lambda: True, "cancelled": lambda: cancelled, "startsWith": str.startswith,
}) is expected
@pytest.mark.parametrize(("failed_platform", "expected"), [("android-apk", 0), ("macos-arm64", 1), (None, 0)])
@pytest.mark.skipif(os.name == "nt", reason="Executes the Ubuntu release step with a POSIX gh fixture")
def test_attestation_command_failure_excludes_android_but_stops_desktop(tmp_path, failed_platform, expected):
import os
import shutil
import subprocess
import sys
import yaml
bash = shutil.which("bash")
if not bash:
pytest.skip("the workflow's Bash runner is unavailable")
workflow = yaml.safe_load((REPO / ".github/workflows/ci.yml").read_text(encoding="utf-8"))
script = next(step["run"] for step in workflow["jobs"]["release"]["steps"] if step.get("id") == "verify_artifacts")
binaries = tmp_path / "bin"
binaries.mkdir()
(binaries / "python").symlink_to(sys.executable)
gh = binaries / "gh"
gh.write_text(f"#!{sys.executable}\nimport os,sys,pathlib\n"
"with open(os.environ['GH_CALL_LOG'],'a',encoding='utf-8') as stream:\n"
" stream.write(pathlib.Path(sys.argv[3]).name+'\\n')\n"
"raise SystemExit(1 if pathlib.Path(sys.argv[3]).name == os.environ['FAIL_ARTIFACT'] else 0)\n",
encoding="utf-8")
gh.chmod(0o755)
version = (REPO / "VERSION").read_text(encoding="utf-8").strip()
output, calls = tmp_path / "output", tmp_path / "calls"
env = {**os.environ, "PATH": str(binaries) + os.pathsep + os.environ.get("PATH", ""),
"RUNNER_TEMP": str(tmp_path), "GITHUB_OUTPUT": str(output), "ANDROID_BUILD_RESULT": "success",
"GITHUB_REPOSITORY": "example/source", "GITHUB_SHA": "a" * 40, "GITHUB_REF": "refs/tags/v" + version,
"GH_CALL_LOG": str(calls), "FAIL_ARTIFACT": release_asset_name(failed_platform, version) if failed_platform else ""}
result = subprocess.run([bash, "-c", script], cwd=REPO, env=env, capture_output=True, text=True)
assert result.returncode == expected, result.stderr
if expected == 0:
outcome = "failure" if failed_platform else "success"
assert output.read_text(encoding="utf-8").strip() == "android_result=" + outcome
observed = calls.read_text(encoding="utf-8").splitlines()
assert {release_asset_name(key, version) for key in DESKTOP_DOWNLOAD_IDS} <= set(observed)
@pytest.mark.parametrize("api", [26, 29, 30, 33, 36])
@pytest.mark.skipif(os.name == "nt", reason="Exercises the Ubuntu emulator setup's Bash step")
def test_emulator_image_selection_uses_google_apis_only_for_oreo(tmp_path, api):
import shutil
import subprocess
import yaml
bash = shutil.which("bash")
if not bash:
pytest.skip("the workflow's Bash runner is unavailable")
jobs = yaml.safe_load((REPO / ".github/workflows/ci.yml").read_text(encoding="utf-8"))["jobs"]
script = next(step["run"] for step in jobs["android-emulator-smoke"]["steps"]
if step.get("name") == "Install emulator API and compiler")
script = script.replace("${{ matrix.api-level }}", str(api))
sdk = tmp_path / "sdk"
(sdk / "emulator").mkdir(parents=True)
executable = sdk / "emulator/emulator"
executable.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
executable.chmod(0o755)
setup = ('sdkmanager() { printf "%s\\n" "$@" > "$RUNNER_TEMP/sdk-args"; }\n'
'avdmanager() { cat >/dev/null; printf "%s\\n" "$@" > "$RUNNER_TEMP/avd-args"; }\n')
result = subprocess.run([bash, "-c", setup + script], capture_output=True, text=True,
env={**os.environ, "RUNNER_TEMP": str(tmp_path), "ANDROID_HOME": str(sdk),
"GITHUB_ENV": str(tmp_path / "job-env")})
assert result.returncode == 0, result.stderr
expected = "system-images;android-26;google_apis;x86" if api == 26 else f"system-images;android-{api};default;x86_64"
for name in ("sdk-args", "avd-args"):
args = (tmp_path / name).read_text(encoding="utf-8").splitlines()
assert [arg for arg in args if arg.startswith("system-images;")] == [expected]
@pytest.mark.parametrize(("case", "expected_exit", "attempts"), [
("recover", 0, 3), ("dump_error", 7, 5), ("stale", 9, 5), ("crash_dialog", 1, 1),
])
@pytest.mark.skipif(os.name == "nt", reason="Exercises the Ubuntu capture and EXIT trap in Bash")
def test_emulator_capture_retries_fresh_xml_and_preserves_failure_evidence(tmp_path, case, expected_exit, attempts):
import shutil
import subprocess
import yaml
bash = shutil.which("bash")
if not bash:
pytest.skip("the workflow's Bash runner is unavailable")
jobs = yaml.safe_load((REPO / ".github/workflows/ci.yml").read_text(encoding="utf-8"))["jobs"]
script = next(step["run"] for step in jobs["android-emulator-smoke"]["steps"]
if step.get("name") == "Boot emulator and smoke install/start")
capture = "capture_native_ui() {" + script.split("capture_native_ui() {", 1)[1].split("\n}\n", 1)[0] + "\n}\n"
trap = next(line for line in script.splitlines() if line.startswith("trap "))
assertion = next(line for line in script.splitlines() if line.startswith("grep -Fq 'text="))
smoke = tmp_path / "android-smoke"
screenshots = smoke / "screenshots"
screenshots.mkdir(parents=True)
old_xml = ''
(tmp_path / "remote.xml").write_text(old_xml, encoding="utf-8")
(screenshots / "01-access-setup.xml").write_text(old_xml, encoding="utf-8")
(tmp_path / "count").write_text("0", encoding="utf-8")
(smoke / "emulator.log").write_text("emulator evidence\n", encoding="utf-8")
# Command doubles exercise the real workflow shell, without starting adb or a device.
commands = r'''
set -euo pipefail
timeout() { shift; "$@"; }
sleep() { :; }
kill() { :; }
adb() {
case "$1 $2" in
"shell rm") rm -f "$REMOTE_XML" ;;
"shell uiautomator")
count=$(( $(cat "$COUNT") + 1 )); printf '%s' "$count" > "$COUNT"
case "$CASE" in
recover) if [ "$count" -lt 3 ]; then return 0; fi ;;
dump_error) return 7 ;;
stale) return 0 ;;
crash_dialog) printf '' > "$REMOTE_XML"; return 0 ;;
esac
printf '' > "$REMOTE_XML"
;;
"pull /data/local/tmp/obo-native-ui.xml")
if [ ! -s "$REMOTE_XML" ]; then return 9; fi
cp "$REMOTE_XML" "$3" ;;
"exec-out screencap") printf 'screenshot command executed' ;;
"logcat -d") printf 'full logcat evidence' ;;
*) return 88 ;;
esac
}
'''
env = {**os.environ, "CASE": case, "COUNT": str(tmp_path / "count"),
"REMOTE_XML": str(tmp_path / "remote.xml"), "RUNNER_TEMP": str(tmp_path),
"SCREENSHOTS": str(screenshots), "LOG": str(smoke / "emulator.log"), "emulator_pid": "fixture"}
result = subprocess.run([bash, "-c", commands + trap + "\n" + capture
+ "capture_native_ui 01-access-setup\n" + assertion],
env=env, capture_output=True, text=True)
assert result.returncode == expected_exit, result.stderr
assert int((tmp_path / "count").read_text(encoding="utf-8")) == attempts
assert (screenshots / "01-access-setup.png").read_bytes() == b"screenshot command executed"
assert (smoke / "logcat.txt").read_text(encoding="utf-8") == "full logcat evidence"
if case in {"stale", "dump_error"}:
assert not (screenshots / "01-access-setup.xml").exists()