OPS: Refactor iOS pipeline

This commit is contained in:
Marcos Rodriguez Velez 2025-03-01 23:51:54 -04:00
parent 4c0fd89530
commit 1946fa0dde
3 changed files with 366 additions and 222 deletions

View file

@ -22,12 +22,28 @@ jobs:
branch_name: ${{ steps.get_latest_commit_details.outputs.branch_name }}
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
# Set Match to read-only for builds, only write new profiles manually
MATCH_READONLY: "true"
steps:
- name: Checkout Project
uses: actions/checkout@v4
with:
fetch-depth: 0 # Ensures the full Git history is available
# Setup caching to speed up builds
- name: Setup Caching
uses: actions/cache@v3
with:
path: |
~/Library/Caches/CocoaPods
ios/Pods
~/.npm
node_modules
vendor/bundle
key: ${{ runner.os }}-ios-${{ hashFiles('**/package-lock.json', '**/Podfile.lock', '**/Gemfile.lock') }}
restore-keys: |
${{ runner.os }}-ios-
- name: Clear All Caches
if: github.ref == 'refs/heads/master'
@ -81,6 +97,7 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- uses: maxim-lobanov/setup-xcode@v1
with:
@ -90,6 +107,7 @@ jobs:
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.1.6
bundler-cache: true # This caches gems installed via bundler
- name: Install Dependencies with Bundler
run: |
@ -102,6 +120,7 @@ jobs:
- name: Install CocoaPods Dependencies
run: |
bundle exec fastlane ios install_pods
echo "CocoaPods dependencies installed successfully"
- name: Generate Build Number Based on Timestamp
id: generate_build_number
@ -147,8 +166,7 @@ jobs:
- name: Build App
id: build_app
run: |
bundle exec fastlane ios build_app_lane --verbose
echo "ipa_output_path=$IPA_OUTPUT_PATH" >> $GITHUB_OUTPUT # Set the IPA output path for future jobs
bundle exec fastlane ios build_app_lane
- name: Upload Bugsnag Sourcemaps
if: success()
@ -156,8 +174,8 @@ jobs:
env:
BUGSNAG_API_KEY: ${{ secrets.BUGSNAG_API_KEY }}
BUGSNAG_RELEASE_STAGE: production
PROJECT_VERSION: ${{ needs.build.outputs.project_version }}
NEW_BUILD_NUMBER: ${{ needs.build.outputs.new_build_number }}
PROJECT_VERSION: ${{ env.PROJECT_VERSION }}
NEW_BUILD_NUMBER: ${{ env.NEW_BUILD_NUMBER }}
- name: Upload Build Logs
if: always()
@ -165,13 +183,19 @@ jobs:
with:
name: build_logs
path: ./ios/build_logs/
retention-days: 7
- name: Upload IPA as Artifact
if: success()
uses: actions/upload-artifact@v4
with:
name: BlueWallet_${{env.PROJECT_VERSION}}_${{env.NEW_BUILD_NUMBER}}.ipa
path: ${{ env.IPA_OUTPUT_PATH }} # Directly from Fastfile `IPA_OUTPUT_PATH`
path: ${{ env.IPA_OUTPUT_PATH }}
retention-days: 7
- name: Delete Temporary Keychain
if: always()
run: bundle exec fastlane ios delete_temp_keychain
testflight-upload:
needs: build
@ -191,6 +215,7 @@ jobs:
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.1.6
bundler-cache: true
- name: Install Dependencies with Bundler
run: |
@ -205,13 +230,6 @@ jobs:
- name: Create App Store Connect API Key JSON
run: echo '${{ secrets.APP_STORE_CONNECT_API_KEY_CONTENT }}' > ./appstore_api_key.json
- name: Verify IPA File Download
run: |
echo "Current directory:"
pwd
echo "Files in current directory:"
ls -la ./
- name: Set IPA Path Environment Variable
run: echo "IPA_OUTPUT_PATH=$(pwd)/BlueWallet_${{ needs.build.outputs.project_version }}_${{ needs.build.outputs.new_build_number }}.ipa" >> $GITHUB_ENV
@ -219,19 +237,23 @@ jobs:
- name: Verify IPA Path Before Upload
run: |
if [ ! -f "$IPA_OUTPUT_PATH" ]; then
echo "IPA file not found at path: $IPA_OUTPUT_PATH"
echo "❌ IPA file not found at path: $IPA_OUTPUT_PATH"
ls -la $(pwd)
exit 1
else
echo "✅ Found IPA at: $IPA_OUTPUT_PATH"
fi
- name: Print Environment Variables for Debugging
run: |
echo "LATEST_COMMIT_MESSAGE: $LATEST_COMMIT_MESSAGE"
echo "BRANCH_NAME: $BRANCH_NAME"
echo "PROJECT_VERSION: $PROJECT_VERSION"
echo "NEW_BUILD_NUMBER: $NEW_BUILD_NUMBER"
echo "IPA_OUTPUT_PATH: $IPA_OUTPUT_PATH"
- name: Upload to TestFlight
run: |
ls -la $IPA_OUTPUT_PATH
bundle exec fastlane ios upload_to_testflight_lane
run: bundle exec fastlane ios upload_to_testflight_lane
env:
APP_STORE_CONNECT_API_KEY_PATH: $(pwd)/appstore_api_key.p8
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
@ -242,18 +264,19 @@ jobs:
APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY_ID }}
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
IPA_OUTPUT_PATH: ${{ env.IPA_OUTPUT_PATH }}
- name: Post PR Comment
if: success() && github.event_name == 'pull_request'
uses: actions/github-script@v6
env:
BUILD_NUMBER: ${{ needs.build.outputs.new_build_number }}
PROJECT_VERSION: ${{ needs.build.outputs.project_version }}
LATEST_COMMIT_MESSAGE: ${{ needs.build.outputs.latest_commit_message }}
with:
script: |
const buildNumber = process.env.BUILD_NUMBER;
const message = `The build ${buildNumber} has been uploaded to TestFlight.`;
const version = process.env.PROJECT_VERSION;
const message = `✅ Build ${version} (${buildNumber}) has been uploaded to TestFlight and will be available for testing soon.`;
const prNumber = context.payload.pull_request.number;
const repo = context.repo;
github.rest.issues.createComment({

View file

@ -192,223 +192,222 @@ end
# ===========================
platform :ios do
# ==== Helper Methods ====
def ensure_env_vars(vars)
vars.each do |var|
UI.user_error!("#{var} environment variable is missing") if ENV[var].nil? || ENV[var].empty?
end
end
def log_success(message)
UI.success("✅ #{message}")
end
def log_error(message)
UI.error("❌ #{message}")
end
desc "Register new devices from a file"
# ==== Device Management ====
desc "Register new devices from a file and update provisioning profiles"
lane :register_devices_from_txt do
UI.message("Registering new devices from file...")
csv_path = "../../devices.txt" # Update with actual path
unless File.exist?(csv_path)
UI.user_error!("Devices file not found at: #{csv_path}")
end
csv_path = "../../devices.txt" # Update this with the actual path to your file
register_devices(devices_file: csv_path)
log_success("Devices registered successfully")
# Register devices using the devices_file parameter
register_devices(
devices_file: csv_path
)
UI.message("Devices registered successfully.")
# Update provisioning profiles for all app identifiers
update_provisioning_profiles_for_new_devices
end
desc "Update provisioning profiles for all app identifiers after adding new devices"
private_lane :update_provisioning_profiles_for_new_devices do
UI.message("Updating provisioning profiles for new devices...")
app_identifiers.each do |app_identifier|
match(
type: "development",
app_identifier: app_identifier,
readonly: false, # Regenerate provisioning profile if needed
readonly: false,
force_for_new_devices: true,
clone_branch_directly: true
)
end
UI.message("Development provisioning profiles updated.")
end
desc "Create a temporary keychain"
lane :create_temp_keychain do
UI.message("Creating a temporary keychain...")
create_keychain(
name: "temp_keychain",
password: ENV["KEYCHAIN_PASSWORD"],
default_keychain: true,
unlock: true,
timeout: 3600,
lock_when_sleeps: true
)
UI.message("Temporary keychain created successfully.")
log_success("Development provisioning profiles updated")
end
desc "Synchronize certificates and provisioning profiles"
# ==== Keychain Management ====
desc "Create a temporary keychain for CI builds"
lane :create_temp_keychain do
ensure_env_vars(["KEYCHAIN_PASSWORD"])
UI.message("Creating temporary keychain...")
begin
create_keychain(
name: "temp_keychain",
password: ENV["KEYCHAIN_PASSWORD"],
default_keychain: true,
unlock: true,
timeout: 3600,
lock_when_sleeps: true
)
log_success("Temporary keychain created")
rescue => e
log_error("Failed to create temporary keychain: #{e.message}")
raise e
end
end
desc "Delete temporary keychain when done with the build"
lane :delete_temp_keychain do
UI.message("Deleting temporary keychain...")
begin
delete_keychain(name: "temp_keychain")
log_success("Temporary keychain deleted")
rescue => e
log_error("Failed to delete temporary keychain: #{e.message}")
# Don't raise error here, as this is cleanup code
end
end
# ==== Provisioning Profile Management ====
desc "Setup provisioning profiles for all app identifiers"
lane :setup_provisioning_profiles do
required_vars = ["GIT_ACCESS_TOKEN", "GIT_URL", "ITC_TEAM_ID", "ITC_TEAM_NAME", "KEYCHAIN_PASSWORD"]
ensure_env_vars(required_vars)
UI.message("Setting up provisioning profiles...")
platform = "ios"
# Iterate over app identifiers to fetch provisioning profiles
app_identifiers.each do |app_identifier|
match(
git_basic_authorization: ENV["GIT_ACCESS_TOKEN"],
git_url: ENV["GIT_URL"],
type: "appstore",
clone_branch_directly: true, # Skip if the branch already exists
platform: platform,
app_identifier: app_identifier,
team_id: ENV["ITC_TEAM_ID"],
team_name: ENV["ITC_TEAM_NAME"],
readonly: true,
keychain_name: "temp_keychain",
keychain_password: ENV["KEYCHAIN_PASSWORD"]
)
begin
match(
git_basic_authorization: ENV["GIT_ACCESS_TOKEN"],
git_url: ENV["GIT_URL"],
type: "appstore",
clone_branch_directly: true,
platform: "ios",
app_identifier: app_identifier,
team_id: ENV["ITC_TEAM_ID"],
team_name: ENV["ITC_TEAM_NAME"],
readonly: true,
keychain_name: "temp_keychain",
keychain_password: ENV["KEYCHAIN_PASSWORD"]
)
rescue => e
log_error("Failed to fetch provisioning profile for #{app_identifier}: #{e.message}")
raise e
end
end
log_success("All provisioning profiles set up")
end
desc "Fetch development certificates and provisioning profiles for Mac Catalyst"
lane :fetch_dev_profiles_catalyst do
match(
type: "development",
platform: "catalyst",
app_identifier: app_identifiers,
readonly: true,
clone_branch_directly: true
)
end
desc "Fetch App Store certificates and provisioning profiles for Mac Catalyst"
lane :fetch_appstore_profiles_catalyst do
match(
type: "appstore",
platform: "catalyst",
app_identifier: app_identifiers,
readonly: true,
clone_branch_directly: true
)
end
# ==== Catalyst Support Lanes ====
desc "Setup provisioning profiles for Mac Catalyst"
lane :setup_catalyst_provisioning_profiles do
lane :setup_catalyst_provisioning do
UI.message("Setting up Mac Catalyst provisioning profiles...")
# First development profiles
fetch_catalyst_profiles(type: "development")
# Then App Store profiles
fetch_catalyst_profiles(type: "appstore")
log_success("Mac Catalyst provisioning profiles set up")
end
private_lane :fetch_catalyst_profiles do |options|
type = options[:type]
readonly = options[:readonly] || true
force_for_new_devices = options[:force_for_new_devices] || false
app_identifiers.each do |app_identifier|
match(
type: "development",
type: type,
platform: "catalyst",
app_identifier: app_identifier,
readonly: false,
force_for_new_devices: true,
clone_branch_directly: true
)
match(
type: "appstore",
platform: "catalyst",
app_identifier: app_identifier,
readonly: false,
readonly: readonly,
force_for_new_devices: force_for_new_devices,
clone_branch_directly: true
)
end
end
# ==== Build Preparation ====
desc "Clear derived data"
lane :clear_derived_data_lane do
UI.message("Clearing derived data...")
clear_derived_data
begin
clear_derived_data
log_success("Derived data cleared")
rescue => e
log_error("Failed to clear derived data: #{e.message}")
# Continue despite error
end
end
desc "Increment build number"
lane :increment_build_number_lane do
UI.message("Incrementing build number to current timestamp...")
ensure_env_vars(["NEW_BUILD_NUMBER"])
UI.message("Incrementing build number to #{ENV['NEW_BUILD_NUMBER']}...")
# Set the new build number
increment_build_number(
xcodeproj: "ios/BlueWallet.xcodeproj",
build_number: ENV["NEW_BUILD_NUMBER"]
)
UI.message("Build number set to: #{ENV['NEW_BUILD_NUMBER']}")
begin
increment_build_number(
xcodeproj: "ios/BlueWallet.xcodeproj",
build_number: ENV["NEW_BUILD_NUMBER"]
)
log_success("Build number set to: #{ENV['NEW_BUILD_NUMBER']}")
rescue => e
log_error("Failed to increment build number: #{e.message}")
raise e
end
end
desc "Install CocoaPods dependencies"
lane :install_pods do
UI.message("Installing CocoaPods dependencies...")
cocoapods(podfile: "ios/Podfile")
begin
cocoapods(podfile: "ios/Podfile")
log_success("CocoaPods dependencies installed")
rescue => e
log_error("Failed to install CocoaPods dependencies: #{e.message}")
raise e
end
end
desc "Upload IPA to TestFlight"
lane :upload_to_testflight_lane do
branch_name = ENV['BRANCH_NAME'] || "unknown-branch"
last_commit_message = ENV['LATEST_COMMIT_MESSAGE'] || "No commit message found"
changelog = <<~CHANGELOG
Build Information:
CHANGELOG
# Include the branch name only if it is not 'master'
if branch_name != 'master'
changelog += <<~CHANGELOG
- Branch: #{branch_name}
CHANGELOG
end
changelog += <<~CHANGELOG
- Commit: #{last_commit_message}
CHANGELOG
ipa_path = ENV['IPA_OUTPUT_PATH']
# ==== Build and Upload ====
if ipa_path.nil? || ipa_path.empty? || !File.exist?(ipa_path)
UI.user_error!("IPA file not found at path: #{ipa_path}")
end
UI.message("Uploading IPA to TestFlight from path: #{ipa_path}")
UI.message("Changelog:\n#{changelog}")
upload_to_testflight(
api_key_path: "./appstore_api_key.json",
ipa: ipa_path,
skip_waiting_for_build_processing: true,
changelog: changelog
)
UI.success("Successfully uploaded IPA to TestFlight!")
end
desc "Upload iOS source maps to Bugsnag"
lane :upload_bugsnag_sourcemaps do
bugsnag_api_key = ENV['BUGSNAG_API_KEY']
bugsnag_release_stage = ENV['BUGSNAG_RELEASE_STAGE'] || "production"
version = ENV['PROJECT_VERSION']
build_number = ENV['NEW_BUILD_NUMBER']
UI.user_error!("BUGSNAG_API_KEY environment variable is missing") if bugsnag_api_key.nil?
UI.user_error!("PROJECT_VERSION environment variable is missing") if version.nil?
UI.user_error!("NEW_BUILD_NUMBER environment variable is missing") if build_number.nil?
ios_sourcemap = "./ios/build/Build/Products/Release-iphonesimulator/main.jsbundle.map"
if File.exist?(ios_sourcemap)
UI.message("Uploading iOS source map to Bugsnag...")
bugsnag_sourcemaps_upload(
api_key: bugsnag_api_key,
source_map: ios_sourcemap,
minified_file: "./ios/main.jsbundle",
code_bundle_id: "#{version}-#{build_number}",
release_stage: bugsnag_release_stage,
app_version: version
)
UI.success("iOS source map uploaded successfully.")
else
UI.error("iOS source map not found at #{ios_sourcemap}")
end
end
desc "Build the iOS app"
desc "Build the iOS app for distribution"
lane :build_app_lane do
Dir.chdir(project_root) do
UI.message("Building the application from: #{Dir.pwd}")
UI.message("Building the application...")
workspace_path = File.join(project_root, "ios", "BlueWallet.xcworkspace")
export_options_path = File.join(project_root, "ios", "export_options.plist")
output_name = "BlueWallet_#{ENV['PROJECT_VERSION']}_#{ENV['NEW_BUILD_NUMBER']}.ipa"
output_dir = File.join(project_root, "ios", "build")
logs_dir = File.join(project_root, "ios", "build_logs")
# Ensure the build logs directory exists
FileUtils.mkdir_p(logs_dir) unless Dir.exist?(logs_dir)
# Clear derived data before building
clear_derived_data_lane
begin
@ -422,29 +421,142 @@ end
include_symbols: true,
export_team_id: ENV["ITC_TEAM_ID"],
export_options: export_options_path,
output_directory: File.join(project_root, "ios", "build"),
output_name: "BlueWallet_#{ENV['PROJECT_VERSION']}_#{ENV['NEW_BUILD_NUMBER']}.ipa",
buildlog_path: File.join(project_root, "ios", "build_logs"),
output_directory: output_dir,
output_name: output_name,
buildlog_path: logs_dir,
silent: false,
clean: true
)
# Set environment variables for the IPA path
ipa_path = lane_context[SharedValues::IPA_OUTPUT_PATH]
if ipa_path && File.exist?(ipa_path)
ENV['IPA_OUTPUT_PATH'] = ipa_path
sh("echo 'ipa_output_path=#{ipa_path}' >> $GITHUB_OUTPUT") if ENV['GITHUB_OUTPUT']
log_success("IPA built successfully at: #{ipa_path}")
else
UI.user_error!("IPA not found after build_ios_app")
end
rescue => e
UI.user_error!("build_ios_app failed: #{e.message}")
end
# Use File.join to construct paths without extra slashes
ipa_path = lane_context[SharedValues::IPA_OUTPUT_PATH]
if ipa_path && File.exist?(ipa_path)
UI.message("IPA successfully found at: #{ipa_path}")
ENV['IPA_OUTPUT_PATH'] = ipa_path
sh("echo 'IPA_OUTPUT_PATH=#{ipa_path}' >> $GITHUB_ENV") # Export for GitHub Actions
else
UI.user_error!("IPA not found after build_ios_app.")
log_error("Failed to build app: #{e.message}")
raise e
end
end
desc "Upload IPA to TestFlight"
lane :upload_to_testflight_lane do
required_vars = ["IPA_OUTPUT_PATH"]
ensure_env_vars(required_vars)
branch_name = ENV['BRANCH_NAME'] || "unknown-branch"
commit_message = ENV['LATEST_COMMIT_MESSAGE'] || "No commit message found"
changelog = <<~CHANGELOG
Build Information:
CHANGELOG
# Include branch name only if not master
if branch_name != 'master'
changelog += <<~CHANGELOG
- Branch: #{branch_name}
CHANGELOG
end
changelog += <<~CHANGELOG
- Commit: #{commit_message}
CHANGELOG
ipa_path = ENV['IPA_OUTPUT_PATH']
if !File.exist?(ipa_path)
UI.user_error!("IPA file not found at path: #{ipa_path}")
end
UI.message("Uploading IPA to TestFlight from path: #{ipa_path}")
UI.message("Changelog:\n#{changelog}")
begin
upload_to_testflight(
api_key_path: "./appstore_api_key.json",
ipa: ipa_path,
skip_waiting_for_build_processing: true,
changelog: changelog
)
log_success("Successfully uploaded IPA to TestFlight!")
rescue => e
log_error("Failed to upload to TestFlight: #{e.message}")
raise e
end
end
# ==== Bugsnag Integration ====
desc "Upload iOS source maps to Bugsnag"
lane :upload_bugsnag_sourcemaps do
required_vars = ["BUGSNAG_API_KEY", "PROJECT_VERSION", "NEW_BUILD_NUMBER"]
ensure_env_vars(required_vars)
bugsnag_api_key = ENV['BUGSNAG_API_KEY']
bugsnag_release_stage = ENV['BUGSNAG_RELEASE_STAGE'] || "production"
version = ENV['PROJECT_VERSION']
build_number = ENV['NEW_BUILD_NUMBER']
ios_sourcemap = "./ios/build/Build/Products/Release-iphonesimulator/main.jsbundle.map"
if File.exist?(ios_sourcemap)
UI.message("Uploading iOS source map to Bugsnag...")
begin
bugsnag_sourcemaps_upload(
api_key: bugsnag_api_key,
source_map: ios_sourcemap,
minified_file: "./ios/main.jsbundle",
code_bundle_id: "#{version}-#{build_number}",
release_stage: bugsnag_release_stage,
app_version: version
)
log_success("iOS source map uploaded successfully")
rescue => e
log_error("Failed to upload sourcemaps to Bugsnag: #{e.message}")
# Continue despite error, don't fail the build
end
else
log_error("iOS source map not found at #{ios_sourcemap}")
end
end
# ==== Complete Deployment Workflow ====
desc "Complete iOS deployment workflow"
lane :deploy_ios do |options|
UI.message("Starting iOS deployment process...")
# Setup everything
create_temp_keychain
setup_provisioning_profiles
clear_derived_data_lane
increment_build_number_lane
# Install pods if needed
unless File.directory?("ios/Pods")
install_pods
end
# Build and upload
build_app_lane
upload_to_testflight_lane
upload_bugsnag_sourcemaps
# Cleanup
delete_temp_keychain
log_success("iOS deployment completed successfully!")
end
end
end
# ===========================
# Global Lanes
# ===========================

View file

@ -3,33 +3,42 @@
# URL of the Git repository to store the certificates
git_url(ENV["GIT_URL"])
# Define the type of match to run, could be one of 'appstore', 'adhoc', 'development', or 'enterprise'.
# For example, use 'appstore' for App Store builds, 'adhoc' for Ad Hoc distribution,
# 'development' for development builds, and 'enterprise' for In-House (enterprise) distribution.
type("appstore")
# Define the type of match to run
# Default to "appstore" but can be overridden
type(ENV["MATCH_TYPE"] || "appstore")
app_identifier(["io.bluewallet.bluewallet", "io.bluewallet.bluewallet.watch", "io.bluewallet.bluewallet.watch.extension", "io.bluewallet.bluewallet.Stickers", "io.bluewallet.bluewallet.MarketWidget"]) # Replace with your app identifiers
# App identifiers for all BlueWallet apps
app_identifier([
"io.bluewallet.bluewallet",
"io.bluewallet.bluewallet.watch",
"io.bluewallet.bluewallet.watch.extension",
"io.bluewallet.bluewallet.Stickers",
"io.bluewallet.bluewallet.MarketWidget"
])
# List of app identifiers to create provisioning profiles for.
# Replace with your app's bundle identifier(s).
# Your Apple Developer account email address.
# Your Apple Developer account email address
username(ENV["APPLE_ID"])
# The ID of your Apple Developer team if you're part of multiple teams
# The ID of your Apple Developer team
team_id(ENV["ITC_TEAM_ID"])
# Set this to true if match should only read existing certificates and profiles
# and not create new ones.
readonly(true)
# Set readonly based on environment (default to true for safety)
# Set to false explicitly when new profiles need to be created
readonly(ENV["MATCH_READONLY"] == "false" ? false : true)
# Optional: The Git branch that is used for match.
# Default is 'master'.
# Optional: Path to a specific SSH key to be used by match.
# Only needed if you're using a private repository and match needs to use SSH keys for authentication.
# ssh_key("/path/to/your/private/key")
# Optional: Define the platform to use, can be 'ios', 'macos', or 'tvos'.
# For React Native projects, you'll typically use 'ios'.
# Define the platform to use
platform("ios")
# Git basic authentication through access token
# This is useful for CI/CD environments where SSH keys aren't available
git_basic_authorization(ENV["GIT_ACCESS_TOKEN"])
# Storage mode (git by default)
storage_mode("git")
# Always retry on network failures
retry_on_exception(true)
# Optional: The Git branch that is used for match
# Default is 'master'
# branch("main")