ios: configure api-key TestFlight signing

This commit is contained in:
2026-06-25 20:51:01 -07:00
parent fb28508764
commit fad25d7f2b
5 changed files with 207 additions and 74 deletions

View File

@@ -71,35 +71,48 @@ jobs:
brew install "${missing_tools[@]}" brew install "${missing_tools[@]}"
- name: Import code signing certificates - name: Install signing secrets
uses: Apple-Actions/import-codesign-certs@v3
with:
p12-file-base64: ${{ secrets.APPSTORE_CERTIFICATES_FILE_BASE64 }}
p12-password: ${{ secrets.APPSTORE_CERTIFICATES_PASSWORD }}
keychain: ${{ env.SIGNING_KEYCHAIN }}
- name: Create fastlane environment
working-directory: ios
env: env:
FASTLANE_USER: ${{ secrets.FASTLANE_USER }} APPSTORE_CERTIFICATES_FILE_BASE64: ${{ secrets.APPSTORE_CERTIFICATES_FILE_BASE64 }}
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: ${{ secrets.FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD }} APPSTORE_CERTIFICATES_PASSWORD: ${{ secrets.APPSTORE_CERTIFICATES_PASSWORD }}
APPSTORE_PROVISIONING_PROFILE_BASE64: ${{ secrets.APPSTORE_PROVISIONING_PROFILE_BASE64 }}
run: | run: |
set -euo pipefail set -euo pipefail
: "${FASTLANE_USER:?FASTLANE_USER secret is required}" : "${APPSTORE_CERTIFICATES_FILE_BASE64:?APPSTORE_CERTIFICATES_FILE_BASE64 secret is required}"
: "${FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD:?FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD secret is required}" : "${APPSTORE_CERTIFICATES_PASSWORD:?APPSTORE_CERTIFICATES_PASSWORD secret is required}"
: "${APPSTORE_PROVISIONING_PROFILE_BASE64:?APPSTORE_PROVISIONING_PROFILE_BASE64 secret is required}"
{ keychain_password="$(uuidgen)"
printf 'FASTLANE_USER=%s\n' "${FASTLANE_USER}" keychain_path="${HOME}/Library/Keychains/${SIGNING_KEYCHAIN}.keychain-db"
printf 'FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD=%s\n' "${FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD}" mkdir -p "${HOME}/Library/Keychains" "${HOME}/Library/MobileDevice/Provisioning Profiles" ios/build/secrets
printf 'FASTLANE_SKIP_UPDATE_CHECK=1\n'
printf 'FASTLANE_HIDE_CHANGELOG=1\n' printf '%s' "${APPSTORE_CERTIFICATES_FILE_BASE64}" | base64 --decode > ios/build/secrets/appstore-signing.p12
} > .env printf '%s' "${APPSTORE_PROVISIONING_PROFILE_BASE64}" | base64 --decode > "${HOME}/Library/MobileDevice/Provisioning Profiles/Sybil_AppStore_CI.mobileprovision"
security create-keychain -p "${keychain_password}" "${keychain_path}"
security set-keychain-settings -lut 21600 "${keychain_path}"
security unlock-keychain -p "${keychain_password}" "${keychain_path}"
security list-keychains -d user -s "${keychain_path}" $(security list-keychains -d user | sed 's/[ "]//g')
security import ios/build/secrets/appstore-signing.p12 \
-k "${keychain_path}" \
-P "${APPSTORE_CERTIFICATES_PASSWORD}" \
-T /usr/bin/codesign \
-T /usr/bin/security \
-T /usr/bin/xcodebuild
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "${keychain_password}" "${keychain_path}"
- name: Build and upload to TestFlight - name: Build and upload to TestFlight
working-directory: ios working-directory: ios
env: env:
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_API_KEY_CONTENT }}
APP_STORE_CONNECT_API_KEY_CONTENT_BASE64: "true"
FASTLANE_DONT_STORE_PASSWORD: "1" FASTLANE_DONT_STORE_PASSWORD: "1"
FASTLANE_HIDE_CHANGELOG: "1"
FASTLANE_SKIP_UPDATE_CHECK: "1"
SYBIL_PROVISIONING_PROFILE_SPECIFIER: Sybil AppStore CI
run: | run: |
set -euo pipefail set -euo pipefail
@@ -192,4 +205,4 @@ jobs:
- name: Clean up temporary keychain - name: Clean up temporary keychain
if: always() if: always()
run: | run: |
security delete-keychain "${SIGNING_KEYCHAIN}.keychain" security delete-keychain "${HOME}/Library/Keychains/${SIGNING_KEYCHAIN}.keychain-db" || true

