From fad25d7f2b26018e49e058ca535b0367b5d0a5d7 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Thu, 25 Jun 2026 20:51:01 -0700 Subject: [PATCH] ios: configure api-key TestFlight signing --- .gitea/workflows/testflight-release.yml | 53 ++++--- ios/.env.example | 6 +- ios/fastlane/CI.md | 33 ++++- ios/fastlane/Fastfile | 181 ++++++++++++++++++------ ios/fastlane/README.md | 8 ++ 5 files changed, 207 insertions(+), 74 deletions(-) diff --git a/.gitea/workflows/testflight-release.yml b/.gitea/workflows/testflight-release.yml index c39f022..2ed2ebe 100644 --- a/.gitea/workflows/testflight-release.yml +++ b/.gitea/workflows/testflight-release.yml @@ -71,35 +71,48 @@ jobs: brew install "${missing_tools[@]}" - - name: Import code signing certificates - 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 + - name: Install signing secrets env: - FASTLANE_USER: ${{ secrets.FASTLANE_USER }} - FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: ${{ secrets.FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD }} + APPSTORE_CERTIFICATES_FILE_BASE64: ${{ secrets.APPSTORE_CERTIFICATES_FILE_BASE64 }} + APPSTORE_CERTIFICATES_PASSWORD: ${{ secrets.APPSTORE_CERTIFICATES_PASSWORD }} + APPSTORE_PROVISIONING_PROFILE_BASE64: ${{ secrets.APPSTORE_PROVISIONING_PROFILE_BASE64 }} run: | set -euo pipefail - : "${FASTLANE_USER:?FASTLANE_USER secret is required}" - : "${FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD:?FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD secret is required}" + : "${APPSTORE_CERTIFICATES_FILE_BASE64:?APPSTORE_CERTIFICATES_FILE_BASE64 secret is required}" + : "${APPSTORE_CERTIFICATES_PASSWORD:?APPSTORE_CERTIFICATES_PASSWORD secret is required}" + : "${APPSTORE_PROVISIONING_PROFILE_BASE64:?APPSTORE_PROVISIONING_PROFILE_BASE64 secret is required}" - { - printf 'FASTLANE_USER=%s\n' "${FASTLANE_USER}" - printf 'FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD=%s\n' "${FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD}" - printf 'FASTLANE_SKIP_UPDATE_CHECK=1\n' - printf 'FASTLANE_HIDE_CHANGELOG=1\n' - } > .env + keychain_password="$(uuidgen)" + keychain_path="${HOME}/Library/Keychains/${SIGNING_KEYCHAIN}.keychain-db" + mkdir -p "${HOME}/Library/Keychains" "${HOME}/Library/MobileDevice/Provisioning Profiles" ios/build/secrets + + printf '%s' "${APPSTORE_CERTIFICATES_FILE_BASE64}" | base64 --decode > ios/build/secrets/appstore-signing.p12 + 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 working-directory: ios 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_HIDE_CHANGELOG: "1" + FASTLANE_SKIP_UPDATE_CHECK: "1" + SYBIL_PROVISIONING_PROFILE_SPECIFIER: Sybil AppStore CI run: | set -euo pipefail @@ -192,4 +205,4 @@ jobs: - name: Clean up temporary keychain if: always() run: | - security delete-keychain "${SIGNING_KEYCHAIN}.keychain" + security delete-keychain "${HOME}/Library/Keychains/${SIGNING_KEYCHAIN}.keychain-db" || true diff --git a/ios/.env.example b/ios/.env.example index 51ccba4..818be5b 100644 --- a/ios/.env.example +++ b/ios/.env.example @@ -1,14 +1,12 @@ FASTLANE_APP_IDENTIFIER=net.buzzert.sybil2 FASTLANE_TEAM_ID=DQQH5H6GBD -FASTLANE_USER=you@example.com -FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD=xxxx-xxxx-xxxx-xxxx FASTLANE_SKIP_UPDATE_CHECK=1 FASTLANE_HIDE_CHANGELOG=1 SYBIL_APP_STORE_APPLE_ID=6759442828 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 -# TestFlight build-number lookup. +# App Store Connect API key settings for TestFlight upload and signing setup. APP_STORE_CONNECT_API_KEY_ID= APP_STORE_CONNECT_API_ISSUER_ID= APP_STORE_CONNECT_API_KEY_PATH= diff --git a/ios/fastlane/CI.md b/ios/fastlane/CI.md index aff4a1b..ed16ba6 100644 --- a/ios/fastlane/CI.md +++ b/ios/fastlane/CI.md @@ -13,21 +13,40 @@ git tag 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 -`Apple-Actions/import-codesign-certs`, builds and uploads the app with fastlane, -then creates or updates the matching Gitea release with the generated IPA as an -asset. The job deletes the temporary signing keychain in an `always()` cleanup -step. +The release job runs on the `xcode` runner label, imports the signing p12 into +a temporary keychain, installs the App Store provisioning profile, builds and +uploads the app with fastlane, then creates or updates the matching Gitea +release with the generated IPA as an asset. The job deletes the temporary +signing keychain in an `always()` cleanup step. Required repository secrets: ```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_PASSWORD -FASTLANE_USER -FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD +APPSTORE_PROVISIONING_PROFILE_BASE64 ``` +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 upload, with `contents: write` permissions. In Gitea this covers release asset publication. diff --git a/ios/fastlane/Fastfile b/ios/fastlane/Fastfile index f344f1e..6c05a7d 100644 --- a/ios/fastlane/Fastfile +++ b/ios/fastlane/Fastfile @@ -1,5 +1,8 @@ require "dotenv" +require "base64" +require "fileutils" require "open3" +require "securerandom" require "shellwords" require "yaml" @@ -11,10 +14,12 @@ APP_IDENTIFIER = ENV.fetch("FASTLANE_APP_IDENTIFIER", "net.buzzert.sybil2") TEAM_ID = ENV.fetch("FASTLANE_TEAM_ID", "DQQH5H6GBD") 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") +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__) PROJECT_FILE = File.join(IOS_ROOT, "Sybil.xcodeproj") PROJECT_SPEC = File.join(IOS_ROOT, "project.yml") APP_SPEC = File.join(IOS_ROOT, "Apps/Sybil/project.yml") +SIGNING_OUTPUT_DIR = File.join(IOS_ROOT, "build/signing") SCHEME = "Sybil" TARGET = "SybilApp" @@ -29,6 +34,17 @@ def capture(command) UI.user_error!("Command failed: #{command}\n#{stderr.strip}") 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 YAML.safe_load(File.read(APP_SPEC)).fetch("targets").fetch(TARGET).fetch("settings").fetch("base") end @@ -62,6 +78,7 @@ end def app_store_connect_key_options 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_KEY_ISSUER_ID"] unless present?(issuer_id) return nil unless present?(key_id) && present?(issuer_id) key_path = ENV["APP_STORE_CONNECT_API_KEY_PATH"] @@ -83,6 +100,13 @@ def app_store_connect_key_options end 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" lane :version do UI.message("Git tag version: #{release_version}") @@ -90,32 +114,112 @@ platform :ios do UI.message("Checked-in build number: #{local_build_number}") 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" lane :beta do version = release_version build_number = ENV["SYBIL_BUILD_NUMBER"].to_s - api_key = nil - - if app_store_connect_key_options - api_key = app_store_connect_api_key(app_store_connect_key_options) - end + api_key = load_app_store_connect_api_key unless present?(build_number) build_number = (local_build_number + 1).to_s - if api_key - begin - latest = latest_testflight_build_number( - app_identifier: APP_IDENTIFIER, - version: version, - api_key: api_key, - initial_build_number: local_build_number - ).to_i - build_number = [latest + 1, local_build_number + 1].max.to_s - rescue StandardError => e - UI.important("Could not look up TestFlight build number: #{e.message}") - UI.important("Using checked-in build number + 1: #{build_number}") - end + begin + latest = latest_testflight_build_number( + app_identifier: APP_IDENTIFIER, + version: version, + api_key: api_key, + initial_build_number: local_build_number + ).to_i + build_number = [latest + 1, local_build_number + 1].max.to_s + rescue StandardError => e + UI.important("Could not look up TestFlight build number: #{e.message}") + UI.important("Using checked-in build number + 1: #{build_number}") end end @@ -124,9 +228,12 @@ platform :ios do sh("xcodegen --spec #{PROJECT_SPEC.shellescape}") xcode_args = [ - "-allowProvisioningUpdates", 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(" ") ipa_path = build_app( @@ -138,11 +245,13 @@ platform :ios do output_directory: File.join(IOS_ROOT, "build/fastlane"), output_name: "Sybil-#{version}-#{build_number}.ipa", xcargs: xcode_args, - export_xcargs: "-allowProvisioningUpdates", export_options: { - method: "app-store-connect", + method: "app-store", destination: "export", - signingStyle: "automatic", + signingStyle: "manual", + provisioningProfiles: { + APP_IDENTIFIER => PROFILE_SPECIFIER + }, teamID: TEAM_ID, manageAppVersionAndBuildNumber: false, uploadSymbols: true, @@ -153,25 +262,11 @@ platform :ios do 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) - password = ENV["FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD"] - UI.user_error!("FASTLANE_USER is required for altool upload") unless present?(ENV["FASTLANE_USER"]) - UI.user_error!("FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD is required for altool upload") unless present?(password) - UI.user_error!("SYBIL_APP_STORE_APPLE_ID is required for altool upload") unless present?(APP_STORE_APPLE_ID) - UI.user_error!("SYBIL_PROVIDER_PUBLIC_ID is required for altool upload") unless present?(PROVIDER_PUBLIC_ID) - - 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(" ")) + upload_to_testflight( + api_key: api_key, + app_identifier: APP_IDENTIFIER, + ipa: ipa_path, + skip_waiting_for_build_processing: true + ) end end diff --git a/ios/fastlane/README.md b/ios/fastlane/README.md index 3679069..0b44874 100644 --- a/ios/fastlane/README.md +++ b/ios/fastlane/README.md @@ -23,6 +23,14 @@ For _fastlane_ installation instructions, see [Installing _fastlane_](https://do 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 ```sh