Files
element-x-ios/fastlane/Fastfile
T
joel.poyet 33d04f0303 Add a fastlane pipeline that releases HealthChat to TestFlight (#2)
element-x-ios had no release machinery at all: no fastlane, no Gemfile, and none of
its workflows built or uploaded anything. Upstream ships via Xcode Cloud, which
needs Element's private Enterprise submodule and so is unavailable here, while the
legacy element-ios fork already proves a fastlane pipeline against the same Apple
team, certificate and App Store Connect key.

Tagging healthchat-v<version> now builds a signed App Store IPA on the org's Mac
Studio and uploads it to TestFlight. Release notes come from a new
HEALTHCHAT_CHANGES.md, because upstream's own tooling owns CHANGES.md.

The lane deliberately does not pin Xcode. The runner carries a single globally
selected version, and naming an absent one does not fail fast — it sends xcodes
into a multi-GB authenticated download that no CI secret can satisfy. Each run logs
xcodebuild -version instead, which is the only record of what compiled a build.

Everything here is fork-owned, since upstream has no fastlane directory, so none of
it can conflict on rebase.
2026-09-22 18:21:48 +02:00

155 lines
6.8 KiB
Ruby

default_platform :ios
# No Xcode version pin: novaloop-mac-studio-001 carries a single, globally-selected Xcode, so
# whatever xcode-select points at is by definition the one we build with. Pinning bought nothing
# and risked everything — a version the runner lacks sends xcodes into a multi-GB authenticated
# download that no CI secret can satisfy, hanging the release. Change the runner's Xcode instead.
platform :ios do
#### Public ####
desc "Builds the App Store ipa and uploads it to TestFlight (internal testers only)"
# Named release_testflight to avoid colliding with fastlane's built-in :testflight action.
lane :release_testflight do |options|
# Accept inputs via options (local) or env vars (CI — lets the workflow pass multi-line
# release notes without shell-escaping them).
marketing_version = options[:marketing_version] || ENV["HEALTHCHAT_MARKETING_VERSION"]
release_notes = options[:release_notes] || ENV["HEALTHCHAT_RELEASE_NOTES"]
UI.user_error!("'marketing_version' is required (pass the option or set HEALTHCHAT_MARKETING_VERSION)") if marketing_version.to_s.empty?
# Apple caps CFBundleShortVersionString at three period-separated integers. The workflow's
# regex is laxer and accepts markers like 26.07.3-rc1, so catch it here — otherwise the run
# dies at the upload step after ~30 minutes of signing, looking exactly like a signing failure.
unless marketing_version =~ /\A\d+(\.\d+){0,2}\z/
UI.user_error!("'marketing_version' must be 1-3 dot-separated integers (got '#{marketing_version}'). " \
"Apple rejects anything else at upload. Put markers in the tag or release notes instead.")
end
build_number = options[:build_number] || generate_build_number
build_release(marketing_version: marketing_version, build_number: build_number)
upload_to_testflight(
ipa: lane_context[SharedValues::IPA_OUTPUT_PATH],
skip_submission: true,
skip_waiting_for_build_processing: true,
changelog: release_notes,
distribute_external: false,
)
end
#### Private ####
desc "Builds the signed App Store ipa"
private_lane :build_release do |options|
marketing_version = options[:marketing_version]
build_number = options[:build_number]
UI.user_error!("'build_number' parameter is missing") if build_number.to_s.empty?
if is_ci
%w[APPSTORECONNECT_KEY_ID APPSTORECONNECT_KEY_ISSUER_ID APPSTORECONNECT_KEY_CONTENT].each do |key|
UI.user_error!("'#{key}' environment variable should be set to use this lane") if ENV[key].to_s.empty?
end
app_store_connect_api_key(
key_id: ENV["APPSTORECONNECT_KEY_ID"],
issuer_id: ENV["APPSTORECONNECT_KEY_ISSUER_ID"],
key_content: ENV["APPSTORECONNECT_KEY_CONTENT"]
)
else
UI.user_error!("'APPLE_ID' environment variable should be set to use this lane") if ENV["APPLE_ID"].to_s.empty?
end
team_id = CredentialsManager::AppfileConfig.try_fetch_value(:team_id)
UI.user_error!("Failed to fetch the team id from the Appfile") if team_id.to_s.empty?
# Inject the version at build time rather than rewriting project.yml, so no tracked file ever
# carries a release-only value. All three targets resolve CFBundleShortVersionString and
# CFBundleVersion from these settings, which keeps the app and its extensions consistent —
# App Store rejects an upload where they disagree.
xcargs_hash = {
"MARKETING_VERSION" => marketing_version,
"CURRENT_PROJECT_VERSION" => build_number,
"CODE_SIGN_STYLE" => "Manual",
}
xcargs = xcargs_hash.map { |k, v| "#{k}=#{v.to_s.shellescape}" }.join(" ")
xcodegen(spec: "project.yml")
clear_derived_data(derived_data_path: ENV["DERIVED_DATA_PATH"])
download_provisioning_profiles
disable_automatic_code_signing(path: ENV["PROJECT_PATH"])
update_project_provisioning_profiles
build_app(
clean: true,
scheme: ENV["SCHEME"],
xcargs: xcargs,
export_method: "app-store",
derived_data_path: ENV["DERIVED_DATA_PATH"],
archive_path: ENV["ARCHIVE_PATH"],
output_directory: ENV["BUILD_OUTPUT_DIRECTORY"],
output_name: "#{ENV['IPA_NAME']}.ipa",
buildlog_path: ENV["BUILD_LOG_DIRECTORY"],
codesigning_identity: ENV["APPSTORE_CODESIGNING_IDENTITY"],
skip_profile_detection: true,
export_options: {
method: "app-store",
signingStyle: "manual",
teamID: team_id,
signingCertificate: ENV["APPSTORE_SIGNING_CERTIFICATE"],
provisioningProfiles: {
ENV["MAIN_BUNDLE_ID"] => ENV["APPSTORE_MAIN_PROVISIONING_PROFILE_SPECIFIER"],
ENV["NSE_BUNDLE_ID"] => ENV["APPSTORE_NSE_PROVISIONING_PROFILE_SPECIFIER"],
ENV["SHARE_EXTENSION_BUNDLE_ID"] => ENV["APPSTORE_SHARE_EXTENSION_PROVISIONING_PROFILE_SPECIFIER"],
},
},
)
end
desc "Downloads the three App Store provisioning profiles"
private_lane :download_provisioning_profiles do
{
ENV["MAIN_BUNDLE_ID"] => [ENV["APPSTORE_MAIN_PROVISIONING_PROFILE_SPECIFIER"], ENV["MAIN_PROVISIONING_PROFILE_FILENAME"]],
ENV["NSE_BUNDLE_ID"] => [ENV["APPSTORE_NSE_PROVISIONING_PROFILE_SPECIFIER"], ENV["NSE_PROVISIONING_PROFILE_FILENAME"]],
ENV["SHARE_EXTENSION_BUNDLE_ID"] => [ENV["APPSTORE_SHARE_EXTENSION_PROVISIONING_PROFILE_SPECIFIER"], ENV["SHARE_EXTENSION_PROVISIONING_PROFILE_FILENAME"]],
}.each do |bundle_id, (profile_name, filename)|
get_provisioning_profile(
app_identifier: bundle_id,
provisioning_name: profile_name,
ignore_profiles_with_different_name: true,
adhoc: false,
skip_certificate_verification: false,
output_path: ENV["PROVISIONING_PROFILES_PATH"],
filename: filename,
readonly: true,
)
end
end
desc "Points each target at its downloaded provisioning profile"
private_lane :update_project_provisioning_profiles do
{
ENV["MAIN_TARGET"] => ENV["MAIN_PROVISIONING_PROFILE_FILENAME"],
ENV["NSE_TARGET"] => ENV["NSE_PROVISIONING_PROFILE_FILENAME"],
ENV["SHARE_EXTENSION_TARGET"] => ENV["SHARE_EXTENSION_PROVISIONING_PROFILE_FILENAME"],
}.each do |target, filename|
update_project_provisioning(
xcodeproj: ENV["PROJECT_PATH"],
profile: "#{ENV['PROVISIONING_PROFILES_PATH']}#{filename}",
# Anchored: target_filter is a regex, and an unanchored "NSE" or "ElementX" would also
# match any future target containing that substring.
target_filter: "^#{target}$",
build_configuration: "Release",
)
end
end
end
# Generates a build number from the current timestamp. Monotonic across runs, so a retry never
# collides with a build number App Store Connect has already seen.
def generate_build_number
require 'date'
DateTime.now.strftime("%Y%m%d%H%M%S")
end