#!/usr/bin/env python3 """Package a built mobile release and allowlisted source without signing secrets. Run tool/build.py distribution first. This script does not build or deploy. """ import datetime import hashlib import json from pathlib import Path import re import shutil import subprocess import tempfile import zipfile ROOT = Path(__file__).resolve().parents[1] WORKSPACE = ROOT.parent INFO = (ROOT / 'lib/core/build_info.dart').read_text() BUILD_ID = re.search(r"const buildId = '([^']+)'", INFO).group(1) VERSION = re.search(r"const appVersion = '([^']+)'", INFO).group(1) if not re.fullmatch(r'sm-mobile-\d{8}T\d{6}Z-[a-f0-9]{12}', BUILD_ID): raise SystemExit('Invalid build ID') RELEASE = WORKSPACE / 'POUZE-VYVOJOVA-DATA-BEZ-ZAVISLOSTI-NA-APP/builds' / BUILD_ID RELEASE.mkdir(exist_ok=False) FILES = RELEASE / 'source/files' FILES.mkdir(parents=True) # Explicit roots; local SDK configuration and generated Xcode configuration are excluded. selected = [ROOT / name for name in [ 'README.md', 'LICENSE', 'NOTICE', 'pubspec.yaml', 'pubspec.lock', 'analysis_options.yaml', 'l10n.yaml', '.gitignore', '.metadata', 'android/build.gradle.kts', 'android/settings.gradle.kts', 'android/gradle.properties', 'android/gradlew', 'android/gradlew.bat', 'android/app/build.gradle.kts', 'ios/Flutter/AppFrameworkInfo.plist', 'ios/Flutter/Debug.xcconfig', 'ios/Flutter/Release.xcconfig', 'ios/Runner.xcodeproj/project.pbxproj', 'ios/Runner.xcworkspace/contents.xcworkspacedata', ]] for folder in ['lib', 'assets', 'docs', 'test', 'integration_test', 'tool', 'android/gradle/wrapper', 'android/app/src', 'ios/Runner', 'ios/RunnerTests', 'ios/Runner.xcodeproj/xcshareddata', 'ios/Runner.xcworkspace/xcshareddata']: selected.extend((ROOT / folder).rglob('*')) entries = [] for source in sorted(set(selected)): if source.is_symlink(): raise SystemExit('Symlink in source allowlist: ' + str(source)) if not source.is_file(): continue relative = source.relative_to(ROOT) if source.name.startswith('.') and source.name not in ['.gitignore', '.metadata']: continue if any(part in ['__pycache__', '.dart_tool', 'build', 'ephemeral', '.swiftpm', 'xcuserdata'] for part in relative.parts): continue if source.name in ['GeneratedPluginRegistrant.java', 'GeneratedPluginRegistrant.h', 'GeneratedPluginRegistrant.m']: continue if source.suffix.lower() in ['.jks', '.keystore', '.mobileprovision', '.p12', '.p8', '.pem', '.key', '.pyc'] or source.name in ['key.properties', 'local.properties', 'Generated.xcconfig', 'flutter_export_environment.sh']: raise SystemExit('Private or machine-specific file in source allowlist: ' + str(relative)) if not re.fullmatch(r'[A-Za-z0-9_@.\/-]+', relative.as_posix()): raise SystemExit('Unsupported source browser path: ' + str(relative)) data = source.read_bytes() destination = FILES / relative destination.parent.mkdir(parents=True, exist_ok=True) destination.write_bytes(data) destination.chmod(0o755 if source.name == 'gradlew' else 0o644) try: text = data.decode('utf-8') is_text = '\0' not in text except UnicodeDecodeError: text, is_text = '', False entries.append(dict(path=relative.as_posix(), size=len(data), sha256=hashlib.sha256(data).hexdigest(), text=is_text, lines=len(text.splitlines()) if is_text else None)) archive = RELEASE / f'CH-J-SM-Mobile-{VERSION}-{BUILD_ID}-source.zip' with zipfile.ZipFile(archive, 'w', zipfile.ZIP_DEFLATED, compresslevel=9) as handle: for entry in entries: handle.write(FILES / entry['path'], 'app/' + entry['path']) built_at = datetime.datetime.strptime(BUILD_ID.split('-')[2], '%Y%m%dT%H%M%SZ').replace(tzinfo=datetime.timezone.utc).isoformat() manifest = dict(buildId=BUILD_ID, version=VERSION, edition='mobile', builtAt=built_at, addedAt=datetime.datetime.now(datetime.timezone.utc).isoformat(), license='CH-J Proprietary Software License 1.14', archive=archive.name, archiveSize=archive.stat().st_size, archiveSha256=hashlib.sha256(archive.read_bytes()).hexdigest(), files=entries) (RELEASE / 'source/manifest.json').write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + '\n') artifacts = [] def add(path, kind, platform, arch, signing, certificate=None): data = path.read_bytes() entry = dict(id=hashlib.sha256((BUILD_ID + ':' + kind).encode()).hexdigest()[:24], type='mobile', kind=kind, active=True, version=VERSION, build_id=BUILD_ID, platform=platform, arch=arch, signing=signing, channel='alpha', artifact_role='installer', filename=path.name, file=f'files/mobile/{BUILD_ID}/{path.name}', size=len(data), sha512=hashlib.sha512(data).hexdigest(), sha256=hashlib.sha256(data).hexdigest(), published_at=manifest['addedAt']) if certificate: entry['certificateSha256'] = certificate artifacts.append(entry) # Verify APK signature using the same Java runtime as the Flutter Android toolchain. import os java_home = '/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home' android_sdk = Path(dict(line.split('=', 1) for line in (ROOT / 'android/local.properties').read_text().splitlines() if '=' in line)['sdk.dir']) checker = sorted((android_sdk / 'build-tools').glob('*/apksigner'))[-1] signature = subprocess.check_output([str(checker), 'verify', '--verbose', '--print-certs', str(ROOT / 'build/app/outputs/flutter-apk/app-release.apk')], env={**os.environ, 'JAVA_HOME': java_home}, text=True) (RELEASE / 'android-signature.txt').write_text(signature) certificate = re.search(r'Signer #1 certificate SHA-256 digest: ([a-f0-9]+)', signature).group(1) for kind, original in [('apk', 'build/app/outputs/flutter-apk/app-release.apk'), ('aab', 'build/app/outputs/bundle/release/app-release.aab')]: destination = RELEASE / f'CH-J-SM-Mobile-{VERSION}-{BUILD_ID}.{kind}' shutil.copyfile(ROOT / original, destination) add(destination, kind, 'android', 'arm64-v8a / armeabi-v7a / x86_64', 'android-release', certificate) # ZIP the actual device .app under Payload without inventing an Apple signature. ipa = RELEASE / f'CH-J-SM-Mobile-{VERSION}-{BUILD_ID}-unsigned.ipa' with tempfile.TemporaryDirectory() as temporary: payload = Path(temporary) / 'Payload' payload.mkdir() subprocess.run(['ditto', str(ROOT / 'build/ios/iphoneos/Runner.app'), str(payload / 'Runner.app')], check=True) subprocess.run(['ditto', '-c', '-k', '--keepParent', str(payload), str(ipa)], check=True) add(ipa, 'ipa', 'ios', 'arm64', 'unsigned') simulator = ROOT / 'build/ios/iphonesimulator/Runner.app' if simulator.is_dir(): destination = RELEASE / f'CH-J-SM-Mobile-{VERSION}-{BUILD_ID}-simulator.app.zip' subprocess.run(['ditto', '-c', '-k', '--keepParent', str(simulator), str(destination)], check=True) add(destination, 'simulator', 'ios-simulator', 'arm64', 'simulator-only') (RELEASE / 'mobile-release.json').write_text(json.dumps(dict(buildId=BUILD_ID, version=VERSION, artifacts=artifacts), indent=2) + '\n') (RELEASE / 'SHA512SUMS.txt').write_text(''.join(f"{entry['sha512']} {entry['filename']}\n" for entry in artifacts) + hashlib.sha512(archive.read_bytes()).hexdigest() + ' ' + archive.name + '\n') print(json.dumps(dict(release=str(RELEASE), buildId=BUILD_ID, files=len(entries), artifacts=len(artifacts))))