Files
Alex be4b1438b8 feat(mobile): switch iOS code signing to fastlane match
- Replace manual certificate and provisioning profile handling with fastlane match
- Match automatically syncs certificates and profiles from a private git repository
- Simplifies CI/CD workflow by removing 8 secrets (replaced with 2: MATCH_PASSWORD and MATCH_GIT_BASIC_AUTHORIZATION)
- Add new lanes: sync_certificates and regenerate_certificates for easier maintenance
- When certificates expire, just run 'fastlane regenerate_certificates' locally

Benefits:
- Single source of truth for code signing
- Automatic certificate/profile management
- Easier onboarding for new team members
- Simpler secret rotation when certificates expire

Required new GitHub secrets:
- MATCH_PASSWORD: Encryption password for the match repository
- MATCH_GIT_BASIC_AUTHORIZATION: base64(username:token) for repo access

Removed secrets (no longer needed):
- IOS_CERTIFICATE_P12
- IOS_CERTIFICATE_PASSWORD
- IOS_PROVISIONING_PROFILE
- IOS_PROVISIONING_PROFILE_SHARE_EXTENSION
- IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION
- IOS_DEVELOPMENT_PROVISIONING_PROFILE
- IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION
- IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION
2026-01-05 21:56:49 -06:00

291 lines
8.7 KiB
Ruby

