2026-08-01 11:12:33 +00:00
|
|
|
#!/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
|
2026-08-01 13:57:37 +00:00
|
|
|
and it matches. Handles an `.apk`, a Linux bundle `.zip`, a Windows bundle `.zip`
|
|
|
|
|
and an unpacked build **directory** — it finds the Dart AOT snapshot inside each.
|
|
|
|
|
|
|
|
|
|
A Windows `.exe` installer cannot be checked here and is refused rather than
|
|
|
|
|
passed: Inno LZMA-compresses the payload, so the marker only exists in a form
|
|
|
|
|
nothing can grep. That is why the release script gates the staged folder before
|
|
|
|
|
packaging instead of gating the artifact afterwards.
|
2026-08-01 11:12:33 +00:00
|
|
|
|
|
|
|
|
**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].
|
|
|
|
|
"""
|
2026-08-01 13:57:37 +00:00
|
|
|
import os
|
2026-08-01 11:12:33 +00:00
|
|
|
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
|
|
|
|
|
|
2026-08-01 13:57:37 +00:00
|
|
|
if path.lower().endswith('.exe'):
|
|
|
|
|
# Say why rather than throwing a BadZipFile, and fail rather than pass:
|
|
|
|
|
# a checker that prints something reassuring about an artifact it never
|
|
|
|
|
# read is worse than no checker.
|
|
|
|
|
print(f'FAIL {path}: an Inno installer LZMA-compresses its payload, so '
|
|
|
|
|
'the marker is not readable here.\n'
|
|
|
|
|
' The gate for a .exe is Assert-Flavor in '
|
|
|
|
|
'amber-app/scripts/release_windows.ps1, which reads the staged\n'
|
|
|
|
|
' folder BEFORE packaging. Point this script at that folder, '
|
|
|
|
|
'or at the .zip built beside the installer.')
|
|
|
|
|
return 1
|
|
|
|
|
|
2026-08-01 11:12:33 +00:00
|
|
|
found = set()
|
|
|
|
|
checked = []
|
2026-08-01 13:57:37 +00:00
|
|
|
if os.path.isdir(path):
|
|
|
|
|
# The staged build folder, which is where the truth actually lives.
|
2026-08-01 11:12:33 +00:00
|
|
|
for candidate in SNAPSHOTS:
|
2026-08-01 13:57:37 +00:00
|
|
|
f = os.path.join(path, candidate.replace('/', os.sep))
|
|
|
|
|
if os.path.isfile(f):
|
2026-08-01 11:12:33 +00:00
|
|
|
checked.append(candidate)
|
2026-08-01 13:57:37 +00:00
|
|
|
with open(f, 'rb') as fh:
|
|
|
|
|
found |= markers_in(fh.read())
|
|
|
|
|
else:
|
|
|
|
|
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))
|
2026-08-01 11:12:33 +00:00
|
|
|
|
|
|
|
|
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]))
|