389 lines
14 KiB
Python
389 lines
14 KiB
Python
#!/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",
|
||
"--split-per-abi",
|
||
"--target-platform",
|
||
"android-arm64",
|
||
]
|
||
append_common_flutter_args(apk_cmd, args)
|
||
with timed_stage(manifest, "android.localReleaseApk", "Android 测试包(Release / 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-release.apk",
|
||
ROOT / "build" / "app" / "outputs" / "flutter-apk" / "app-release.apk",
|
||
)
|
||
artifacts["localArm64Apk"] = copy_file(
|
||
arm64_src,
|
||
local_dir / f"{artifact_prefix}-arm64-v8a.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())
|