# This file contains the fastlane.tools configuration
# You can find the documentation at https://docs.fastlane.tools
#
# For a list of all available actions, check out
#
# https://docs.fastlane.tools/actions
#
# For a list of all available plugins, check out
#
# https://docs.fastlane.tools/plugins/available-plugins
#
# Uncomment the line if you want fastlane to automatically update itself
# update_fastlane
default_platform(:ios)
platform :ios do
# Constants
TEAM_ID = "2F67MQ8R79"
CODE_SIGN_IDENTITY = "Apple Distribution: Hau Tran (#{TEAM_ID})"
BASE_BUNDLE_ID = "app.alextran.immich"
# App identifiers for production
PROD_APP_IDENTIFIERS = [
"app.alextran.immich",
"app.alextran.immich.ShareExtension",
"app.alextran.immich.Widget"
]
# App identifiers for development
DEV_APP_IDENTIFIERS = [
"app.alextran.immich.development",
"app.alextran.immich.development.ShareExtension",
"app.alextran.immich.development.Widget"
]
# Helper method to get App Store Connect API key
def get_api_key
app_store_connect_api_key(
key_id: ENV["APP_STORE_CONNECT_API_KEY_ID"],
issuer_id: ENV["APP_STORE_CONNECT_API_KEY_ISSUER_ID"],
key_filepath: "#{Dir.home}/.appstoreconnect/private_keys/AuthKey_#{ENV['APP_STORE_CONNECT_API_KEY_ID']}.p8",
duration: 1200,
in_house: false
)
end
# Helper method to sync certificates and profiles using match
def sync_code_signing(app_identifiers:, readonly: true)
match(
type: "appstore",
app_identifier: app_identifiers,
readonly: readonly,
keychain_name: ENV["KEYCHAIN_NAME"] || "login.keychain",
keychain_password: ENV["KEYCHAIN_PASSWORD"] || ""
)
end
# Helper method to get version from pubspec.yaml
def get_version_from_pubspec
require 'yaml'
pubspec_path = File.join(Dir.pwd, "../..", "pubspec.yaml")
pubspec = YAML.load_file(pubspec_path)
version_string = pubspec['version']
version_string ? version_string.split('+').first : nil
end
# Helper method to configure code signing for all targets
def configure_code_signing(bundle_id_suffix: "")
bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}"
# Runner (main app)
update_code_signing_settings(
use_automatic_signing: false,
path: "./Runner.xcodeproj",
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
code_sign_identity: CODE_SIGN_IDENTITY,
bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}",
profile_name: "match AppStore #{BASE_BUNDLE_ID}#{bundle_suffix}",
targets: ["Runner"]
)
# ShareExtension
update_code_signing_settings(
use_automatic_signing: false,
path: "./Runner.xcodeproj",
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
code_sign_identity: CODE_SIGN_IDENTITY,
bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}.ShareExtension",
profile_name: "match AppStore #{BASE_BUNDLE_ID}#{bundle_suffix}.ShareExtension",
targets: ["ShareExtension"]
)
# WidgetExtension
update_code_signing_settings(
use_automatic_signing: false,
path: "./Runner.xcodeproj",
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
code_sign_identity: CODE_SIGN_IDENTITY,
bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}.Widget",
profile_name: "match AppStore #{BASE_BUNDLE_ID}#{bundle_suffix}.Widget",
targets: ["WidgetExtension"]
)
end
# Helper method to build and upload to TestFlight
def build_and_upload(
api_key:,
bundle_id_suffix: "",
configuration: "Release",
distribute_external: true,
version_number: nil
)
bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}"
app_identifier = "#{BASE_BUNDLE_ID}#{bundle_suffix}"
# Set version number if provided
if version_number
increment_version_number(version_number: version_number)
end
# Increment build number
increment_build_number(
build_number: latest_testflight_build_number(
api_key: api_key,
app_identifier: app_identifier
) + 1,
xcodeproj: "./Runner.xcodeproj"
)
# Build the app
build_app(
scheme: "Runner",
workspace: "Runner.xcworkspace",
configuration: configuration,
export_method: "app-store",
xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual",
export_options: {
provisioningProfiles: {
"#{app_identifier}" => "match AppStore #{app_identifier}",
"#{app_identifier}.ShareExtension" => "match AppStore #{app_identifier}.ShareExtension",
"#{app_identifier}.Widget" => "match AppStore #{app_identifier}.Widget"
},
signingStyle: "manual",
signingCertificate: CODE_SIGN_IDENTITY
}
)
# Upload to TestFlight
upload_to_testflight(
api_key: api_key,
skip_waiting_for_build_processing: true,
distribute_external: distribute_external
)
end
desc "iOS Development Build to TestFlight (requires separate bundle ID)"
lane :gha_testflight_dev do
api_key = get_api_key
# Sync certificates and profiles using match
sync_code_signing(app_identifiers: DEV_APP_IDENTIFIERS)
# Configure code signing for dev bundle IDs
configure_code_signing(bundle_id_suffix: "development")
# Build and upload
build_and_upload(
api_key: api_key,
bundle_id_suffix: "development",
configuration: "Profile",
distribute_external: false
)
end
desc "iOS Release to TestFlight"
lane :gha_release_prod do
api_key = get_api_key
# Sync certificates and profiles using match
sync_code_signing(app_identifiers: PROD_APP_IDENTIFIERS)
# Configure code signing for production bundle IDs
configure_code_signing
# Build and upload with version number
build_and_upload(
api_key: api_key,
version_number: get_version_from_pubspec,
distribute_external: false,
)
end
desc "iOS Manual Release"
lane :release_manual do
enable_automatic_code_signing(
path: "./Runner.xcodeproj",
targets: ["Runner", "ShareExtension", "WidgetExtension"]
)
increment_version_number(
version_number: get_version_from_pubspec
)
increment_build_number(
build_number: latest_testflight_build_number + 1,
)
# Build archive with automatic signing
gym(
scheme: "Runner",
workspace: "Runner.xcworkspace",
configuration: "Release",
export_method: "app-store",
skip_package_ipa: false,
xcargs: "-skipMacroValidation -allowProvisioningUpdates",
export_options: {
method: "app-store",
signingStyle: "automatic",
uploadBitcode: false,
uploadSymbols: true,
compileBitcode: false
}
)
upload_to_testflight(
skip_waiting_for_build_processing: true
)
end
desc "iOS Build Only (no TestFlight upload)"
lane :gha_build_only do
# Use the same build process as production, just skip the upload
# This ensures PR builds validate the same way as production builds
# Sync certificates and profiles using match
sync_code_signing(app_identifiers: DEV_APP_IDENTIFIERS)
# Configure code signing for dev bundle IDs
configure_code_signing(bundle_id_suffix: "development")
# Build the app (same as gha_testflight_dev but without upload)
build_app(
scheme: "Runner",
workspace: "Runner.xcworkspace",
configuration: "Release",
export_method: "app-store",
skip_package_ipa: true,
xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual",
export_options: {
provisioningProfiles: {
"#{BASE_BUNDLE_ID}.development" => "match AppStore #{BASE_BUNDLE_ID}.development",
"#{BASE_BUNDLE_ID}.development.ShareExtension" => "match AppStore #{BASE_BUNDLE_ID}.development.ShareExtension",
"#{BASE_BUNDLE_ID}.development.Widget" => "match AppStore #{BASE_BUNDLE_ID}.development.Widget"
},
signingStyle: "manual",
signingCertificate: CODE_SIGN_IDENTITY
}
)
end
desc "Sync all certificates and profiles (run locally to update match repo)"
lane :sync_certificates do
# Sync production certificates and profiles
match(
type: "appstore",
app_identifier: PROD_APP_IDENTIFIERS,
readonly: false
)
# Sync development certificates and profiles
match(
type: "appstore",
app_identifier: DEV_APP_IDENTIFIERS,
readonly: false
)
end
desc "Regenerate all certificates and profiles (use when expired)"
lane :regenerate_certificates do
# Nuke existing certificates
match_nuke(type: "appstore")
# Generate new ones
sync_certificates
end
end