chatapp3-flutter/scripts/build_release.py
2026-04-17 15:21:23 +08:00

388 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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())