View File

@@ -1,14 +1,12 @@
FASTLANE_APP_IDENTIFIER=net.buzzert.sybil2 FASTLANE_APP_IDENTIFIER=net.buzzert.sybil2
FASTLANE_TEAM_ID=DQQH5H6GBD FASTLANE_TEAM_ID=DQQH5H6GBD
FASTLANE_USER=you@example.com
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD=xxxx-xxxx-xxxx-xxxx
FASTLANE_SKIP_UPDATE_CHECK=1 FASTLANE_SKIP_UPDATE_CHECK=1
FASTLANE_HIDE_CHANGELOG=1 FASTLANE_HIDE_CHANGELOG=1
SYBIL_APP_STORE_APPLE_ID=6759442828 SYBIL_APP_STORE_APPLE_ID=6759442828
SYBIL_PROVIDER_PUBLIC_ID=c043d167-ad88-4036-84ea-76c223f1b1b2 SYBIL_PROVIDER_PUBLIC_ID=c043d167-ad88-4036-84ea-76c223f1b1b2
SYBIL_PROVISIONING_PROFILE_SPECIFIER=Sybil AppStore CI
# Optional App Store Connect API key settings for non-interactive upload and # App Store Connect API key settings for TestFlight upload and signing setup.
# TestFlight build-number lookup.
APP_STORE_CONNECT_API_KEY_ID= APP_STORE_CONNECT_API_KEY_ID=
APP_STORE_CONNECT_API_ISSUER_ID= APP_STORE_CONNECT_API_ISSUER_ID=
APP_STORE_CONNECT_API_KEY_PATH= APP_STORE_CONNECT_API_KEY_PATH=

View File

@@ -13,21 +13,40 @@ git tag release/v1.10.0
git push origin release/v1.10.0 git push origin release/v1.10.0
``` ```
The release job runs on the `xcode` runner label, imports the signing p12 with The release job runs on the `xcode` runner label, imports the signing p12 into
`Apple-Actions/import-codesign-certs`, builds and uploads the app with fastlane, a temporary keychain, installs the App Store provisioning profile, builds and
then creates or updates the matching Gitea release with the generated IPA as an uploads the app with fastlane, then creates or updates the matching Gitea
asset. The job deletes the temporary signing keychain in an `always()` cleanup release with the generated IPA as an asset. The job deletes the temporary
step. signing keychain in an `always()` cleanup step.
Required repository secrets: Required repository secrets:
```text ```text
APP_STORE_CONNECT_API_KEY_ID
APP_STORE_CONNECT_API_ISSUER_ID
APP_STORE_CONNECT_API_KEY_CONTENT
APPSTORE_CERTIFICATES_FILE_BASE64 APPSTORE_CERTIFICATES_FILE_BASE64
APPSTORE_CERTIFICATES_PASSWORD APPSTORE_CERTIFICATES_PASSWORD
FASTLANE_USER APPSTORE_PROVISIONING_PROFILE_BASE64
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD
``` ```
Generate or refresh the signing assets locally with:
```sh
cd ios
fastlane ios create_ci_signing
```
The generated `build/signing/ci-secrets.env` file is ignored by Git. Copy its
certificate and provisioning profile values into the repository secrets listed
above. The workflow uses the `Sybil AppStore CI` provisioning profile name by
default.
If `create_ci_signing` fails with an expired or missing agreement error, the
Apple Developer Program account holder must accept the current agreements in
App Store Connect before new certificates or provisioning profiles can be
created through the API.
The workflow uses Gitea's built-in `GITEA_TOKEN` for release creation and asset The workflow uses Gitea's built-in `GITEA_TOKEN` for release creation and asset
upload, with `contents: write` permissions. In Gitea this covers release asset upload, with `contents: write` permissions. In Gitea this covers release asset
publication. publication.

View File

