#!/usr/bin/env python3 from __future__ import annotations import argparse import datetime as dt import hashlib import json import re import shutil import subprocess import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] PUBSPEC_PATH = ROOT / "pubspec.yaml" def parse_pubspec_identity(pubspec_path: Path) -> tuple[str, str, str]: content = pubspec_path.read_text(encoding="utf-8") name_match = re.search(r"^name:\s*([^\s]+)\s*$", content, re.MULTILINE) version_match = re.search(r"^version:\s*([^\s]+)\s*$", content, re.MULTILINE) if not name_match or not version_match: raise RuntimeError("Failed to read app name or version from pubspec.yaml") package_name = name_match.group(1) version = version_match.group(1) if "+" in version: build_name, build_number = version.split("+", 1) else: build_name, build_number = version, "1" return package_name, build_name, build_number def sha256_of(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def run_command(command: list[str]) -> None: print(f"$ {' '.join(command)}", flush=True) subprocess.run(command, cwd=ROOT, check=True) def ensure_clean_dir(path: Path) -> None: if path.exists(): shutil.rmtree(path) path.mkdir(parents=True, exist_ok=True) def copy_file(src: Path, dest: Path) -> dict[str, object]: dest.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(src, dest) return { "path": str(dest.relative_to(ROOT)), "sizeBytes": dest.stat().st_size, "sha256": sha256_of(dest), } def copy_tree(src: Path, dest: Path) -> None: ensure_clean_dir(dest) shutil.copytree(src, dest, dirs_exist_ok=True) def append_common_flutter_args(command: list[str], args: argparse.Namespace) -> None: command.extend(["--release", f"--target={args.target}"]) if args.flavor: command.extend(["--flavor", args.flavor]) if args.build_name: command.append(f"--build-name={args.build_name}") if args.build_number: command.append(f"--build-number={args.build_number}") for item in args.dart_define: command.extend(["--dart-define", item]) for item in args.dart_define_from_file: command.extend(["--dart-define-from-file", item]) def build_android(args: argparse.Namespace, manifest: dict[str, object]) -> None: android_symbols_dir = ROOT / "build" / "symbols" / "android" android_output_dir = args.output_dir / "android" appbundle_cmd = ["flutter", "build", "appbundle"] append_common_flutter_args(appbundle_cmd, args) appbundle_cmd.append(f"--split-debug-info={android_symbols_dir.relative_to(ROOT)}") run_command(appbundle_cmd) apk_cmd = [ "flutter", "build", "apk", "--split-per-abi", "--target-platform", "android-arm,android-arm64,android-x64", ] append_common_flutter_args(apk_cmd, args) apk_cmd.append(f"--split-debug-info={android_symbols_dir.relative_to(ROOT)}") run_command(apk_cmd) google_play_dir = android_output_dir / "google-play" local_dir = android_output_dir / "local" testing_dir = android_output_dir / "testing" artifact_prefix = f"{args.package_name}-v{args.build_name}-b{args.build_number}" aab_src = ROOT / "build" / "app" / "outputs" / "bundle" / "release" / "app-release.aab" arm64_src = ROOT / "build" / "app" / "outputs" / "flutter-apk" / "app-arm64-v8a-release.apk" armv7_src = ROOT / "build" / "app" / "outputs" / "flutter-apk" / "app-armeabi-v7a-release.apk" x64_src = ROOT / "build" / "app" / "outputs" / "flutter-apk" / "app-x86_64-release.apk" artifacts = { "googlePlayAab": copy_file(aab_src, google_play_dir / f"{artifact_prefix}-google-play.aab"), "localArm64Apk": copy_file(arm64_src, local_dir / f"{artifact_prefix}-arm64-v8a.apk"), "localArmeabiV7aApk": copy_file(armv7_src, local_dir / f"{artifact_prefix}-armeabi-v7a.apk"), "testingX64Apk": copy_file(x64_src, testing_dir / f"{artifact_prefix}-x86_64-test.apk"), } if android_symbols_dir.exists(): copy_tree(android_symbols_dir, google_play_dir / "symbols") artifacts["dartSymbolsDir"] = { "path": str((google_play_dir / "symbols").relative_to(ROOT)), "sizeBytes": sum(path.stat().st_size for path in (google_play_dir / "symbols").rglob("*") if path.is_file()), } manifest["android"] = artifacts def build_ios(args: argparse.Namespace, manifest: dict[str, object]) -> None: ios_symbols_dir = ROOT / "build" / "symbols" / "ios" ios_output_dir = args.output_dir / "ios" command = ["flutter", "build", "ipa"] append_common_flutter_args(command, args) command.append(f"--split-debug-info={ios_symbols_dir.relative_to(ROOT)}") if args.ios_codesign: command.extend(["--export-method", args.ios_export_method]) if args.ios_export_options_plist: command.append(f"--export-options-plist={args.ios_export_options_plist}") else: command.append("--no-codesign") run_command(command) artifact_prefix = f"{args.package_name}-v{args.build_name}-b{args.build_number}" artifacts: dict[str, object] = {} archive_src = ROOT / "build" / "ios" / "archive" / "Runner.xcarchive" ipa_candidates = sorted((ROOT / "build" / "ios" / "ipa").glob("*.ipa")) if archive_src.exists(): copy_tree(archive_src, ios_output_dir / "archive" / f"{artifact_prefix}.xcarchive") artifacts["archiveDir"] = { "path": str((ios_output_dir / "archive" / f"{artifact_prefix}.xcarchive").relative_to(ROOT)), "sizeBytes": sum(path.stat().st_size for path in (ios_output_dir / "archive" / f"{artifact_prefix}.xcarchive").rglob("*") if path.is_file()), } if ipa_candidates: ipa_src = ipa_candidates[-1] artifacts["ipa"] = copy_file(ipa_src, ios_output_dir / "ipa" / f"{artifact_prefix}.ipa") if ios_symbols_dir.exists(): copy_tree(ios_symbols_dir, ios_output_dir / "symbols") artifacts["dartSymbolsDir"] = { "path": str((ios_output_dir / "symbols").relative_to(ROOT)), "sizeBytes": sum(path.stat().st_size for path in (ios_output_dir / "symbols").rglob("*") if path.is_file()), } if not artifacts: raise RuntimeError("iOS build finished but no archive or ipa artifact was found.") manifest["ios"] = artifacts def create_argument_parser() -> argparse.ArgumentParser: package_name, build_name, build_number = parse_pubspec_identity(PUBSPEC_PATH) timestamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S") default_output_dir = ROOT / "dist" / "release" / timestamp parser = argparse.ArgumentParser( description="Build release artifacts for Google Play, local Android distribution, and iOS." ) parser.add_argument( "--platform", choices=["all", "android", "ios"], default="all", help="Select which platform artifacts to build.", ) parser.add_argument("--target", default="lib/main.dart", help="Flutter entrypoint file.") parser.add_argument("--flavor", help="Optional Flutter flavor.") parser.add_argument("--build-name", default=build_name, help="Override version name.") parser.add_argument("--build-number", default=build_number, help="Override version code / CFBundleVersion.") parser.add_argument( "--output-dir", type=Path, default=default_output_dir, help="Directory used to store copied release artifacts.", ) parser.add_argument( "--dart-define", action="append", default=[], help="Repeatable dart-define entries passed to flutter build.", ) parser.add_argument( "--dart-define-from-file", action="append", default=[], help="Repeatable dart-define-from-file entries passed to flutter build.", ) parser.add_argument( "--ios-codesign", action="store_true", help="Enable codesign for flutter build ipa. Default keeps the iOS build unsigned.", ) parser.add_argument( "--ios-export-method", choices=["app-store", "ad-hoc", "development", "enterprise"], default="app-store", help="Export method used when --ios-codesign is enabled.", ) parser.add_argument( "--ios-export-options-plist", help="Optional ExportOptions.plist used when --ios-codesign is enabled.", ) parser.set_defaults(package_name=package_name) return parser def main() -> int: parser = create_argument_parser() args = parser.parse_args() args.output_dir = args.output_dir.resolve() args.output_dir.mkdir(parents=True, exist_ok=True) manifest: dict[str, object] = { "generatedAt": dt.datetime.now().isoformat(timespec="seconds"), "packageName": args.package_name, "buildName": args.build_name, "buildNumber": args.build_number, "target": args.target, "flavor": args.flavor, "outputDir": str(args.output_dir.relative_to(ROOT)), } if args.platform in {"all", "android"}: build_android(args, manifest) if args.platform in {"all", "ios"}: build_ios(args, manifest) manifest_path = args.output_dir / "build_manifest.json" manifest_path.write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") print(f"Artifacts copied to: {args.output_dir}") print(f"Manifest written to: {manifest_path}") return 0 if __name__ == "__main__": sys.exit(main())