Add check-flavor.py: verify an artifact's flavour without installing it

Reads the Dart AOT snapshot out of an .apk or a Linux/Windows bundle .zip and
asserts exactly one AMBER_FLAVOR:: marker matching the expected variant. See the
amber-app commit for why nothing cheaper distinguishes the two builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Claude 2026-08-01 13:12:33 +02:00
parent 1d50e5a680
commit 4bf6523ab3

80
scripts/check-flavor.py Executable file
View file

@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""Verify a built artifact's flavour without installing or launching it.
check-flavor.py <artifact> <expected: adult|clean>
Exits non-zero unless the artifact contains exactly one `AMBER_FLAVOR::` marker
and it matches. Handles an `.apk`, a Linux bundle `.zip` and a Windows bundle
`.zip` it finds the Dart AOT snapshot inside each.
**Why this exists.** The clean flavour is what anonymous downloads and every
child profile receive, so publishing an adult build into a clean slot is the
worst mistake this release process can make and until now the only way to tell
the two apart offline was to install the APK on a television and look at the tab
row. That made a content-safety gate depend on a TV being awake and on adb still
being authorised, which is exactly how it gets skipped "just this once".
Nothing cheaper worked. Class names such as `AdultBrowse` survive in **both**
snapshots whatever the dart-define, and the `'adult'`/`'clean'` strings the
updater compares sit inside a function body, so both literals ship in both
builds. The two APKs are also routinely **byte-identical in size** (zip
alignment absorbs the difference), so size proves nothing either. `config.dart`
therefore compiles in a deliberate const-folded marker; see [kFlavorMarker].
"""
import re
import sys
import zipfile
MARKER = re.compile(rb'AMBER_FLAVOR::(adult|clean)')
# Where the Dart AOT snapshot lives, per artifact kind.
SNAPSHOTS = (
'lib/arm64-v8a/libapp.so', # apk
'lib/armeabi-v7a/libapp.so', # apk, 32-bit
'lib/libapp.so', # linux bundle zip
'data/app.so', # windows bundle zip
)
def markers_in(blob: bytes) -> set:
return {m.group(1).decode() for m in MARKER.finditer(blob)}
def main(path: str, expected: str) -> int:
if expected not in ('adult', 'clean'):
print(f'error: expected must be adult|clean, got {expected!r}')
return 2
found = set()
checked = []
with zipfile.ZipFile(path) as z:
names = set(z.namelist())
for candidate in SNAPSHOTS:
if candidate in names:
checked.append(candidate)
found |= markers_in(z.read(candidate))
if not checked:
# Better to fail loudly than to pass an artifact nothing was read from.
print(f'FAIL {path}: no Dart snapshot found (looked for {SNAPSHOTS})')
return 1
if not found:
print(f'FAIL {path}: no AMBER_FLAVOR marker in {checked}'
'built before the marker existed?')
return 1
if len(found) > 1:
print(f'FAIL {path}: ambiguous, contains {sorted(found)}')
return 1
actual = found.pop()
if actual != expected:
print(f'FAIL {path}: is {actual!r}, expected {expected!r}')
return 1
print(f'ok {path}: {actual} (from {", ".join(checked)})')
return 0
if __name__ == '__main__':
if len(sys.argv) != 3:
raise SystemExit(__doc__)
raise SystemExit(main(sys.argv[1], sys.argv[2]))