fastlane을 이용한 버전 컨트롤 & 업로드 작업

This commit is contained in:
leedongmyung 2025-05-22 21:15:09 +09:00
parent 3135b4b4ed
commit 2c203c4719
7 changed files with 477 additions and 3 deletions

View file

@ -1,6 +1,12 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:dart_framework/utils/string_util.dart';
import 'package:dart_framework/utils/system_util.dart';
import 'package:oto_cli/oto/commands/build/fastlane_template.dart';
import 'package:oto_cli/oto/commands/file/file.dart';
import 'package:xml/xml.dart';
import 'package:oto_cli/oto/application.dart';
import 'package:oto_cli/oto/commands/build/build_flutter.dart';
import 'package:oto_cli/oto/commands/command.dart';
@ -22,6 +28,100 @@ class BuildiOS extends Command {
}
var xcworkspaceFilePath = Command.getPathNormal(xcodeProjectPath);
String? version = data.version;
String? buildNumber = data.buildNumber;
var xcodeproj = data.xcodeProjectFilePath ??
await getMainXcodeprojPath(data.xcworkspaceFilePath!);
if (data.autoBuildNumber ?? false) {
var appID = await getAppIdentifier(
data.workspace, xcodeproj, data.scheme, data.setAppID);
if (appID == null) {
throw Exception('Can not find App Identifier.');
}
if (data.apiKeyPath == null) {
throw Exception(
'If autoVersionNumber = true then must set apiKeyPath.');
}
var json = jsonDecode(File(data.apiKeyPath!).readAsStringSync());
var fastlaneContent = fastlaneTemplete;
fastlaneContent = fastlaneContent
.replaceFirst('[APP_IDENTIFIER]', appID)
.replaceFirst('[API_KEY_ID]', json['key_id'])
.replaceFirst('[API_ISSUER_ID]', json['issuer_id'])
.replaceFirst('[AUTH_KEY_CONTENT]',
json['key'].toString().replaceAll('\n', '\\n'));
var shell = StringBuffer();
shell.write('cd ${data.workspace}');
shell.write(' && rm -rf "${data.workspace}/fastlane"');
shell.write(' && yes | fastlane init');
await ProcessExecutor.run(shell);
var fastFile = File('${data.workspace}/fastlane/Fastfile');
fastFile.writeAsStringSync(fastlaneContent, flush: true);
//Find PREPARE_FOR_SUBMISSION state Version
String? targetVersion;
shell = StringBuffer();
shell.write('cd ${data.workspace}/fastlane');
shell.write(' && fastlane appstore_versions');
var process = await ProcessExecutor.run(shell);
var arr = process.stdout.toString().split('\n');
const result = '[RESULT]: ';
for (var item in arr) {
if (item.contains(result)) {
var jsonArr =
jsonDecode(item.substring(item.indexOf(result) + result.length))
as List<dynamic>;
for (var item in jsonArr) {
if (item['state'] == 'PREPARE_FOR_SUBMISSION') {
targetVersion = item['version'];
break;
}
}
break;
}
}
if (targetVersion == null) {
throw Exception(
'Can not find target version when state = PREPARE_FOR_SUBMISSION.');
}
version = targetVersion;
//Find Last Build Number
String? targetBuildNumber;
fastlaneContent =
fastlaneContent.replaceFirst('[LATEST_VERSION]', version);
fastFile.writeAsStringSync(fastlaneContent, flush: true);
shell = StringBuffer();
shell.write('cd ${data.workspace}/fastlane');
shell.write(' && fastlane latest_build_number');
process = await ProcessExecutor.run(shell);
arr = process.stdout.toString().split('\n');
for (var item in arr) {
if (item.contains(result)) {
var json =
jsonDecode(item.substring(item.indexOf(result) + result.length));
targetBuildNumber = json['latest_build_number'];
break;
}
}
if (targetBuildNumber == null) {
throw Exception('Can not find target build number.');
}
buildNumber = incrementVersion(targetBuildNumber);
}
if (version != null) {
await updateMarketingVersion(
'${data.workspace}/$xcodeproj/project.pbxproj', version);
}
if (buildNumber != null) {
await updateBuildNumber(
'${data.workspace}/$xcodeproj/project.pbxproj', buildNumber);
}
var os = 'iOS';
if (data.os != null) {
if (data.os == BuildPlatform.Macos) {
@ -92,6 +192,116 @@ class BuildiOS extends Command {
}
}
}
Future<String?> getAppIdentifier(String? workspace, String? xcodeproj,
String scheme, String? setAppID) async {
String? identifier;
var shell = StringBuffer();
shell.write(
"xcodebuild -project '$workspace/$xcodeproj' -scheme $scheme -destination 'generic/platform=iOS Simulator' -showBuildSettings | grep 'PRODUCT_BUNDLE_IDENTIFIER' | tail -n1 | awk -F ' = ' '{print \$2}'");
var process = await ProcessExecutor.start(shell,
workspace: workspace,
printStderr: false,
logHandler: Application.logWithType);
for (var item in process.stdoutArr) {
var appID = item.trim().replaceAll(' ', '');
if (appID.isNotEmpty) {
setAppID = (setAppID == null) ? '<@property.appIdentifier>' : setAppID;
await setProperty(setAppID, appID);
identifier = appID;
break;
}
}
return dataFutrue(identifier);
}
Future<String?> getMainXcodeprojPath(String workspacePath) async {
final contentsPath = '$workspacePath/contents.xcworkspacedata';
final file = File(contentsPath);
if (!await file.exists()) return null;
final xmlString = await file.readAsString();
final document = XmlDocument.parse(xmlString);
final fileRef = document.findAllElements('FileRef').firstWhere(
(element) =>
element.getAttribute('location')?.endsWith('.xcodeproj') ?? false,
orElse: () => XmlElement(XmlName('Dummy')), // null
);
final location =
fileRef.name.local == 'Dummy' ? null : fileRef.getAttribute('location');
if (location != null && location.startsWith('group:')) {
return location.replaceFirst('group:', '');
}
return null;
}
Future<void> updateMarketingVersion(
String pbxprojPath, String version) async {
await _updatePbxprojSetting(
pbxprojPath: pbxprojPath,
settingKey: 'MARKETING_VERSION',
settingValue: version,
);
print('✅ Updated MARKETING_VERSION = $version');
}
Future<void> updateBuildNumber(String pbxprojPath, String buildNumber) async {
await _updatePbxprojSetting(
pbxprojPath: pbxprojPath,
settingKey: 'CURRENT_PROJECT_VERSION',
settingValue: buildNumber,
);
print('✅ Updated CURRENT_PROJECT_VERSION = $buildNumber');
}
Future<void> _updatePbxprojSetting({
required String pbxprojPath,
required String settingKey,
required String settingValue,
}) async {
final file = File(pbxprojPath);
if (!await file.exists()) throw Exception('File not found: $pbxprojPath');
final lines = await file.readAsLines();
final updatedLines = <String>[];
bool insideBuildSettings = false;
bool hasTargetSetting = false;
for (var line in lines) {
final trimmed = line.trim();
if (trimmed == 'buildSettings = {') {
insideBuildSettings = true;
hasTargetSetting = false;
updatedLines.add(line);
continue;
}
if (insideBuildSettings && trimmed == '};') {
//
if (!hasTargetSetting) {
updatedLines.add(' $settingKey = $settingValue;');
}
insideBuildSettings = false;
updatedLines.add(line);
continue;
}
if (insideBuildSettings && trimmed.startsWith('$settingKey =')) {
line = line.replaceFirst(RegExp('$settingKey = .*?;'),
' $settingKey = $settingValue;');
hasTargetSetting = true;
}
updatedLines.add(line);
}
await file.writeAsString(updatedLines.join('\n'));
}
}
class CodeSign extends Command {

View file

@ -0,0 +1,116 @@
var fastlaneTemplete = '''
default_platform(:ios)
APP_IDENTIFIER = "[APP_IDENTIFIER]"
platform :ios do
private_lane :base_api_key do
app_store_connect_api_key(
key_id: "[API_KEY_ID]",
issuer_id: "[API_ISSUER_ID]",
key_content: "[AUTH_KEY_CONTENT]"
)
end
desc "지정한 앱 버전 + 빌드 넘버에 대한 TestFlight 상태 추적 (JSON 출력)"
lane :testflight_status do
require 'spaceship'
require 'json'
api_key = base_api_key
target_app_version = "[TARGET_VERSION]" # CFBundleShortVersionString
target_build_number = "[TARGET_BUILD_NUMBER]" # CFBundleVersion
app = Spaceship::ConnectAPI::App.find(APP_IDENTIFIER)
builds = app.get_builds(includes: "preReleaseVersion")
matched = builds.find do |b|
b.version.to_s == target_build_number &&
b.respond_to?(:pre_release_version) &&
b.pre_release_version&.version == target_app_version
end
result = {
version: target_app_version,
build_number: target_build_number,
state: matched ? matched.processing_state : "NONE",
uploaded: matched ? matched.uploaded_date.to_s : nil
}
puts "[RESULT]: #{JSON.generate(result)}"
end
desc "TestFlight에 등록된 특정 버전의 최신 빌드 넘버 가져오기 (JSON 출력)"
lane :latest_build_number do
require 'json'
api_key = base_api_key
target_version = "[LATEST_VERSION]"
latest_build = app_store_build_number(
api_key: api_key,
app_identifier: APP_IDENTIFIER,
version: target_version,
live: false
)
result = {
version: target_version,
latest_build_number: latest_build
}
puts "[RESULT]: #{JSON.generate(result)}"
end
desc "필터 조건에 맞춰 App Store Connect 버전 상태 JSON 출력"
lane :appstore_versions do
require 'spaceship'
require 'json'
require 'rubygems' # for Gem::Version
api_key = base_api_key
app = Spaceship::ConnectAPI::App.find(APP_IDENTIFIER)
versions = app.get_app_store_versions
.select { |v| v.platform == "IOS" }
.reject { |v| v.app_store_state == "REPLACED_WITH_NEW_VERSION" }
# 1. READY_FOR_SALE
latest_ready = versions
.select { |v| v.app_store_state == "READY_FOR_SALE" }
.max_by { |v| v.respond_to?(:created_date) ? v.created_date.to_s : "" }
# 2. PREPARE_FOR_SUBMISSION
prepare_versions = versions
.select { |v| v.app_store_state == "PREPARE_FOR_SUBMISSION" }
.sort_by { |v| Gem::Version.new(v.version_string) }
lowest_prepare = prepare_versions.first
# 3. (READY_FOR_SALE, PREPARE_FOR_SUBMISSION, REPLACED )
included_states = ["READY_FOR_SALE", "PREPARE_FOR_SUBMISSION", "REPLACED_WITH_NEW_VERSION"]
others = versions.reject { |v| included_states.include?(v.app_store_state) }
selected = []
selected << latest_ready if latest_ready
selected << lowest_prepare if lowest_prepare
selected += others
json_output = selected.map do |v|
{
version: v.version_string,
state: v.app_store_state,
created: v.respond_to?(:created_date) ? v.created_date.to_s : nil
}
end
puts "[RESULT]: #{JSON.generate(json_output)}"
end
end
''';

View file

@ -0,0 +1,45 @@
import 'package:dart_framework/platform/process.dart';
import 'package:oto_cli/oto/application.dart';
import 'package:oto_cli/oto/commands/command.dart';
import 'package:oto_cli/oto/data/command_data.dart';
class TestflightUpload extends Command {
Function? get error => null;
@override
Future execute(DataCommand command) async {
var data = DataTestflightUpload.fromJson(getParam(command));
var apiKeyPath = Command.getPathNormal(data.apiKeyPath);
var ipaPath = Command.getPathNormal(data.ipaPath);
var shell = StringBuffer();
shell.write(
'FASTLANE_DISABLE_PRECHECK=1 fastlane deliver upload_binary --ipa "$ipaPath" --api_key_path "$apiKeyPath" --force');
var process = await ProcessExecutor.start(shell,
workspace: workspace,
printStderr: false,
logHandler: Application.logWithType);
return await completeProcess(process, command, passExitCodes: [1]);
}
}
class IOSVersion extends Command {
Function? get error => null;
@override
Future execute(DataCommand command) async {
var data = DataiOSVersion.fromJson(getParam(command));
var shell = StringBuffer();
shell.write("echo 'test'");
var process = await ProcessExecutor.start(shell,
workspace: workspace,
printStderr: false,
logHandler: Application.logWithType);
return await completeProcess(process, command, passExitCodes: [1]);
}
}

View file

@ -13,6 +13,7 @@ import 'package:oto_cli/oto/commands/build/build_dot_net.dart';
import 'package:oto_cli/oto/commands/build/build_flutter.dart';
import 'package:oto_cli/oto/commands/build/build_ios.dart';
import 'package:oto_cli/oto/commands/build/build_msbuild.dart';
import 'package:oto_cli/oto/commands/build/testflight_ios.dart';
import 'package:oto_cli/oto/commands/file/directory.dart';
import 'package:oto_cli/oto/commands/file/file.dart';
import 'package:oto_cli/oto/commands/file/file_diff_check.dart';
@ -53,6 +54,8 @@ enum CommandType {
BuildDartCompile,
BuildDotNet,
BuildiOS,
TestflightUpload,
IOSVersion,
BuildMSBuild,
CodeSign,
CodeSignVerify,
@ -122,6 +125,8 @@ abstract class Command {
CommandType.BuildDartCompile: () => BuildDartCompile(),
CommandType.BuildDotNet: () => BuildDotNet(),
CommandType.BuildiOS: () => BuildiOS(),
CommandType.TestflightUpload: () => TestflightUpload(),
CommandType.IOSVersion: () => IOSVersion(),
CommandType.BuildMSBuild: () => BuildMSBuild(),
CommandType.CodeSign: () => CodeSign(),
CommandType.CodeSignVerify: () => CodeSignVerify(),
@ -147,7 +152,7 @@ abstract class Command {
CommandType.StringReplace: () => StringReplace(),
CommandType.StringReplacePattern: () => StringReplacePattern(),
CommandType.StringIndex: () => StringIndex(),
CommandType.URLInfo: ()=> URLInfo(),
CommandType.URLInfo: () => URLInfo(),
CommandType.Jira: () => Jira(),
CommandType.Zip: () => Zip(),
CommandType.Shell: () => Shell(),

View file

@ -221,6 +221,13 @@ class DataBuildiOS extends DataParam {
late String? configuration;
late Map<String, String>? settings;
//Version Setting
late String? version;
late String? buildNumber;
late bool? autoBuildNumber = false;
late String? apiKeyPath; //autoVersionNumber
late String? setAppID;
factory DataBuildiOS.fromJson(Map<String, dynamic> json) =>
_$DataBuildiOSFromJson(json);
@override
@ -322,6 +329,35 @@ class DataExportiOS extends DataParam {
Map<String, dynamic> toJson() => _$DataExportiOSToJson(this);
}
//TestflightUpload
@JsonSerializable()
class DataTestflightUpload extends DataParam {
DataTestflightUpload();
late String apiKeyPath;
late String ipaPath;
factory DataTestflightUpload.fromJson(Map<String, dynamic> json) =>
_$DataTestflightUploadFromJson(json);
@override
Map<String, dynamic> toJson() => _$DataTestflightUploadToJson(this);
}
//iOSVersion
@JsonSerializable()
class DataiOSVersion extends DataParam {
DataiOSVersion();
late String scheme;
late String xcworkspacePath;
late String? setAppID;
factory DataiOSVersion.fromJson(Map<String, dynamic> json) =>
_$DataiOSVersionFromJson(json);
@override
Map<String, dynamic> toJson() => _$DataiOSVersionToJson(this);
}
//PublishiOS
@JsonSerializable()
class DataPublishiOS extends DataParam {
@ -1005,7 +1041,6 @@ class DataURLInfo extends DataParam {
Map<String, dynamic> toJson() => _$DataURLInfoToJson(this);
}
//Web String Replace Item
@JsonSerializable()
class DataStringReplacePair extends DataParam {

View file

@ -62,6 +62,8 @@ const _$CommandTypeEnumMap = {
CommandType.BuildDartCompile: 'BuildDartCompile',
CommandType.BuildDotNet: 'BuildDotNet',
CommandType.BuildiOS: 'BuildiOS',
CommandType.TestflightUpload: 'TestflightUpload',
CommandType.IOSVersion: 'IOSVersion',
CommandType.BuildMSBuild: 'BuildMSBuild',
CommandType.CodeSign: 'CodeSign',
CommandType.CodeSignVerify: 'CodeSignVerify',
@ -391,7 +393,12 @@ DataBuildiOS _$DataBuildiOSFromJson(Map<String, dynamic> json) => DataBuildiOS()
..configuration = json['configuration'] as String?
..settings = (json['settings'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
);
)
..version = json['version'] as String?
..buildNumber = json['buildNumber'] as String?
..autoBuildNumber = json['autoBuildNumber'] as bool?
..apiKeyPath = json['apiKeyPath'] as String?
..setAppID = json['setAppID'] as String?;
Map<String, dynamic> _$DataBuildiOSToJson(DataBuildiOS instance) =>
<String, dynamic>{
@ -409,6 +416,11 @@ Map<String, dynamic> _$DataBuildiOSToJson(DataBuildiOS instance) =>
'cleanPod': instance.cleanPod,
'configuration': instance.configuration,
'settings': instance.settings,
'version': instance.version,
'buildNumber': instance.buildNumber,
'autoBuildNumber': instance.autoBuildNumber,
'apiKeyPath': instance.apiKeyPath,
'setAppID': instance.setAppID,
};
const _$BuildPlatformEnumMap = {
@ -579,6 +591,56 @@ Map<String, dynamic> _$DataExportiOSToJson(DataExportiOS instance) =>
'exportPath': instance.exportPath,
};
DataTestflightUpload _$DataTestflightUploadFromJson(
Map<String, dynamic> json) =>
DataTestflightUpload()
..workspace = json['workspace'] as String?
..passExitCodes = (json['passExitCodes'] as List<dynamic>?)
?.map((e) => (e as num).toInt())
.toList()
..setResult = json['setResult'] as String?
..setExitCode = json['setExitCode'] as String?
..showPrint = json['showPrint'] as bool?
..apiKeyPath = json['apiKeyPath'] as String
..ipaPath = json['ipaPath'] as String;
Map<String, dynamic> _$DataTestflightUploadToJson(
DataTestflightUpload instance) =>
<String, dynamic>{
'workspace': instance.workspace,
'passExitCodes': instance.passExitCodes,
'setResult': instance.setResult,
'setExitCode': instance.setExitCode,
'showPrint': instance.showPrint,
'apiKeyPath': instance.apiKeyPath,
'ipaPath': instance.ipaPath,
};
DataiOSVersion _$DataiOSVersionFromJson(Map<String, dynamic> json) =>
DataiOSVersion()
..workspace = json['workspace'] as String?
..passExitCodes = (json['passExitCodes'] as List<dynamic>?)
?.map((e) => (e as num).toInt())
.toList()
..setResult = json['setResult'] as String?
..setExitCode = json['setExitCode'] as String?
..showPrint = json['showPrint'] as bool?
..scheme = json['scheme'] as String
..xcworkspacePath = json['xcworkspacePath'] as String
..setAppID = json['setAppID'] as String?;
Map<String, dynamic> _$DataiOSVersionToJson(DataiOSVersion instance) =>
<String, dynamic>{
'workspace': instance.workspace,
'passExitCodes': instance.passExitCodes,
'setResult': instance.setResult,
'setExitCode': instance.setExitCode,
'showPrint': instance.showPrint,
'scheme': instance.scheme,
'xcworkspacePath': instance.xcworkspacePath,
'setAppID': instance.setAppID,
};
DataPublishiOS _$DataPublishiOSFromJson(Map<String, dynamic> json) =>
DataPublishiOS()
..workspace = json['workspace'] as String?

View file

@ -20,6 +20,7 @@ dependencies:
color: ^3.0.0
file_hasher: ^0.1.0+0
cron: ^0.6.1
xml: ^6.3.0
dev_dependencies:
lints: ^5.0.0