[CI, Android] (Disaggregated): ship and verify inproc APKs on disaggregation branches

This commit is contained in:
2026-09-20 10:16:54 -04:00
parent 30b1bf25a4
commit 7e82ea0fbf
4 changed files with 141 additions and 11 deletions
+79 -2
View File
@@ -10,6 +10,7 @@ on:
# branch runs the full lane on every push so a phase's landing is not gated on
# someone remembering to dispatch the workflow by hand.
- feat/disaggregated
- codex/fix-github-ci
workflow_dispatch:
jobs:
@@ -25,6 +26,7 @@ jobs:
CCACHE_MAXSIZE: 4G
CCACHE_NOHASHDIR: "true"
MOBILEGL_CMAKE_COMPILER_LAUNCHER: ccache
MOBILEGL_CI_DISAGGREGATED: ${{ (github.ref_name == 'feat/disaggregated' || github.ref_name == 'codex/fix-github-ci') && 'ON' || 'OFF' }}
steps:
- name: Checkout repo
@@ -81,7 +83,13 @@ jobs:
run: python update_glslang_sources.py
- name: Build plugin APK
run: gradle --no-daemon -p android-plugin :app:assemblePluginRelease -Pmobilegl.apkSuffix="${GITHUB_SHA}" -Pmobilegl.logLevel=MOBILEGL_LOG_LEVEL_INFO --parallel --max-workers "$(nproc)"
run: >-
gradle --no-daemon -p android-plugin :app:assemblePluginRelease
-Pmobilegl.apkSuffix="${GITHUB_SHA}" -Pmobilegl.logLevel=MOBILEGL_LOG_LEVEL_INFO
-Pmobilegl.buildDisaggregated="${MOBILEGL_CI_DISAGGREGATED}"
-Pmobilegl.buildDisaggregatedInproc="${MOBILEGL_CI_DISAGGREGATED}"
-Pmobilegl.pipePush="${MOBILEGL_CI_DISAGGREGATED}"
--parallel --max-workers "$(nproc)"
env:
SIGNING_STORE_PASSWORD: ${{ secrets.SIGNING_STORE_PASSWORD }}
SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS }}
@@ -123,12 +131,74 @@ jobs:
66fdc867e552192d553d59095ea2e3cef4829de65c356f1fd826027b1905972e
- name: Build retrace APK
run: gradle --no-daemon -p android-plugin :app:assembleTraceRelease -Pmobilegl.apkSuffix="${GITHUB_SHA}" -Pmobilegl.abis=all -Pmobilegl.debuggableRelease=true -Pmobilegl.logLevel=MOBILEGL_LOG_LEVEL_INFO --parallel --max-workers "$(nproc)"
run: >-
gradle --no-daemon -p android-plugin :app:assembleTraceRelease
-Pmobilegl.apkSuffix="${GITHUB_SHA}" -Pmobilegl.abis=all
-Pmobilegl.debuggableRelease=true -Pmobilegl.logLevel=MOBILEGL_LOG_LEVEL_INFO
-Pmobilegl.buildDisaggregated="${MOBILEGL_CI_DISAGGREGATED}"
-Pmobilegl.buildDisaggregatedInproc="${MOBILEGL_CI_DISAGGREGATED}"
-Pmobilegl.pipePush="${MOBILEGL_CI_DISAGGREGATED}"
--parallel --max-workers "$(nproc)"
env:
SIGNING_STORE_PASSWORD: ${{ secrets.SIGNING_STORE_PASSWORD }}
SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS }}
SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }}
- name: Prove disaggregated native code in both APKs
if: env.MOBILEGL_CI_DISAGGREGATED == 'ON'
run: |
python3 - <<'PY'
import hashlib, json, os, re, zipfile
from pathlib import Path
proofs = {}
for path in Path('.').rglob('compile_commands.json'):
rows = json.loads(path.read_text())
production = [row for row in rows if row['file'].replace('\\', '/').endswith('/MobileGL/ConfigLoader.cpp')]
if not production:
continue
cache = path.with_name('CMakeCache.txt').read_text()
abi = re.search(r'^ANDROID_ABI:[^=]+=(.+)$', cache, re.MULTILINE)
if not abi:
raise SystemExit(f'no Android ABI identity beside {path}')
for row in production:
command = row.get('command') or ' '.join(row['arguments'])
for macro in ('MOBILEGL_BUILD_DISAGGREGATED', 'MOBILEGL_PIPE_PUSH'):
if not re.search(r'-D' + macro + r'=1(?:\s|$)', command):
raise SystemExit(f'{path}: production command lacks {macro}=1')
proofs.setdefault(abi[1].strip(), []).append(str(path))
if not proofs:
raise SystemExit('no production compile commands prove a disaggregated APK build')
artifacts = []
marker = b'Config: MOBILEGL_TRANSPORT=inproc - the MGPipe record stream'
for flavor in ('plugin', 'trace'):
stem = 'MobileGL-plugin-' + ('trace-' if flavor == 'trace' else '')
apk = Path(f'android-plugin/app/build/outputs/apk/{flavor}/release/{stem}release-{os.environ["GITHUB_SHA"]}.apk')
libraries = []
with zipfile.ZipFile(apk) as archive:
for name in archive.namelist():
if not re.fullmatch(r'lib/[^/]+/libMobileGL\.so', name):
continue
abi = name.split('/')[1]
library = archive.read(name)
if abi not in proofs or marker not in library:
raise SystemExit(f'{apk}:{name}: missing compile or compiled transport evidence')
libraries.append({'abi': abi, 'path': name, 'sha256': hashlib.sha256(library).hexdigest()})
required = {'arm64-v8a', 'x86_64'} if flavor == 'trace' else {'arm64-v8a'}
if not required.issubset({library['abi'] for library in libraries}):
raise SystemExit(f'{apk}: requested ABI coverage is missing: {required}')
artifacts.append({'apk': str(apk), 'sha256': hashlib.sha256(apk.read_bytes()).hexdigest(), 'libraries': libraries})
Path('apk-build-mode.json').write_text(json.dumps({'mode': 'disaggregated-inproc', 'source_sha': os.environ['GITHUB_SHA'],
'production_compile_commands_by_abi': proofs, 'artifacts': artifacts}, indent=2))
PY
- name: Upload APK build-mode evidence
if: env.MOBILEGL_CI_DISAGGREGATED == 'ON'
uses: actions/upload-artifact@v7
with:
name: MobileGL-apk-build-mode-${{ github.sha }}
path: apk-build-mode.json
if-no-files-found: error
- name: Show ccache stats
if: always()
run: ccache --show-stats
@@ -338,6 +408,7 @@ jobs:
if: ${{ always() && needs.build.result == 'success' && needs.android-avd.result == 'success' && needs.trace-cases.result == 'success' }}
timeout-minutes: 75
env:
MOBILEGL_CI_DISAGGREGATED: ${{ (github.ref_name == 'feat/disaggregated' || github.ref_name == 'codex/fix-github-ci') && 'ON' || 'OFF' }}
AVD_NAME: mobilegl-ci
ANDROID_AVD_HOME: ${{ github.workspace }}/.android/avd
ANDROID_HOME: ${{ github.workspace }}/.android/sdk
@@ -437,6 +508,12 @@ jobs:
apk_file="android-retrace-apks/MobileGL-plugin-trace-release-${GITHUB_SHA}.apk"
test -f "${apk_file}"
extra_retrace_args=()
if [ "${MOBILEGL_CI_DISAGGREGATED}" = "ON" ]; then
extra_retrace_args+=(
--env "MOBILEGL_TRANSPORT=inproc;MOBILEGL_IPC_ROLE_SPLIT_STATE=1;MOBILEGL_IPC_STRICT_ERRORS=1;MOBILEGL_IPC_RUN_AHEAD=1"
--require-inproc
)
fi
# Bliss needs the newer signed ANGLE variant plus sampler mipmap
# min-filter downgrading on ANGLE llvmpipe.
if [ "${{ matrix.backend.name }}" = "DirectGLES" ] && [ "${{ matrix.case.name }}" = "minecraft-1.21.4-fabric-iris-bliss-in-world" ]; then
+13
View File
@@ -33,6 +33,15 @@ fun Project.mobileGlAbiFilters(): List<String> {
fun Project.mobileGlCmakeCompilerLauncher(): String =
(findProperty("mobilegl.cmakeCompilerLauncher") ?: System.getenv("MOBILEGL_CMAKE_COMPILER_LAUNCHER") ?: "").toString().trim()
// Match the native module's two switches, including INPROC implying the parent
// option. A D/P plugin must ask its launcher to select the transport at runtime;
// compiling MG_Remote alone leaves ConfigLoader's default on monolith.
fun Project.mobileGlDisaggregatedEnabled(): Boolean {
fun enabled(value: Any?): Boolean = value?.toString()?.trim()?.uppercase().orEmpty() in setOf("1", "ON", "YES", "TRUE", "Y")
return enabled(findProperty("mobilegl.buildDisaggregated") ?: System.getenv("MOBILEGL_BUILD_DISAGGREGATED")) ||
enabled(findProperty("mobilegl.buildDisaggregatedInproc") ?: System.getenv("MOBILEGL_BUILD_DISAGGREGATED_INPROC"))
}
// Optional application-id suffix, so a development build can sit next to an
// already-installed plugin instead of having to replace it - a differently
// signed APK cannot upgrade one in place, and uninstalling costs the user their
@@ -73,6 +82,7 @@ val mobileGlMonthlyRevision = runGit(
val mobileGlApkSuffix = (findProperty("mobilegl.apkSuffix") ?: System.getenv("MOBILEGL_APK_SUFFIX") ?: mobileGlGitShortHash)
.toString()
.ifBlank { "nogit" }
val mobileGlDisaggregated = mobileGlDisaggregatedEnabled()
val pluginRendererConfig = buildJsonValue {
renderer(
@@ -83,6 +93,7 @@ val pluginRendererConfig = buildJsonValue {
dlopenLibPaths = emptyList(),
env = buildEnvs {
normal("LIBGL_ES", "3")
if (mobileGlDisaggregated) normal("MOBILEGL_TRANSPORT", "inproc")
selectable(
key = "MOBILEGL_BACKEND_TYPE",
title = RendererConfig.MetaString("mobilegl_backend_type_title"),
@@ -127,11 +138,13 @@ android {
put("LIBGL_ES", "3")
put("POJAV_RENDERER", "opengles3")
put("MOBILEGL_BACKEND_TYPE", "DirectGLES")
if (mobileGlDisaggregated) put("MOBILEGL_TRANSPORT", "inproc")
}
pojavEnv {
put("LIBGL_ES", "3")
put("POJAV_RENDERER", "opengles3")
put("MOBILEGL_BACKEND_TYPE", "DirectGLES")
if (mobileGlDisaggregated) put("MOBILEGL_TRANSPORT", "inproc")
}
})
manifestPlaceholders["appLabel"] = "MobileGL"
+37 -1
View File
@@ -33,6 +33,7 @@ Usage:
[--coherent-as-flush] \
[--dump-texture-2d CALL,TEXTURE,LEVEL,DIR] \
[--env "K=V;K=V"] \
[--require-inproc] \
[--benchmark] \
[--benchmark-tail-frames N] \
[--benchmark-finish 0|1] \
@@ -66,6 +67,9 @@ variables to the replay process. They are applied last, immediately before
libMobileGL.so is loaded, so they override every flag above; an entry with no "="
unsets the variable instead. This is the generic passthrough: a MOBILEGL_* knob
that has no flag of its own needs no plumbing to be forwarded.
Pass --require-inproc to require the library's real transport-resolution log,
strict errors and separate role state, plus zero Fatal records. This checks the
APK process after replay; a host environment echo or an SSIM-only pass is not proof.
EOF
}
@@ -126,6 +130,7 @@ avoid_angle_llvmpipe_explicit_lod_bias=0
coherent_as_flush=0
texture_2d_dumps=""
env_overrides="${MOBILEGL_TRACE_ENV:-}"
require_inproc=0
benchmark=0
benchmark_tail_frames=200
benchmark_finish=1
@@ -172,6 +177,7 @@ while [ "$#" -gt 0 ]; do
--coherent-as-flush) coherent_as_flush=1; shift 1 ;;
--dump-texture-2d) texture_2d_dumps="$(next_arg "$@")"; shift 2 ;;
--env) env_overrides="$(next_arg "$@")"; shift 2 ;;
--require-inproc) require_inproc=1; shift 1 ;;
--benchmark) benchmark=1; shift 1 ;;
--benchmark-tail-frames) benchmark_tail_frames="$(next_arg "$@")"; shift 2 ;;
--benchmark-finish) benchmark_finish="$(next_arg "$@")"; shift 2 ;;
@@ -518,7 +524,37 @@ run_retrace() {
fi
fi
"${PYTHON}" -c 'import json, sys; result = json.load(open(sys.argv[1], encoding="utf-8")); sys.exit(0 if result.get("passed") else f"trace replay failed: {result}")' "${result_dir}/result.json"
"${PYTHON}" -c 'import json, sys; result = json.load(open(sys.argv[1], encoding="utf-8")); sys.exit(0 if result.get("passed") else f"trace replay failed: {result}")' "${result_dir}/result.json" || return "$?"
if [ "${require_inproc}" -eq 1 ]; then
"${PYTHON}" - "${result_dir}/mobilegl.log" "${result_dir}/transport-proof.json" <<'PY'
import json
from pathlib import Path
import re
import sys
log_path, proof_path = map(Path, sys.argv[1:])
text = log_path.read_text(encoding="utf-8", errors="replace") if log_path.is_file() else ""
# ConfigLoader's InProcess arm emits this sentence only after selecting the
# compiled transport. "Accepted env variable: MOBILEGL_TRANSPORT=inproc" is not it.
marker = "Config: MOBILEGL_TRANSPORT=inproc - the MGPipe record stream"
configs = re.findall(r"Config: IPC[^\r\n]*", text)
fatals = re.findall(r"^.*Fatal\{.*$", text, re.MULTILINE)
required = {"strict": "1", "role-split-state": "1", "run-ahead": "1"}
config_ok = bool(configs) and all(
all(re.search(r"\b" + re.escape(key) + "=" + value + r"\b", line) for key, value in required.items())
for line in configs
)
passed = marker in text and config_ok and not fatals
proof = {"required_transport": "inproc", "library_log": str(log_path),
"transport_resolution_marker": marker in text, "ipc_config_lines": configs,
"required_ipc_settings": required, "ipc_settings_match": config_ok,
"fatal_count": len(fatals), "fatal_lines": fatals, "passed": passed}
proof_path.write_text(json.dumps(proof, indent=2), encoding="utf-8")
if not passed:
sys.exit("trace replay failed actual inproc/strict/role/zero-Fatal proof: " + json.dumps(proof))
print("Android retrace: real inproc transport, strict role state and zero Fatal records confirmed")
PY
fi
}
mkdir -p "${fixture_root}" "${result_root}"
+12 -8
View File
@@ -15,14 +15,17 @@ def mobileGlBuildServerSpike = {
(rootProject.findProperty('mobilegl.buildServerSpike') ?: System.getenv('MOBILEGL_BUILD_SERVER_SPIKE') ?: 'OFF') as String
}
// P2 A/B opt-in. OFF by default, so the plugin flavour and every trace APK nobody asked for
// keep the pull shape; the paired two-device A/B (docs/Disaggregated ROADMAP GO/NO-GO items
// 3-4) builds one trace APK with it ON and compares it against the OFF build. Either
// ./gradlew :app:assembleTraceDebug -Pmobilegl.pipePush=ON
// or MOBILEGL_PIPE_PUSH_APK=ON in the environment.
// Shared native shape for BOTH plugin and trace APKs. Defaults keep existing
// callers on pull; disaggregated CI explicitly selects all three switches.
def mobileGlPipePush = {
(rootProject.findProperty('mobilegl.pipePush') ?: System.getenv('MOBILEGL_PIPE_PUSH_APK') ?: 'OFF') as String
}
def mobileGlBuildDisaggregated = {
(rootProject.findProperty('mobilegl.buildDisaggregated') ?: System.getenv('MOBILEGL_BUILD_DISAGGREGATED') ?: 'OFF') as String
}
def mobileGlBuildDisaggregatedInproc = {
(rootProject.findProperty('mobilegl.buildDisaggregatedInproc') ?: System.getenv('MOBILEGL_BUILD_DISAGGREGATED_INPROC') ?: mobileGlBuildDisaggregated()) as String
}
android {
namespace 'top.mobilegl.mobilegl'
@@ -36,6 +39,10 @@ android {
externalNativeBuild {
cmake {
arguments "-DMOBILEGL_LOG_ACTIVE_LEVEL=${mobileGlLogActiveLevel()}"
arguments '-DCMAKE_EXPORT_COMPILE_COMMANDS=ON'
arguments "-DMOBILEGL_PIPE_PUSH=${mobileGlPipePush()}"
arguments "-DMOBILEGL_BUILD_DISAGGREGATED=${mobileGlBuildDisaggregated()}"
arguments "-DMOBILEGL_BUILD_DISAGGREGATED_INPROC=${mobileGlBuildDisaggregatedInproc()}"
}
}
}
@@ -79,9 +86,6 @@ android {
// grow a binary nothing loads. See mobileGlBuildServerSpike
// above for the two ways to enable it.
arguments "-DMOBILEGL_BUILD_SERVER_SPIKE=${mobileGlBuildServerSpike()}"
// P2: the push-shaped library for the paired A/B, same flavour, same
// signing, one extra CMake option. See mobileGlPipePush above.
arguments "-DMOBILEGL_PIPE_PUSH=${mobileGlPipePush()}"
}
}
}