Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions ci/Jenkinsfile.ios
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env groovy
library 'status-jenkins-lib@v1.9.31'
library 'status-jenkins-lib@fix-ios-signing-with-fastlane'

/* Options section can't access functions in objects. */
def isPRBuild = utils.isPRBuild()
Expand Down Expand Up @@ -73,10 +73,11 @@ pipeline {
/* iOS build configuration */
IPHONE_SDK = "iphoneos"
ARCH = "x86_64"
/* iOS app paths */
/* iOS app paths - PR builds use StatusPR, release builds use Status */
STATUS_IOS_APP_NAME = "${utils.isReleaseBuild() ? 'Status' : 'StatusPR'}"
STATUS_IOS_APP_ARTIFACT = "pkg/${utils.pkgFilename(ext: 'ipa', arch: getArch(), version: env.VERSION, type: env.APP_TYPE)}"
STATUS_IOS_APP = "${WORKSPACE}/mobile/bin/ios/qt6/Status.app"
STATUS_IOS_IPA = "${WORKSPACE}/mobile/bin/ios/qt6/Status.ipa"
STATUS_IOS_APP = "${WORKSPACE}/mobile/bin/ios/qt6/${env.STATUS_IOS_APP_NAME}.app"
STATUS_IOS_IPA = "${WORKSPACE}/mobile/bin/ios/qt6/${env.STATUS_IOS_APP_NAME}.ipa"
TESTFLIGHT_POLL_TIMEOUT = "${params.TESTFLIGHT_POLL_TIMEOUT}"
TESTFLIGHT_POLL_INTERVAL = "${params.TESTFLIGHT_POLL_INTERVAL}"
/* nwaku source directory */
Expand Down Expand Up @@ -124,6 +125,7 @@ pipeline {
stage('Parallel Upload') {
parallel {
stage('Upload to TestFlight') {
when { expression { utils.isReleaseBuild() } }
steps {
script {
def changelog = sh(script: './scripts/generate-changelog.sh', returnStdout: true).trim()
Expand Down
63 changes: 63 additions & 0 deletions ci/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,66 @@ It also expects the presence of the following credentials:
* `macos-keychain-file` - Keychain file with the MacOS signing certificate.

You can read about how to create such a keychain [here](https://github.com/status-im/infra-docs/blob/master/articles/macos_signing_keychain.md).

## iOS
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this could be somewhere in mobile, maybe mobile/fastlane


iOS builds use **fastlane** with **match** for code signing management. This provides:
- Automatic certificate and profile management
- Separate signing for PR vs release builds

### Bundle Identifiers

| Build Type | Bundle ID | Fastlane Lane |
|------------|-----------|---------------|
| PR builds | `app.status.mobile.pr` | `pr` |
| Release | `app.status.mobile` | `release` |

### Certificate Types

| Build Type | Certificate Type | Match Type | Purpose |
|------------|------------------|------------|---------|
| PR builds | Apple Development | `development` | Testing on registered devices |
| Release | Apple Distribution | `appstore` | App Store / TestFlight |

### Fastlane Files

The `fastlane` configuration is located in `mobile/fastlane/`:

| File | Purpose |
|------|---------|
| `Fastfile` | Defines build lanes (pr, nightly, release) |
| `Matchfile` | Configures match for certificate management |
| `Appfile` | App identifiers and team configuration |
| `Gemfile` | Ruby dependencies |


### Local Development

To run `fastlane` locally for testing:

```bash
cd mobile/fastlane
nix --extra-experimental-features 'nix-command flakes' develop
bundle install

# Run a specific lane
bundle exec fastlane ios pr
bundle exec fastlane ios release
```

### Revoking/Rotating Certificates

If a certificate is compromised or revoked:

```bash
cd mobile/fastlane

# Nuke existing certificates (warning!! watch what you nuke)
bundle exec fastlane match nuke development
bundle exec fastlane match nuke distribution

# Regenerate
bundle exec fastlane match development --app_identifier "app.status.mobile.pr"
bundle exec fastlane match appstore --app_identifier "app.status.mobile"
```

11 changes: 11 additions & 0 deletions mobile/fastlane/.env.default
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Default environment variables for fastlane
# These can be overridden by CI environment

# Disable fastlane colors in CI
FASTLANE_DISABLE_COLORS=1

# Skip session verification
FASTLANE_SESSION=""

# Team ID
FASTLANE_TEAM_ID=8B5X2M6H2Y
15 changes: 15 additions & 0 deletions mobile/fastlane/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Gems installed locally
.gems/
vendor/bundle/

# Bundler
.bundle/

# Fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots/
fastlane/test_output/

# Nix
result
1 change: 1 addition & 0 deletions mobile/fastlane/.ruby-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.2.2
9 changes: 9 additions & 0 deletions mobile/fastlane/Appfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# App identifiers for Status App iOS builds
app_identifier("app.status.mobile")
apple_id(ENV["FASTLANE_APPLE_ID"])
team_id("8B5X2M6H2Y")
itc_team_id(ENV["FASTLANE_ITC_TEAM_ID"])

for_lane :pr do
app_identifier("app.status.mobile.pr")
end
224 changes: 224 additions & 0 deletions mobile/fastlane/Fastfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
# This file defines the build and signing lanes for iOS

default_platform(:ios)

TEAM_ID = "8B5X2M6H2Y"
APP_ID_RELEASE = "app.status.mobile"
APP_ID_PR = "app.status.mobile.pr"
APP_NAME_RELEASE = "Status.app"
APP_NAME_PR = "StatusPR.app"
PROJECT_DIR = File.expand_path("../", __dir__)
BUILD_DIR = File.join(PROJECT_DIR, "bin", "ios", "qt6")

platform :ios do
before_all do
UI.message("Project directory: #{PROJECT_DIR}")
UI.message("Build directory: #{BUILD_DIR}")
end

after_all do
# Clean up CI keychain after build
if is_ci
delete_keychain(name: keychain_name) if File.exist?(File.expand_path("~/Library/Keychains/#{keychain_name}-db"))
end
end

error do
# Clean up CI keychain on failure too
if is_ci
delete_keychain(name: keychain_name) rescue nil
end
end

# ============================================
# PR Builds
# ============================================
desc "Build iOS app for PRs"
lane :pr do
setup_ci_keychain

run_match(type: "adhoc", app_identifier: APP_ID_PR)

sign_app(
app_identifier: APP_ID_PR,
app_name: APP_NAME_PR,
profile_type: "adhoc"
)

create_ipa(app_name: APP_NAME_PR)
end

# ============================================
# Release Builds
# ============================================
desc "Build iOS app for release"
lane :release do
setup_ci_keychain

run_match(type: "appstore", app_identifier: APP_ID_RELEASE)

sign_app(
app_identifier: APP_ID_RELEASE,
app_name: APP_NAME_RELEASE,
profile_type: "appstore"
)

create_ipa(app_name: APP_NAME_RELEASE)

if ENV["UPLOAD_TO_TESTFLIGHT"] == "true"
upload_to_testflight_lane
end
end

# ============================================
# TestFlight Upload
# ============================================
desc "Upload IPA to TestFlight"
lane :upload_to_testflight_lane do
api_key = app_store_connect_api_key(
key_id: ENV["ASC_KEY_ID"],
issuer_id: ENV["ASC_ISSUER_ID"],
key_filepath: ENV["ASC_KEY_FILE"],
duration: 1200,
in_house: false
)

upload_to_testflight(
api_key: api_key,
ipa: File.join(BUILD_DIR, "Status.ipa"),
skip_waiting_for_build_processing: true,
changelog: ENV["CHANGELOG"] || "New build from CI"
)
end

# ============================================
# Helper Methods
# ============================================

private_lane :setup_ci_keychain do
if is_ci
create_keychain(
name: keychain_name,
password: keychain_password,
default_keychain: true,
unlock: true,
timeout: 3600,
lock_when_sleeps: false
)
end
end

private_lane :run_match do |options|
match_params = {
type: options[:type],
app_identifier: options[:app_identifier],
readonly: false,
# Auto-regenerate development profiles when new devices are registered
force_for_new_devices: options[:type] == "development"
}

# Only specify keychain params in CI where we create a custom keychain
if is_ci
match_params[:keychain_name] = keychain_name
match_params[:keychain_password] = keychain_password
end

match(match_params)
end

private_lane :sign_app do |options|
app_identifier = options[:app_identifier]
app_name = options[:app_name] || "Status.app"
profile_type = options[:profile_type]

app_path = File.join(BUILD_DIR, app_name)

unless File.exist?(app_path)
UI.user_error!("#{app_name} not found at #{app_path}")
end

# Use plutil to handle both binary and XML plist formats
plist_path = File.join(app_path, "Info.plist")
UI.message("Setting CFBundleIdentifier to #{app_identifier} in #{plist_path}")
sh("plutil -replace CFBundleIdentifier -string '#{app_identifier}' '#{plist_path}'")

UI.message("Signing app with identifier: #{app_identifier}")

profile_name = "match #{profile_type.capitalize} #{app_identifier}"

signing_identity = ENV["sigh_#{app_identifier}_#{profile_type}_certificate-name"] ||
lane_context[SharedValues::MATCH_PROVISIONING_PROFILE_MAPPING]&.dig(app_identifier) ||
get_signing_identity(profile_type)

# Need to Sign all frameworks first, else App crashes at runtime
frameworks_path = File.join(app_path, "Frameworks")
if File.directory?(frameworks_path)
Dir.glob("#{frameworks_path}/*.framework").each do |framework|
UI.message("Signing framework: #{File.basename(framework)}")
sh("codesign --force --sign '#{signing_identity}' --timestamp '#{framework}'")
end
end

UI.message("Signing main app bundle...")

profile_path = ENV["sigh_#{app_identifier}_#{profile_type}_profile-path"]

if profile_path && File.exist?(profile_path)
FileUtils.cp(profile_path, File.join(app_path, "embedded.mobileprovision"))
end

# Extract entitlements from provisioning profile and sign with them
entitlements_path = File.join(Dir.tmpdir, "entitlements.plist")
decoded_profile_path = File.join(Dir.tmpdir, "profile_decoded.plist")
embedded_profile = File.join(app_path, "embedded.mobileprovision")
sh("security cms -D -i '#{embedded_profile}' > '#{decoded_profile_path}'")
sh("plutil -extract Entitlements xml1 -o '#{entitlements_path}' '#{decoded_profile_path}'")

sh("codesign --force --sign '#{signing_identity}' --entitlements '#{entitlements_path}' --timestamp '#{app_path}'")

UI.success("App signed successfully!")
end

private_lane :create_ipa do |options|
app_name = options[:app_name] || "Status.app"
ipa_name = app_name.sub(".app", ".ipa")

app_path = File.join(BUILD_DIR, app_name)
ipa_path = File.join(BUILD_DIR, ipa_name)

UI.message("Creating IPA at #{ipa_path}...")

FileUtils.rm_f(ipa_path)

Dir.mktmpdir do |tmpdir|
payload_dir = File.join(tmpdir, "Payload")
FileUtils.mkdir_p(payload_dir)
FileUtils.cp_r(app_path, payload_dir)

Dir.chdir(tmpdir) do
sh("zip -r '#{ipa_path}' Payload")
end
end

UI.success("IPA created at #{ipa_path}")
end

def keychain_name
"status_ci_#{ENV['BUILD_NUMBER'] || 'local'}.keychain"
end

def keychain_password
ENV["MATCH_PASSWORD"] || "fastlane_ci_password"
end

def get_signing_identity(profile_type)
case profile_type
when "development"
"Apple Development"
when "adhoc", "appstore"
"Apple Distribution"
else
"Apple Distribution"
end
end
end
9 changes: 9 additions & 0 deletions mobile/fastlane/Gemfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
source "https://rubygems.org"

ruby ">= 3.0.0", "< 4.0.0"

# Core dependencies
gem "fastlane", "~> 2.225"

plugins_path = File.join(File.dirname(__FILE__), 'Pluginfile')
eval_gemfile(plugins_path) if File.exist?(plugins_path)
Loading