Compare commits

...

1 Commits

Author SHA1 Message Date
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
4 changed files with 132 additions and 80 deletions

View File

@@ -26,21 +26,9 @@ on:
required: true
APP_STORE_CONNECT_API_KEY:
required: true
IOS_CERTIFICATE_P12:
MATCH_PASSWORD:
required: true
IOS_CERTIFICATE_PASSWORD:
required: true
IOS_PROVISIONING_PROFILE:
required: true
IOS_PROVISIONING_PROFILE_SHARE_EXTENSION:
required: true
IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION:
required: true
IOS_DEVELOPMENT_PROVISIONING_PROFILE:
required: true
IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION:
required: true
IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION:
MATCH_GIT_BASIC_AUTHORIZATION:
required: true
FASTLANE_TEAM_ID:
required: true
@@ -193,6 +181,21 @@ jobs:
runs-on: macos-latest
steps:
- name: Generate token for ios-certs repo
id: token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
owner: immich-app
repositories: immich,ios-certs
- name: Set up match authorization
id: match-auth
run: |
# Create base64-encoded authorization for match
echo "base64_token=$(echo -n 'x-access-token:${{ steps.token.outputs.token }}' | base64)" >> $GITHUB_OUTPUT
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
@@ -240,64 +243,26 @@ jobs:
mkdir -p ~/.appstoreconnect/private_keys
echo "$API_KEY_CONTENT" | base64 --decode > ~/.appstoreconnect/private_keys/AuthKey_${API_KEY_ID}.p8
- name: Import Certificate and Provisioning Profiles
- name: Create keychain for match
env:
IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }}
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }}
IOS_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_SHARE_EXTENSION }}
IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
IOS_DEVELOPMENT_PROVISIONING_PROFILE: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE }}
IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION }}
IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
ENVIRONMENT: ${{ inputs.environment || 'development' }}
working-directory: ./mobile/ios
KEYCHAIN_PASSWORD: ${{ github.run_id }}
run: |
# Decode certificate
echo "$IOS_CERTIFICATE_P12" | base64 --decode > certificate.p12
# Decode provisioning profiles based on environment
if [[ "$ENVIRONMENT" == "development" ]]; then
echo "$IOS_DEVELOPMENT_PROVISIONING_PROFILE" | base64 --decode > profile_dev.mobileprovision
echo "$IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION" | base64 --decode > profile_dev_share.mobileprovision
echo "$IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION" | base64 --decode > profile_dev_widget.mobileprovision
ls -lh profile_dev*.mobileprovision
else
echo "$IOS_PROVISIONING_PROFILE" | base64 --decode > profile.mobileprovision
echo "$IOS_PROVISIONING_PROFILE_SHARE_EXTENSION" | base64 --decode > profile_share.mobileprovision
echo "$IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION" | base64 --decode > profile_widget.mobileprovision
ls -lh profile*.mobileprovision
fi
- name: Create keychain and import certificate
env:
KEYCHAIN_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
working-directory: ./mobile/ios
run: |
# Create keychain
# Create a temporary keychain for CI
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security set-keychain-settings -t 3600 -u build.keychain
# Import certificate
security import certificate.p12 -k build.keychain -P "$CERTIFICATE_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" build.keychain
# Verify certificate was imported
security find-identity -v -p codesigning build.keychain
- name: Build and deploy to TestFlight
env:
FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }}
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ steps.match-auth.outputs.base64_token }}
KEYCHAIN_NAME: build.keychain
KEYCHAIN_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ github.run_id }}
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
ENVIRONMENT: ${{ inputs.environment || 'development' }}
BUNDLE_ID_SUFFIX: ${{ inputs.environment == 'production' && '' || 'development' }}
GITHUB_REF: ${{ github.ref }}
working-directory: ./mobile/ios
run: |

View File

