Files
Attractor/fastlane/Fastfile
James Magahern 45b09a13d3
All checks were successful
TestFlight / testflight (push) Successful in 1m27s
ci: put CI keychain first in codesign search list
codesign resolves signing identities through the user keychain search
list (first match wins) and ignores --keychain for the lookup. This
runner hosts another project (Sybil-2) whose keychain holds the same
Apple Distribution identity, so if that keychain is locked and appears
earlier in the search list, codesign fails with errSecInternalComponent
no matter how correctly our own keychain is set up. Prepend the fresh
CI keychain to the search list for the build and always delete it
afterward, which restores the original list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 13:16:41 -07:00

187 lines
5.6 KiB
Ruby

require "shellwords"
require "xcodeproj"
default_platform(:ios)
APP_IDENTIFIER = "net.buzzert.attractor"
SCHEME = "Attractor"
TARGET_NAME = "App"
TEAM_ID = ENV["FASTLANE_TEAM_ID"].to_s.strip.empty? ? "DQQH5H6GBD" : ENV["FASTLANE_TEAM_ID"]
PROFILE_NAME = ENV["ATTRACTOR_PROVISIONING_PROFILE_SPECIFIER"].to_s.strip.empty? ? "Attractor AppStore CI" : ENV["ATTRACTOR_PROVISIONING_PROFILE_SPECIFIER"]
CI_KEYCHAIN_NAME = "attractor_ci_keychain"
CI_KEYCHAIN_PASSWORD = "attractor-ci-keychain-password"
CI_KEYCHAIN_DB_PATH = File.expand_path("~/Library/Keychains/#{CI_KEYCHAIN_NAME}-db")
PROJECT_FILE = File.expand_path("../SBrowser.xcodeproj", __dir__)
def present?(value)
!value.to_s.strip.empty?
end
def ci?
present?(ENV["CI"])
end
def version_from_tag(tag)
patterns = [
%r{\Arelease/ios/v(\d+(?:\.\d+){1,2})\z},
%r{\AAttractor-(\d+(?:\.\d+){1,2})\z},
%r{\Av(\d+(?:\.\d+){1,2})\z}
]
patterns.each do |pattern|
match = tag.to_s.match(pattern)
return match[1] if match
end
nil
end
def release_version
candidates = [
ENV["ATTRACTOR_VERSION_TAG"],
ENV["GITHUB_REF_NAME"],
ENV["GITHUB_REF"].to_s.sub(%r{\Arefs/tags/}, "")
]
candidates.each do |tag|
version = version_from_tag(tag)
return version if version
end
latest_tag = sh("git describe --tags --abbrev=0").strip
version = version_from_tag(latest_tag)
return version if version
candidates << latest_tag
UI.user_error!("Release tag must look like release/ios/v4.1, Attractor-4.1, or v4.1; got #{candidates.compact.inspect}")
end
# App Store Connect requires CFBundleVersion to be unique and strictly
# increasing app-wide. Attractor already has historical builds, so use a UTC
# timestamp by default instead of assuming a fresh CI run number is high enough.
def build_number
value = ENV["ATTRACTOR_BUILD_NUMBER"]
value = Time.now.utc.strftime("%Y%m%d%H%M%S") unless present?(value)
unless value.to_s.match?(/\A\d+\z/)
UI.user_error!("Build number must be numeric; got #{value.inspect}")
end
value.to_s
end
def stamp_project_versions(version:, build:)
project = Xcodeproj::Project.open(PROJECT_FILE)
target = project.targets.find { |candidate| candidate.name == TARGET_NAME }
UI.user_error!("Could not find target #{TARGET_NAME.inspect} in #{PROJECT_FILE}") unless target
target.build_configurations.each do |configuration|
configuration.build_settings["MARKETING_VERSION"] = version
configuration.build_settings["CURRENT_PROJECT_VERSION"] = build
end
project.save
end
platform :ios do
private_lane :app_store_api_key do
app_store_connect_api_key(
key_id: ENV.fetch("APP_STORE_CONNECT_KEY_ID"),
issuer_id: ENV.fetch("APP_STORE_CONNECT_ISSUER_ID"),
key_content: ENV.fetch("APP_STORE_CONNECT_KEY_CONTENT"),
is_key_content_base64: true
)
end
# CI signs headlessly, so match needs a fresh unlocked keychain to import
# into. codesign resolves identities through the user keychain search
# list (first match wins; the --keychain flag does not restrict the
# lookup), and other projects' keychains on this runner hold the same
# identity but are usually locked — so ours must come first. delete_keychain
# in the beta lane's ensure removes both the keychain and its search-list
# entry, which also keeps our (later locked) copy from shadowing those
# other projects.
private_lane :prepare_ci_keychain do
next unless ci?
delete_keychain(name: CI_KEYCHAIN_NAME) if File.file?(CI_KEYCHAIN_DB_PATH)
create_keychain(
name: CI_KEYCHAIN_NAME,
password: CI_KEYCHAIN_PASSWORD,
unlock: true,
timeout: 3600,
add_to_search_list: false
)
others = sh("security list-keychains -d user", log: false)
.scan(/"([^"]+)"/)
.flatten
.reject { |path| path.include?(CI_KEYCHAIN_NAME) }
sh("security list-keychains -d user -s #{([CI_KEYCHAIN_DB_PATH] + others).shelljoin}")
ENV["MATCH_KEYCHAIN_NAME"] = CI_KEYCHAIN_NAME
ENV["MATCH_KEYCHAIN_PASSWORD"] = CI_KEYCHAIN_PASSWORD
end
private_lane :sync_signing do |options|
match(
type: "appstore",
readonly: options.fetch(:readonly),
app_identifier: APP_IDENTIFIER,
team_id: TEAM_ID,
profile_name: PROFILE_NAME,
git_url: ENV.fetch("MATCH_GIT_URL"),
git_branch: ENV.fetch("MATCH_GIT_BRANCH", "master"),
git_full_name: "Attractor Release Bot",
git_user_email: "james.magahern@me.com",
api_key: options.fetch(:api_key)
)
end
desc "Create or update match signing assets"
lane :setup_signing do
sync_signing(api_key: app_store_api_key, readonly: false)
end
desc "Build and upload to TestFlight"
lane :beta do
prepare_ci_keychain
api_key = app_store_api_key
version = release_version
build = build_number
stamp_project_versions(version: version, build: build)
sync_signing(api_key: api_key, readonly: true)
build_app(
project: PROJECT_FILE,
scheme: SCHEME,
destination: "generic/platform=iOS",
export_method: "app-store",
codesigning_identity: "Apple Distribution",
xcargs: [
"DEVELOPMENT_TEAM=#{TEAM_ID.shellescape}",
"CODE_SIGN_STYLE=Manual",
"CODE_SIGN_IDENTITY=Apple\\ Distribution",
"PROVISIONING_PROFILE_SPECIFIER=#{PROFILE_NAME.shellescape}"
].join(" "),
export_options: {
signingStyle: "manual",
teamID: TEAM_ID,
provisioningProfiles: {
APP_IDENTIFIER => PROFILE_NAME
}
}
)
upload_to_testflight(
api_key: api_key,
skip_waiting_for_build_processing: true
)
ensure
delete_keychain(name: CI_KEYCHAIN_NAME) if ci? && File.file?(CI_KEYCHAIN_DB_PATH)
end
end