#!/usr/bin/env python3 from __future__ import annotations import argparse from contextlib import contextmanager import datetime as dt import hashlib import json import re import shutil import subprocess import sys import time 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, *, include_sha256: bool = True) -> dict[str, object]: dest.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(src, dest) result: dict[str, object] = { "path": str(dest.relative_to(ROOT)), "sizeBytes": dest.stat().st_size, } if include_sha256: result["sha256"] = sha256_of(dest) return result def copy_tree(src: Path, dest: Path) -> None: ensure_clean_dir(dest) shutil.copytree(src, dest, dirs_exist_ok=True) def first_existing_path(*candidates: Path) -> Path: for candidate in candidates: if candidate.exists(): return candidate raise FileNotFoundError("No build output found in: " + ", ".join(str(candidate) for candidate in candidates)) def timings_bucket(manifest: dict[str, object]) -> dict[str, object]: timings = manifest.get("timings") if not isinstance(timings, dict): timings = {"stages": []} manifest["timings"] = timings stages = timings.get("stages") if not isinstance(stages, list): timings["stages"] = [] return timings @contextmanager def timed_stage(manifest: dict[str, object], key: str, label: str) -> dict[str, object]: stage_started_at = dt.datetime.now() stage_started_perf = time.perf_counter() stage: dict[str, object] = { "key": key, "label": label, "startedAt": stage_started_at.isoformat(timespec="seconds"), } timings_bucket(manifest)["stages"].append(stage) print(f"[stage:start] {label}", flush=True) try: yield stage except Exception as exc: stage["status"] = "failed" stage["error"] = str(exc) raise else: stage["status"] = "succeeded" finally: duration_seconds = round(time.perf_counter() - stage_started_perf, 3) stage["endedAt"] = dt.datetime.now().isoformat(timespec="seconds") stage["durationSeconds"] = duration_seconds print(f"[stage:end] {label} ({duration_seconds:.1f}s)", flush=True) def append_common_flutter_args(command: list[str], args: argparse.Namespace, *, build_mode: str = "release") -> None: command.extend([f"--{build_mode}", 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 should_build_ios(platform: str) -> bool: return platform in {"all", "ios"} def should_build_android(platform: str) -> bool: return platform in {"all", "android", "android-google-play", "android-local-arm64"} def android_build_profile(platform: str) -> str: if platform == "android-google-play": return "google-play" if platform == "android-local-arm64": return "local-arm64" return "full" 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" profile = android_build_profile(args.platform) if profile in {"full", "google-play"}: 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)}") with timed_stage(manifest, "android.googlePlayAab", "Android 谷歌发布包(AAB)"): run_command(appbundle_cmd) if profile == "full": 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)}") with timed_stage(manifest, "android.releaseApks", "Android 多 ABI 正式 APK"): run_command(apk_cmd) elif profile == "local-arm64": apk_cmd = [ "flutter", "build", "apk", "--target-platform", "android-arm64", ] append_common_flutter_args(apk_cmd, args, build_mode="debug") with timed_stage(manifest, "android.localDebugApk", "Android 极速测试包(Debug / ARM64)"): 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}" artifacts: dict[str, object] = {} with timed_stage(manifest, "android.collectArtifacts", "整理 Android 产物"): if profile in {"full", "google-play"}: aab_src = ROOT / "build" / "app" / "outputs" / "bundle" / "release" / "app-release.aab" artifacts["googlePlayAab"] = copy_file(aab_src, google_play_dir / f"{artifact_prefix}-google-play.aab") if profile == "full": arm64_src = ROOT / "build" / "app" / "outputs" / "flutter-apk" / "app-arm64-v8a-release.apk" artifacts["localArm64Apk"] = copy_file(arm64_src, local_dir / f"{artifact_prefix}-arm64-v8a.apk") elif profile == "local-arm64": arm64_src = first_existing_path( ROOT / "build" / "app" / "outputs" / "flutter-apk" / "app-arm64-v8a-debug.apk", ROOT / "build" / "app" / "outputs" / "flutter-apk" / "app-debug.apk", ) artifacts["localArm64Apk"] = copy_file( arm64_src, local_dir / f"{artifact_prefix}-arm64-v8a-debug.apk", include_sha256=False, ) if profile == "full": 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["localArmeabiV7aApk"] = copy_file(armv7_src, local_dir / f"{artifact_prefix}-armeabi-v7a.apk") artifacts["testingX64Apk"] = copy_file(x64_src, testing_dir / f"{artifact_prefix}-x86_64-test.apk") if android_symbols_dir.exists() and profile in {"full", "google-play"}: symbols_parent_dir = google_play_dir if profile in {"full", "google-play"} else local_dir copy_tree(android_symbols_dir, symbols_parent_dir / "symbols") artifacts["dartSymbolsDir"] = { "path": str((symbols_parent_dir / "symbols").relative_to(ROOT)), "sizeBytes": sum(path.stat().st_size for path in (symbols_parent_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") with timed_stage(manifest, "ios.buildIpa", "iOS 构建与导出"): 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")) with timed_stage(manifest, "ios.collectArtifacts", "整理 iOS 产物"): 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", "ios", "android", "android-google-play", "android-local-arm64"], 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() build_started_at = dt.datetime.now() build_started_perf = time.perf_counter() manifest_path = args.output_dir / "build_manifest.json" args.output_dir.mkdir(parents=True, exist_ok=True) manifest: dict[str, object] = { "generatedAt": dt.datetime.now().isoformat(timespec="seconds"), "packageName": args.package_name, "platform": args.platform, "buildName": args.build_name, "buildNumber": args.build_number, "target": args.target, "flavor": args.flavor, "outputDir": str(args.output_dir.relative_to(ROOT)), "timings": { "startedAt": build_started_at.isoformat(timespec="seconds"), "stages": [], }, } exit_code = 0 try: if should_build_android(args.platform): build_android(args, manifest) if should_build_ios(args.platform): build_ios(args, manifest) manifest["status"] = "succeeded" print(f"Artifacts copied to: {args.output_dir}") except Exception as exc: manifest["status"] = "failed" manifest["error"] = str(exc) exit_code = 1 raise finally: timings = timings_bucket(manifest) timings["endedAt"] = dt.datetime.now().isoformat(timespec="seconds") timings["totalSeconds"] = round(time.perf_counter() - build_started_perf, 3) manifest_path.write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") print(f"Total build time: {timings['totalSeconds']:.1f}s", flush=True) print(f"Manifest written to: {manifest_path}", flush=True) return exit_code if __name__ == "__main__": sys.exit(main())