runIosSign function

Future<void> runIosSign(
  1. ArgResults args
)

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');
}