chatapp3-flutter/scripts/build_release.py
2026-04-13 15:25:55 +08:00

271 lines
9.6 KiB
Python

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