@@ -1,5 +1,8 @@
require "dotenv" require "dotenv"
require "base64"
require "fileutils"
require "open3" require "open3"
require "securerandom"
require "shellwords" require "shellwords"
require "yaml" require "yaml"
@@ -11,10 +14,12 @@ APP_IDENTIFIER = ENV.fetch("FASTLANE_APP_IDENTIFIER", "net.buzzert.sybil2")
TEAM_ID = ENV.fetch("FASTLANE_TEAM_ID", "DQQH5H6GBD") TEAM_ID = ENV.fetch("FASTLANE_TEAM_ID", "DQQH5H6GBD")
APP_STORE_APPLE_ID = ENV.fetch("SYBIL_APP_STORE_APPLE_ID", "6759442828") APP_STORE_APPLE_ID = ENV.fetch("SYBIL_APP_STORE_APPLE_ID", "6759442828")
PROVIDER_PUBLIC_ID = ENV.fetch("SYBIL_PROVIDER_PUBLIC_ID", "c043d167-ad88-4036-84ea-76c223f1b1b2") PROVIDER_PUBLIC_ID = ENV.fetch("SYBIL_PROVIDER_PUBLIC_ID", "c043d167-ad88-4036-84ea-76c223f1b1b2")
PROFILE_SPECIFIER = ENV["SYBIL_PROVISIONING_PROFILE_SPECIFIER"].to_s.strip.empty? ? "Sybil AppStore CI" : ENV["SYBIL_PROVISIONING_PROFILE_SPECIFIER"]
IOS_ROOT = File.expand_path("..", __dir__) IOS_ROOT = File.expand_path("..", __dir__)
PROJECT_FILE = File.join(IOS_ROOT, "Sybil.xcodeproj") PROJECT_FILE = File.join(IOS_ROOT, "Sybil.xcodeproj")
PROJECT_SPEC = File.join(IOS_ROOT, "project.yml") PROJECT_SPEC = File.join(IOS_ROOT, "project.yml")
APP_SPEC = File.join(IOS_ROOT, "Apps/Sybil/project.yml") APP_SPEC = File.join(IOS_ROOT, "Apps/Sybil/project.yml")
SIGNING_OUTPUT_DIR = File.join(IOS_ROOT, "build/signing")
SCHEME = "Sybil" SCHEME = "Sybil"
TARGET = "SybilApp" TARGET = "SybilApp"
@@ -29,6 +34,17 @@ def capture(command)
UI.user_error!("Command failed: #{command}\n#{stderr.strip}") UI.user_error!("Command failed: #{command}\n#{stderr.strip}")
end end
def run_silent(*command, error_message:)
_stdout, stderr, status = Open3.capture3(*command)
return if status.success?
UI.user_error!("#{error_message}\n#{stderr.strip}")
end
def user_keychains
capture("security list-keychains -d user").lines.map { |line| line.strip.delete('"') }.reject(&:empty?)
end
def app_project_settings def app_project_settings
YAML.safe_load(File.read(APP_SPEC)).fetch("targets").fetch(TARGET).fetch("settings").fetch("base") YAML.safe_load(File.read(APP_SPEC)).fetch("targets").fetch(TARGET).fetch("settings").fetch("base")
end end
@@ -62,6 +78,7 @@ end
def app_store_connect_key_options def app_store_connect_key_options
key_id = ENV["APP_STORE_CONNECT_API_KEY_ID"] key_id = ENV["APP_STORE_CONNECT_API_KEY_ID"]
issuer_id = ENV["APP_STORE_CONNECT_API_ISSUER_ID"] issuer_id = ENV["APP_STORE_CONNECT_API_ISSUER_ID"]
issuer_id = ENV["APP_STORE_CONNECT_API_KEY_ISSUER_ID"] unless present?(issuer_id)
return nil unless present?(key_id) && present?(issuer_id) return nil unless present?(key_id) && present?(issuer_id)
key_path = ENV["APP_STORE_CONNECT_API_KEY_PATH"] key_path = ENV["APP_STORE_CONNECT_API_KEY_PATH"]
@@ -83,6 +100,13 @@ def app_store_connect_key_options
end end
platform :ios do platform :ios do
private_lane :load_app_store_connect_api_key do
options = app_store_connect_key_options
UI.user_error!("App Store Connect API key is required") unless options
app_store_connect_api_key(options)
end
desc "Show the version Fastlane will stamp into the next TestFlight archive" desc "Show the version Fastlane will stamp into the next TestFlight archive"
lane :version do lane :version do
UI.message("Git tag version: #{release_version}") UI.message("Git tag version: #{release_version}")
@@ -90,20 +114,101 @@ platform :ios do
UI.message("Checked-in build number: #{local_build_number}") UI.message("Checked-in build number: #{local_build_number}")
end end
desc "Create CI signing certificate/profile and write ignored secret material under build/signing"
lane :create_ci_signing do
api_key = load_app_store_connect_api_key
FileUtils.rm_rf(SIGNING_OUTPUT_DIR)
FileUtils.mkdir_p(SIGNING_OUTPUT_DIR)
keychain_path = File.join(SIGNING_OUTPUT_DIR, "sybil_ci_signing.keychain-db")
keychain_password = SecureRandom.base64(24)
p12_password = ENV["SYBIL_CI_P12_PASSWORD"].to_s
if p12_password.empty?
p12_password = SecureRandom.base64(24)
UI.important("Generated a p12 password for CI secrets.")
end
run_silent(
"security", "create-keychain", "-p", keychain_password, keychain_path,
error_message: "Could not create temporary signing keychain"
)
run_silent(
"security", "set-keychain-settings", "-lut", "21600", keychain_path,
error_message: "Could not configure temporary signing keychain"
)
run_silent(
"security", "unlock-keychain", "-p", keychain_password, keychain_path,
error_message: "Could not unlock temporary signing keychain"
)
run_silent(
"security", "list-keychains", "-d", "user", "-s", keychain_path, *user_keychains,
error_message: "Could not add temporary signing keychain to the user search list"
)
begin
cert(
api_key: api_key,
development: false,
force: true,
generate_apple_certs: true,
keychain_password: keychain_password,
keychain_path: keychain_path,
output_path: SIGNING_OUTPUT_DIR,
platform: "ios"
)
cert_id = lane_context[SharedValues::CERT_CERTIFICATE_ID]
UI.user_error!("Could not resolve generated certificate id") unless present?(cert_id)
sigh(
api_key: api_key,
app_identifier: APP_IDENTIFIER,
cert_id: cert_id,
filename: "Sybil_AppStore_CI.mobileprovision",
force: true,
output_path: SIGNING_OUTPUT_DIR,
platform: "ios",
provisioning_name: PROFILE_SPECIFIER
)
profile_path = lane_context[SharedValues::SIGH_PROFILE_PATH]
UI.user_error!("Could not resolve generated provisioning profile path") unless present?(profile_path) && File.exist?(profile_path)
p12_path = File.join(SIGNING_OUTPUT_DIR, "appstore-signing.p12")
run_silent(
"security", "export", "-k", keychain_path, "-t", "identities", "-f", "pkcs12", "-P", p12_password, "-o", p12_path,
error_message: "Could not export the CI signing identity"
)
UI.user_error!("Could not find exported p12 at #{p12_path}") unless File.exist?(p12_path)
secrets_path = File.join(SIGNING_OUTPUT_DIR, "ci-secrets.env")
File.write(
secrets_path,
[
"APPSTORE_CERTIFICATES_FILE_BASE64=#{Base64.strict_encode64(File.binread(p12_path))}",
"APPSTORE_CERTIFICATES_PASSWORD=#{p12_password}",
"APPSTORE_PROVISIONING_PROFILE_BASE64=#{Base64.strict_encode64(File.binread(profile_path))}",
"SYBIL_PROVISIONING_PROFILE_SPECIFIER=#{PROFILE_SPECIFIER}"
].join("\n") + "\n"
)
ensure
system("security", "delete-keychain", keychain_path, out: File::NULL, err: File::NULL) if File.exist?(keychain_path)
end
UI.success("Created CI signing files in #{SIGNING_OUTPUT_DIR}")
UI.important("Add the values from #{secrets_path} as repository secrets.")
end
desc "Build Sybil and upload it to TestFlight" desc "Build Sybil and upload it to TestFlight"
lane :beta do lane :beta do
version = release_version version = release_version
build_number = ENV["SYBIL_BUILD_NUMBER"].to_s build_number = ENV["SYBIL_BUILD_NUMBER"].to_s
api_key = nil api_key = load_app_store_connect_api_key
if app_store_connect_key_options
api_key = app_store_connect_api_key(app_store_connect_key_options)
end
unless present?(build_number) unless present?(build_number)
build_number = (local_build_number + 1).to_s build_number = (local_build_number + 1).to_s
if api_key
begin begin
latest = latest_testflight_build_number( latest = latest_testflight_build_number(
app_identifier: APP_IDENTIFIER, app_identifier: APP_IDENTIFIER,
@@ -117,16 +222,18 @@ platform :ios do
UI.important("Using checked-in build number + 1: #{build_number}") UI.important("Using checked-in build number + 1: #{build_number}")
end end
end end
end
UI.user_error!("Build number must be a positive integer") unless build_number.match?(/\A[1-9]\d*\z/) UI.user_error!("Build number must be a positive integer") unless build_number.match?(/\A[1-9]\d*\z/)
sh("xcodegen --spec #{PROJECT_SPEC.shellescape}") sh("xcodegen --spec #{PROJECT_SPEC.shellescape}")
xcode_args = [ xcode_args = [
"-allowProvisioningUpdates",
xcode_build_setting("MARKETING_VERSION", version), xcode_build_setting("MARKETING_VERSION", version),
xcode_build_setting("CURRENT_PROJECT_VERSION", build_number) xcode_build_setting("CURRENT_PROJECT_VERSION", build_number),
xcode_build_setting("CODE_SIGN_STYLE", "Manual"),
xcode_build_setting("DEVELOPMENT_TEAM", TEAM_ID),
xcode_build_setting("PROVISIONING_PROFILE_SPECIFIER", PROFILE_SPECIFIER),
xcode_build_setting("CODE_SIGN_IDENTITY", "Apple Distribution")
].join(" ") ].join(" ")
ipa_path = build_app( ipa_path = build_app(
@@ -138,11 +245,13 @@ platform :ios do
output_directory: File.join(IOS_ROOT, "build/fastlane"), output_directory: File.join(IOS_ROOT, "build/fastlane"),
output_name: "Sybil-#{version}-#{build_number}.ipa", output_name: "Sybil-#{version}-#{build_number}.ipa",
xcargs: xcode_args, xcargs: xcode_args,
export_xcargs: "-allowProvisioningUpdates",
export_options: { export_options: {
method: "app-store-connect", method: "app-store",
destination: "export", destination: "export",
signingStyle: "automatic", signingStyle: "manual",
provisioningProfiles: {
APP_IDENTIFIER => PROFILE_SPECIFIER
},
teamID: TEAM_ID, teamID: TEAM_ID,
manageAppVersionAndBuildNumber: false, manageAppVersionAndBuildNumber: false,
uploadSymbols: true, uploadSymbols: true,
@@ -153,25 +262,11 @@ platform :ios do
ipa_path ||= lane_context[SharedValues::IPA_OUTPUT_PATH] ipa_path ||= lane_context[SharedValues::IPA_OUTPUT_PATH]
UI.user_error!("IPA export failed; no IPA path was returned") unless present?(ipa_path) && File.exist?(ipa_path) UI.user_error!("IPA export failed; no IPA path was returned") unless present?(ipa_path) && File.exist?(ipa_path)
password = ENV["FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD"] upload_to_testflight(
UI.user_error!("FASTLANE_USER is required for altool upload") unless present?(ENV["FASTLANE_USER"]) api_key: api_key,
UI.user_error!("FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD is required for altool upload") unless present?(password) app_identifier: APP_IDENTIFIER,
UI.user_error!("SYBIL_APP_STORE_APPLE_ID is required for altool upload") unless present?(APP_STORE_APPLE_ID) ipa: ipa_path,
UI.user_error!("SYBIL_PROVIDER_PUBLIC_ID is required for altool upload") unless present?(PROVIDER_PUBLIC_ID) skip_waiting_for_build_processing: true
)
ENV["ITMS_TRANSPORTER_PASSWORD"] = password
sh([
"xcrun altool",
"--upload-package #{ipa_path.shellescape}",
"--platform ios",
"--apple-id #{APP_STORE_APPLE_ID.shellescape}",
"--bundle-id #{APP_IDENTIFIER.shellescape}",
"--bundle-version #{build_number.shellescape}",
"--bundle-short-version-string #{version.shellescape}",
"--provider-public-id #{PROVIDER_PUBLIC_ID.shellescape}",
"--username #{ENV.fetch("FASTLANE_USER").shellescape}",
"--password @env:ITMS_TRANSPORTER_PASSWORD",
"--show-progress"
].join(" "))
end end
end end

View File

@@ -23,6 +23,14 @@ For _fastlane_ installation instructions, see [Installing _fastlane_](https://do
Show the version Fastlane will stamp into the next TestFlight archive Show the version Fastlane will stamp into the next TestFlight archive
### ios create_ci_signing
```sh
[bundle exec] fastlane ios create_ci_signing
```
Create CI signing certificate/profile and write ignored secret material under build/signing
### ios beta ### ios beta
```sh ```sh