Implementation
Future<void> runIosSign(ArgResults args) async {
if (args['help'] as bool) {
print('''
Usage: openci ios-sign [options]
Required environment variables:
ASC_KEY_ID App Store Connect API Key ID
ASC_ISSUER_ID App Store Connect Issuer ID
ASC_PRIVATE_KEY App Store Connect API Private Key (PEM)
Optional environment variables (certificate caching):
OPENCI_DISTRIBUTION_CERTIFICATE_P12 Base64-encoded .p12 certificate
OPENCI_DISTRIBUTION_CERTIFICATE_ID ASC certificate ID
OPENCI_DISTRIBUTION_CERTIFICATE_PASSWORD .p12 password (default: openci)
Options:
${iosSignParser().usage}
''');
return;
}
final bundleId = args['bundle-id'] as String;
final appleTeamId = args['apple-team-id'] as String;
final scheme = args['scheme'] as String;
final workspacePath = args['workspace'] as String;
final xcodeProjectPath = args['xcodeproj'] as String;
final workingDirectory = args['working-directory'] as String;
final uploadToTestflight = args['upload-to-testflight'] as bool;
// Create build log file
final resolvedDir = workingDirectory == '.'
? Directory.current.path
: workingDirectory;
final buildDir = Directory('$resolvedDir/build');
if (!buildDir.existsSync()) {
buildDir.createSync(recursive: true);
}
final logTimestamp = DateTime.now()
.toIso8601String()
.replaceAll(RegExp(r'[:.]'), '-')
.substring(0, 19);
_logFile = File('${buildDir.path}/openci_ios_sign_$logTimestamp.log');
_logFile!.writeAsStringSync('');
_log('๐ OpenCI iOS Sign & Build');
_log(' Bundle ID: $bundleId');
_log(' Apple Team ID: $appleTeamId');
_log(' Scheme: $scheme');
_log(' Workspace: $workspacePath');
_log(' Xcode Project: $xcodeProjectPath');
_log(' Log file: ${_logFile!.path}');
_log('');
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Read ASC credentials from environment
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
final ascKeyId = _requireEnv('ASC_KEY_ID');
final ascIssuerId = _requireEnv('ASC_ISSUER_ID');
final ascPrivateKey = _requireEnv('ASC_PRIVATE_KEY');
// Optional: cached certificate
final existingP12Base64 =
Platform.environment['OPENCI_DISTRIBUTION_CERTIFICATE_P12'];
final existingCertId =
Platform.environment['OPENCI_DISTRIBUTION_CERTIFICATE_ID'];
final existingCertPassword =
Platform.environment['OPENCI_DISTRIBUTION_CERTIFICATE_PASSWORD'] ??
'openci';
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Step 1: Generate ASC JWT
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
_log('๐ Step 1: Generating App Store Connect JWT...');
final jwt = await _generateAscJwt(ascKeyId, ascIssuerId, ascPrivateKey);
_log(' โ
JWT generated');
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Step 2: Handle distribution certificate
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
_log('');
_log('๐ Step 2: Setting up distribution certificate...');
String certP12Base64;
String certPassword;
String certificateId;
bool isNewCertificate = false;
final hasExistingCert =
existingP12Base64 != null &&
existingP12Base64.isNotEmpty &&
existingCertId != null &&
existingCertId.isNotEmpty;
if (hasExistingCert) {
// Validate existing certificate
final validation = await _validateCertificate(jwt, existingCertId);
if (validation.valid) {
_log(' โ
Using existing valid certificate (ID: $existingCertId)');
certP12Base64 = existingP12Base64;
certPassword = existingCertPassword;
certificateId = existingCertId;
} else {
_log(' โ ๏ธ Certificate expired/revoked, creating new...');
// Try to delete old certificate
try {
await _ascApiRequest(
jwt,
'/certificates/$existingCertId',
method: 'DELETE',
);
_log(' ๐๏ธ Deleted old certificate');
} catch (_) {
_log(' โน๏ธ Old certificate already removed or inaccessible');
}
final result = await _createCertificateWithP12(jwt);
certP12Base64 = result.p12Base64;
certPassword = result.password;
certificateId = result.certificateId;
isNewCertificate = true;
}
} else {
_log(' No existing certificate found, creating new...');
if (existingP12Base64 == null || existingP12Base64.isEmpty) {
_log(' โน๏ธ OPENCI_DISTRIBUTION_CERTIFICATE_P12 is not set');
}
if (existingCertId == null || existingCertId.isEmpty) {
_log(' โน๏ธ OPENCI_DISTRIBUTION_CERTIFICATE_ID is not set');
}
final result = await _createCertificateWithP12(jwt);
certP12Base64 = result.p12Base64;
certPassword = result.password;
certificateId = result.certificateId;
isNewCertificate = true;
}
_log(' โ
Certificate ready (ID: $certificateId)');
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Step 3: Create provisioning profile
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
_log('');
_log('๐ฑ Step 3: Creating provisioning profile...');
final profile = await _createProvisioningProfile(
jwt,
certificateId,
bundleId,
);
final profileBase64 = profile.profileContent;
final profileUuid = profile.uuid;
final profileName = profile.name;
_log(' โ
Profile created');
_log(' Name: $profileName');
_log(' UUID: $profileUuid');
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Step 4: Setup temporary keychain
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
_log('');
_log('๐ Step 4: Setting up temporary keychain...');
const keychainName = 'openci-build.keychain';
const keychainPassword = 'openci_temp_password';
await _run('security', ['delete-keychain', keychainName], ignoreError: true);
await _run('security', [
'create-keychain',
'-p',
keychainPassword,
keychainName,
]);
await _run('security', [
'unlock-keychain',
'-p',
keychainPassword,
keychainName,
]);
await _run('security', [
'set-keychain-settings',
'-t',
'3600',
'-u',
keychainName,
]);
await _run('security', [
'list-keychains',
'-d',
'user',
'-s',
keychainName,
'login.keychain-db',
]);
_log(' โ
Keychain created');
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Step 5: Import .p12 certificate
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
_log('');
_log('๐ฆ Step 5: Importing certificate...');
final certFile = File('/tmp/openci_distribution.p12');
certFile.writeAsBytesSync(base64Decode(certP12Base64));
await _run('security', [
'import',
certFile.path,
'-k',
keychainName,
'-P',
certPassword,
'-T',
'/usr/bin/codesign',
'-T',
'/usr/bin/security',
]);
await _run('security', [
'set-key-partition-list',
'-S',
'apple-tool:,apple:,codesign:',
'-k',
keychainPassword,
keychainName,
]);
certFile.deleteSync();
_log(' โ
Certificate imported');
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Step 6: Install provisioning profile
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
_log('');
_log('๐ฑ Step 6: Installing provisioning profile...');
final profileFile = File('/tmp/openci_profile.mobileprovision');
profileFile.writeAsBytesSync(base64Decode(profileBase64));
final profileDir = Directory(
'${Platform.environment['HOME']}/Library/MobileDevice/Provisioning Profiles',
);
if (!profileDir.existsSync()) {
profileDir.createSync(recursive: true);
}
// Remove old profiles to prevent Xcode from using stale ones
for (final file in profileDir.listSync()) {
if (file is File && file.path.endsWith('.mobileprovision')) {
try {
// Read profile and check if it's an OpenCI profile for this bundle ID
final content = file.readAsStringSync();
if (content.contains('OpenCI') && content.contains(bundleId)) {
_log(
' ๐๏ธ Removing old local profile: ${file.path.split('/').last}',
);
file.deleteSync();
}
} catch (_) {}
}
}
final destProfile = File('${profileDir.path}/$profileUuid.mobileprovision');
profileFile.copySync(destProfile.path);
profileFile.deleteSync();
_log(' โ
Profile installed (UUID: $profileUuid)');
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Step 7: Edit xcodeproj for manual signing
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
_log('');
_log('โ๏ธ Step 7: Configuring Xcode project for manual signing...');
final resolvedWorkingDir = workingDirectory == '.'
? Directory.current.path
: workingDirectory;
final pbxprojPath = '$resolvedWorkingDir/$xcodeProjectPath/project.pbxproj';
final pbxprojFile = File(pbxprojPath);
if (!pbxprojFile.existsSync()) {
_error('project.pbxproj not found at $pbxprojPath');
exit(1);
}
var pbxprojContent = pbxprojFile.readAsStringSync();
// CODE_SIGN_STYLE = Automatic โ Manual
pbxprojContent = pbxprojContent.replaceAll(
'CODE_SIGN_STYLE = Automatic;',
'CODE_SIGN_STYLE = Manual;',
);
// Set DEVELOPMENT_TEAM
pbxprojContent = pbxprojContent.replaceAll(
RegExp(r'DEVELOPMENT_TEAM = [^;]*;'),
'DEVELOPMENT_TEAM = $appleTeamId;',
);
// Set CODE_SIGN_IDENTITY to Apple Distribution
pbxprojContent = pbxprojContent
.replaceAll(
'CODE_SIGN_IDENTITY = "Apple Development";',
'CODE_SIGN_IDENTITY = "Apple Distribution";',
)
.replaceAll(
'CODE_SIGN_IDENTITY = "iPhone Developer";',
'CODE_SIGN_IDENTITY = "Apple Distribution";',
)
.replaceAll(
'"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";',
'"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Distribution";',
);
// Add PROVISIONING_PROFILE_SPECIFIER (name) and PROVISIONING_PROFILE (UUID)
pbxprojContent = pbxprojContent.replaceAll(
'PRODUCT_BUNDLE_IDENTIFIER = $bundleId;',
'PRODUCT_BUNDLE_IDENTIFIER = $bundleId;\n'
'\t\t\t\tPROVISIONING_PROFILE_SPECIFIER = "$profileName";\n'
'\t\t\t\tPROVISIONING_PROFILE = "$profileUuid";',
);
pbxprojFile.writeAsStringSync(pbxprojContent);
_log(' โ
Xcode project updated for manual signing');
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Step 8: Generate ExportOptions.plist
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
_log('');
_log('๐ Step 8: Generating ExportOptions.plist...');
final exportOptionsPath = '$resolvedWorkingDir/ExportOptions.plist';
final destination = uploadToTestflight ? 'upload' : 'export';
final exportOptionsContent =
'''<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store-connect</string>
<key>teamID</key>
<string>$appleTeamId</string>
<key>signingStyle</key>
<string>manual</string>
<key>provisioningProfiles</key>
<dict>
<key>$bundleId</key>
<string>$profileUuid</string>
</dict>
<key>signingCertificate</key>
<string>Apple Distribution</string>
<key>destination</key>
<string>$destination</string>
<key>stripSwiftSymbols</key>
<true/>
<key>uploadSymbols</key>
<true/>
</dict>
</plist>
''';
File(exportOptionsPath).writeAsStringSync(exportOptionsContent);
_log(' โ
ExportOptions.plist generated');
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Step 9: Build Archive
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
_log('');
_log('๐จ Step 9: Building archive...');
_log(' โณ This may take several minutes...');
final archivePath = '$resolvedWorkingDir/build/$scheme.xcarchive';
await _run(
'xcodebuild',
[
'archive',
'-quiet',
'-workspace',
'$resolvedWorkingDir/$workspacePath',
'-scheme',
scheme,
'-archivePath',
archivePath,
'-destination',
'generic/platform=iOS',
'DEVELOPMENT_TEAM=$appleTeamId',
'CODE_SIGN_STYLE=Manual',
'CODE_SIGN_IDENTITY=Apple Distribution',
'PROVISIONING_PROFILE_SPECIFIER=$profileName',
'PROVISIONING_PROFILE=$profileUuid',
'SENTRY_DISABLE_AUTO_UPLOAD=true',
],
workingDirectory: resolvedWorkingDir,
quiet: true,
);
_log(' โ
Archive created: $archivePath');
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Step 10: Export IPA (with ASC API key authentication)
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
_log('');
_log('๐ค Step 10: Exporting IPA...');
// Locate or create ASC API key file for xcodebuild authentication
// Check working directory first (user may have decoded it in a prior step)
final existingKeyFile = File('$resolvedWorkingDir/AuthKey_$ascKeyId.p8');
final File apiKeyFile;
bool createdApiKeyFile = false;
if (existingKeyFile.existsSync()) {
apiKeyFile = existingKeyFile;
_log(' ๐ Using existing AuthKey_$ascKeyId.p8 from working directory');
} else {
// Write API key to ~/private_keys/ for xcodebuild
final apiKeyDir = Directory('${Platform.environment['HOME']}/private_keys');
if (!apiKeyDir.existsSync()) {
apiKeyDir.createSync(recursive: true);
}
apiKeyFile = File('${apiKeyDir.path}/AuthKey_$ascKeyId.p8');
// Detect if the key is base64-encoded or raw PEM
if (ascPrivateKey.contains('-----BEGIN')) {
apiKeyFile.writeAsStringSync(ascPrivateKey);
} else {
// Assume base64-encoded
apiKeyFile.writeAsBytesSync(base64Decode(ascPrivateKey.trim()));
}
createdApiKeyFile = true;
_log(' ๐ ASC API key written for xcodebuild authentication');
}
final exportPath = '$resolvedWorkingDir/build';
await _run(
'xcodebuild',
[
'-exportArchive',
'-quiet',
'-archivePath',
archivePath,
'-exportPath',
exportPath,
'-exportOptionsPlist',
exportOptionsPath,
'-allowProvisioningUpdates',
'-authenticationKeyPath',
apiKeyFile.path,
'-authenticationKeyID',
ascKeyId,
'-authenticationKeyIssuerID',
ascIssuerId,
],
workingDirectory: resolvedWorkingDir,
quiet: true,
);
_log(' โ
IPA exported to: $exportPath');
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Cleanup
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
_log('');
_log('๐งน Cleaning up...');
await _run('security', [
'default-keychain',
'-s',
'login.keychain-db',
], ignoreError: true);
await _run('security', [
'list-keychains',
'-d',
'user',
'-s',
'login.keychain-db',
], ignoreError: true);
await _run('security', ['delete-keychain', keychainName], ignoreError: true);
// Clean up ASC API key file (only if we created it)
if (createdApiKeyFile && apiKeyFile.existsSync()) {
apiKeyFile.deleteSync();
}
_log(' โ
Temporary keychain and API key removed');
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Output new certificate info for caching
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
if (isNewCertificate) {
_log('');
_log('๐พ New certificate created. Saving to Secret Manager...');
final gcpSaJson = Platform.environment['OPENCI_GCP_SA_JSON'];
final projectId = Platform.environment['OPENCI_PROJECT_ID'];
if (gcpSaJson != null &&
gcpSaJson.isNotEmpty &&
projectId != null &&
projectId.isNotEmpty) {
try {
await _saveDistributionCertToSecretManager(
gcpSaJson: gcpSaJson,
projectId: projectId,
bundleId: bundleId,
certP12Base64: certP12Base64,
certPassword: certPassword,
certificateId: certificateId,
);
_log(' โ
Certificate saved to Secret Manager automatically');
_log(
' Next run will reuse this certificate via OPENCI_DISTRIBUTION_CERTIFICATE_P12',
);
} catch (e) {
_log(' โ ๏ธ Failed to save to Secret Manager: $e');
_log(' ๐ Manual fallback - save these as secrets:');
_log(' OPENCI_DISTRIBUTION_CERTIFICATE_ID=$certificateId');
_log(
' OPENCI_DISTRIBUTION_CERTIFICATE_P12=<base64, ${certP12Base64.length} chars>',
);
_log(' OPENCI_DISTRIBUTION_CERTIFICATE_PASSWORD=$certPassword');
}
} else {
_log(' ๐ OPENCI_GCP_SA_JSON / OPENCI_PROJECT_ID not set.');
_log(' Save these manually as secrets:');
_log(' OPENCI_DISTRIBUTION_CERTIFICATE_ID=$certificateId');
_log(
' OPENCI_DISTRIBUTION_CERTIFICATE_P12=<base64, ${certP12Base64.length} chars>',
);
_log(' OPENCI_DISTRIBUTION_CERTIFICATE_PASSWORD=$certPassword');
}
}
_log('');
_log('๐ iOS Code Signing & Build complete!');
_log(' IPA: $exportPath/$scheme.ipa');
}