Author SHA1 Message Date
buzzert 90233b6a21 Simplify TestFlight automation
TestFlight / Build and upload (push) Successful in 1m33s
2026-07-27 17:30:02 -07:00
buzzertandClaude Sonnet 5 45b09a13d3 ci: put CI keychain first in codesign search list
TestFlight / testflight (push) Successful in 1m27s
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
9 changed files with 134 additions and 263 deletions
+3 -14
View File
@@ -1,16 +1,5 @@
FASTLANE_SKIP_UPDATE_CHECK=1
FASTLANE_HIDE_CHANGELOG=1
FASTLANE_TEAM_ID=DQQH5H6GBD
APP_STORE_CONNECT_KEY_ID=
APP_STORE_CONNECT_ISSUER_ID=
APP_STORE_CONNECT_KEY_CONTENT=
MATCH_GIT_URL=
ASC_KEY_ID=
ASC_ISSUER_ID=
ASC_KEY=
MATCH_PASSWORD=
MATCH_GIT_BASIC_AUTHORIZATION=
MATCH_GIT_BRANCH=master
ATTRACTOR_PROVISIONING_PROFILE_SPECIFIER=Attractor AppStore CI
ATTRACTOR_VERSION_TAG=
ATTRACTOR_BUILD_NUMBER=
+11 -21
View File
@@ -1,7 +1,6 @@
name: TestFlight
on:
workflow_dispatch:
push:
tags:
- "release/ios/v*"
@@ -9,37 +8,28 @@ on:
jobs:
testflight:
runs-on: xcode
defaults:
run:
shell: bash
name: Build and upload
runs-on: macos-arm64
timeout-minutes: 90
steps:
- name: Checkout
- name: Check out the release tag
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: "3.1.7"
ruby-version: "3.3.11"
bundler-cache: true
- name: Upload to TestFlight
- name: Build and upload to TestFlight
env:
HOME: /var/lib/act_runner
APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
APP_STORE_CONNECT_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_KEY_CONTENT }}
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY: ${{ secrets.ASC_KEY }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
ATTRACTOR_VERSION_TAG: ${{ github.ref_name }}
CI: "true"
FASTLANE_SKIP_UPDATE_CHECK: "1"
FASTLANE_HIDE_CHANGELOG: "1"
FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: "120"
run: |
export PATH="/Users/runner/hostedtoolcache/Ruby/3.1.7/arm64/bin:${PATH}"
ruby --version
bundle exec fastlane ios beta
run: bundle exec fastlane ios beta
+1 -1
View File
@@ -1,3 +1,3 @@
source "https://rubygems.org"
gem "fastlane"
gem "fastlane", "2.237.0"
+75 -62
View File
@@ -1,46 +1,49 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.9)
CFPropertyList (3.0.8)
abbrev (0.1.2)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.3.2)
aws-partitions (1.1109.0)
aws-sdk-core (3.224.1)
aws-eventstream (1.4.0)
aws-partitions (1.1274.0)
aws-sdk-core (3.254.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.101.0)
aws-sdk-core (~> 3, >= 3.216.0)
aws-sdk-kms (1.130.0)
aws-sdk-core (~> 3, >= 3.254.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.188.0)
aws-sdk-core (~> 3, >= 3.224.1)
aws-sdk-s3 (1.228.1)
aws-sdk-core (~> 3, >= 3.254.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.11.0)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.2.0)
base64 (0.3.0)
benchmark (0.5.0)
bigdecimal (4.1.2)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
csv (3.3.5)
csv (3.3.6)
declarative (0.0.20)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0)
domain_name (0.6.20240107)
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (0.109.0)
excon (1.6.0)
logger
faraday (1.10.6)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
@@ -70,42 +73,45 @@ GEM
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.1)
fastlane (2.230.0)
CFPropertyList (>= 2.3, < 4.0.0)
abbrev (~> 0.1.2)
addressable (>= 2.8, < 3.0.0)
fastlane (2.237.0)
CFPropertyList (>= 2.3, < 5.0.0)
abbrev (~> 0.1)
addressable (>= 2.9.0, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.0)
aws-sdk-s3 (~> 1.197)
babosa (>= 1.0.3, < 2.0.0)
base64 (~> 0.2.0)
bundler (>= 1.12.0, < 3.0.0)
base64 (~> 0.2)
benchmark (>= 0.1.0)
bundler (>= 2.4.0, < 5.0.0)
colored (~> 1.2)
commander (~> 4.6)
csv (~> 3.3)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 1.0.0)
excon (>= 0.71.0, < 2.0.0)
faraday (~> 1.0)
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
fastlane-sirp (>= 1.0.0)
fastlane-sirp (>= 1.1.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-env (>= 1.6.0, < 2.0.0)
google-cloud-env (>= 1.6.0, < 2.3.0)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
http-cookie (~> 1.0.5)
json (< 3.0.0)
jwt (>= 2.1.0, < 3)
jwt (>= 2.10.3, < 4)
logger (>= 1.6, < 2.0)
mini_magick (>= 4.9.4, < 5.0.0)
multi_json (~> 1.12)
multipart-post (>= 2.0.0, < 3.0.0)
mutex_m (~> 0.3.0)
mutex_m (~> 0.3)
naturally (~> 2.2)
nkf (~> 0.2.0)
nkf (~> 0.2)
optparse (>= 0.1.1, < 1.0.0)
ostruct (>= 0.1.0)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.5)
@@ -120,41 +126,46 @@ GEM
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-sirp (1.1.0)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.54.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-core (0.11.3)
google-apis-androidpublisher_v3 (0.106.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-core (0.18.0)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
googleauth (~> 1.9)
httpclient (>= 2.8.3, < 3.a)
mini_mime (~> 1.0)
mutex_m
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
rexml
google-apis-iamcredentials_v1 (0.17.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-playcustomapp_v1 (0.13.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-storage_v1 (0.29.0)
google-apis-core (>= 0.11.0, < 2.a)
google-cloud-core (1.6.1)
google-apis-iamcredentials_v1 (0.28.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-playcustomapp_v1 (0.18.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-storage_v1 (0.65.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.9.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.3.1)
google-cloud-storage (1.45.0)
google-cloud-env (2.2.2)
base64 (~> 0.2)
faraday (>= 1.0, < 3.a)
google-cloud-errors (1.7.0)
google-cloud-storage (1.62.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-iamcredentials_v1 (~> 0.1)
google-apis-storage_v1 (~> 0.29.0)
google-apis-core (>= 0.18, < 2)
google-apis-iamcredentials_v1 (~> 0.18)
google-apis-storage_v1 (>= 0.42)
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
googleauth (~> 1.9)
mini_mime (~> 1.0)
googleauth (1.8.1)
faraday (>= 0.17.3, < 3.a)
jwt (>= 1.4, < 3.0)
multi_json (~> 1.11)
google-logging-utils (0.2.0)
googleauth (1.17.2)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.2)
google-logging-utils (~> 0.1)
jwt (>= 1.4, < 4.0)
os (>= 0.9, < 2.0)
pstore (~> 0.1)
signet (>= 0.16, < 2.a)
highline (2.0.3)
http-cookie (1.0.8)
@@ -162,22 +173,24 @@ GEM
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.7.6)
jwt (2.10.3)
json (2.21.1)
jwt (3.2.0)
base64
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
multi_json (1.15.0)
multi_json (1.21.1)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
naturally (2.3.0)
nkf (0.2.0)
nkf (0.3.0)
optparse (0.8.1)
os (1.1.4)
ostruct (0.6.3)
plist (3.7.2)
public_suffix (5.1.1)
pstore (0.2.1)
public_suffix (7.0.5)
rake (13.4.2)
representable (3.2.0)
declarative (< 0.1.0)
@@ -189,11 +202,10 @@ GEM
ruby2_keywords (0.0.5)
rubyzip (2.4.1)
security (0.1.5)
signet (0.18.0)
signet (0.22.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0)
multi_json (~> 1.10)
jwt (>= 1.5, < 4.0)
simctl (1.6.10)
CFPropertyList
naturally
@@ -206,15 +218,16 @@ GEM
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
uber (0.1.0)
unf (0.2.0)
unicode-display_width (2.6.0)
word_wrap (1.0.0)
xcodeproj (1.27.0)
xcodeproj (1.28.1)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
base64
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.4.0)
nkf
rexml (>= 3.3.6, < 4.0)
xcpretty (0.4.1)
rouge (~> 3.28.0)
@@ -225,7 +238,7 @@ PLATFORMS
ruby
DEPENDENCIES
fastlane
fastlane (= 2.237.0)
BUNDLED WITH
2.5.23
+2 -2
View File
@@ -30,6 +30,6 @@ Attractor operates in five "security modes":
TestFlight deployment is handled by Fastlane and the Gitea workflow at `.gitea/workflows/testflight.yml`.
Required Gitea secrets match the local `.env.example`: `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_ISSUER_ID`, `APP_STORE_CONNECT_KEY_CONTENT`, `MATCH_GIT_URL`, `MATCH_PASSWORD`, and `MATCH_GIT_BASIC_AUTHORIZATION`.
Required Gitea secrets match the local `.env.example`: `ASC_KEY_ID`, `ASC_ISSUER_ID`, `ASC_KEY`, `MATCH_PASSWORD`, and `MATCH_GIT_BASIC_AUTHORIZATION`.
Push a tag like `release/ios/v4.2` or `Attractor-4.2` to build and upload to TestFlight. To prepare signing assets locally, run `bundle exec fastlane ios setup_signing`; to upload manually, run `bundle exec fastlane ios beta`.
Push a tag like `release/ios/v4.2` or `Attractor-4.2` to build and upload to TestFlight.
+4 -4
View File
@@ -823,11 +823,11 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "App/Supporting Files/SBrowser.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 6;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 3SJALV9BQ7;
DEVELOPMENT_TEAM = DQQH5H6GBD;
INFOPLIST_FILE = "App/Supporting Files/Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = Attractor;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
@@ -839,7 +839,7 @@
MARKETING_VERSION = 4.1;
PRODUCT_BUNDLE_IDENTIFIER = net.buzzert.attractor;
PRODUCT_NAME = Attractor;
PROVISIONING_PROFILE_SPECIFIER = "";
PROVISIONING_PROFILE_SPECIFIER = "Attractor AppStore CI";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = YES;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
+2
View File
@@ -0,0 +1,2 @@
app_identifier("net.buzzert.attractor")
team_id("DQQH5H6GBD")
+28 -158
View File
@@ -1,170 +1,40 @@
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"),
desc "Build a release tag and upload it to TestFlight"
lane :beta do
setup_ci
match(type: "appstore")
tag = ENV.fetch("GITHUB_REF_NAME")
version = tag[%r{\A(?:release/ios/v|Attractor-)(\d+(?:\.\d+){1,2})\z}, 1]
UI.user_error!("Expected a tag like release/ios/v4.2 or Attractor-4.2; got #{tag.inspect}") unless version
build_number = ENV.fetch("GITHUB_RUN_NUMBER")
UI.user_error!("GITHUB_RUN_NUMBER must be a positive integer") unless build_number.match?(/\A[1-9]\d*\z/)
update_info_plist(
plist_path: "App/Supporting Files/Info.plist",
block: proc do |plist|
plist["CFBundleShortVersionString"] = version
plist["CFBundleVersion"] = build_number
end
)
api_key = app_store_connect_api_key(
key_id: ENV.fetch("ASC_KEY_ID"),
issuer_id: ENV.fetch("ASC_ISSUER_ID"),
key_content: ENV.fetch("ASC_KEY"),
is_key_content_base64: true
)
end
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: true
)
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
}
}
)
build_app(scheme: "Attractor")
upload_to_testflight(
api_key: api_key,
skip_waiting_for_build_processing: true
skip_waiting_for_build_processing: true,
uses_non_exempt_encryption: false
)
end
end
+7
View File
@@ -0,0 +1,7 @@
git_url("https://code.buzzert.dev/buzzert/fastlane-match.git")
storage_mode("git")
type("appstore")
app_identifier(["net.buzzert.attractor"])
team_id("DQQH5H6GBD")
profile_name("Attractor AppStore CI")