@@ -21,6 +21,20 @@ platform :ios do
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(
@@ -32,6 +46,17 @@ platform :ios do
)
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'
@@ -54,7 +79,7 @@ end
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
code_sign_identity: CODE_SIGN_IDENTITY,
bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}",
profile_name: "#{BASE_BUNDLE_ID}#{bundle_suffix} AppStore",
profile_name: "match AppStore #{BASE_BUNDLE_ID}#{bundle_suffix}",
targets: ["Runner"]
)
@@ -65,7 +90,7 @@ end
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
code_sign_identity: CODE_SIGN_IDENTITY,
bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}.ShareExtension",
profile_name: "#{BASE_BUNDLE_ID}#{bundle_suffix}.ShareExtension AppStore",
profile_name: "match AppStore #{BASE_BUNDLE_ID}#{bundle_suffix}.ShareExtension",
targets: ["ShareExtension"]
)
@@ -76,7 +101,7 @@ end
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
code_sign_identity: CODE_SIGN_IDENTITY,
bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}.Widget",
profile_name: "#{BASE_BUNDLE_ID}#{bundle_suffix}.Widget AppStore",
profile_name: "match AppStore #{BASE_BUNDLE_ID}#{bundle_suffix}.Widget",
targets: ["WidgetExtension"]
)
end
@@ -115,9 +140,9 @@ end
xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual",
export_options: {
provisioningProfiles: {
"#{app_identifier}" => "#{app_identifier} AppStore",
"#{app_identifier}.ShareExtension" => "#{app_identifier}.ShareExtension AppStore",
"#{app_identifier}.Widget" => "#{app_identifier}.Widget AppStore"
"#{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
@@ -136,10 +161,8 @@ end
lane :gha_testflight_dev do
api_key = get_api_key
# Install development provisioning profiles
install_provisioning_profile(path: "profile_dev.mobileprovision")
install_provisioning_profile(path: "profile_dev_share.mobileprovision")
install_provisioning_profile(path: "profile_dev_widget.mobileprovision")
# 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")
@@ -157,11 +180,8 @@ end
lane :gha_release_prod do
api_key = get_api_key
# Install provisioning profiles
install_provisioning_profile(path: "profile.mobileprovision")
install_provisioning_profile(path: "profile_share.mobileprovision")
install_provisioning_profile(path: "profile_widget.mobileprovision")
# Sync certificates and profiles using match
sync_code_signing(app_identifiers: PROD_APP_IDENTIFIERS)
# Configure code signing for production bundle IDs
configure_code_signing
@@ -215,10 +235,8 @@ end
# Use the same build process as production, just skip the upload
# This ensures PR builds validate the same way as production builds
# Install provisioning profiles (use development profiles for PR builds)
install_provisioning_profile(path: "profile_dev.mobileprovision")
install_provisioning_profile(path: "profile_dev_share.mobileprovision")
install_provisioning_profile(path: "profile_dev_widget.mobileprovision")
# 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")
@@ -233,9 +251,9 @@ end
xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual",
export_options: {
provisioningProfiles: {
"#{BASE_BUNDLE_ID}.development" => "#{BASE_BUNDLE_ID}.development AppStore",
"#{BASE_BUNDLE_ID}.development.ShareExtension" => "#{BASE_BUNDLE_ID}.development.ShareExtension AppStore",
"#{BASE_BUNDLE_ID}.development.Widget" => "#{BASE_BUNDLE_ID}.development.Widget AppStore"
"#{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
@@ -243,4 +261,30 @@ end
)
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

View File

@@ -0,0 +1,19 @@
git_url(ENV["MATCH_GIT_URL"] || "https://github.com/immich-app/ios-certs")
storage_mode("git")
type("appstore")
team_id("2F67MQ8R79")
app_identifier([
"app.alextran.immich",
"app.alextran.immich.ShareExtension",
"app.alextran.immich.Widget",
"app.alextran.immich.development",
"app.alextran.immich.development.ShareExtension",
"app.alextran.immich.development.Widget"
])
# For all available options run `fastlane match --help`
# The docs are available on https://docs.fastlane.tools/actions/match

View File

@@ -39,6 +39,30 @@ iOS Release to TestFlight
iOS Manual Release
### ios gha_build_only
```sh
[bundle exec] fastlane ios gha_build_only
```
iOS Build Only (no TestFlight upload)
### ios sync_certificates
```sh
[bundle exec] fastlane ios sync_certificates
```
Sync all certificates and profiles (run locally to update match repo)
### ios regenerate_certificates
```sh
[bundle exec] fastlane ios regenerate_certificates
```
Regenerate all certificates and profiles (use when expired)
----
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.