From e137ea1077fc4d8103efb2411fc673e6023bd2ff Mon Sep 17 00:00:00 2001 From: James Magahern Date: Thu, 25 Jun 2026 21:03:43 -0700 Subject: [PATCH] ios: bootstrap signing with existing certificate --- ios/.env.example | 2 + ios/fastlane/CI.md | 6 ++ ios/fastlane/Fastfile | 222 ++++++++++++++++++++++++++++++++---------- 3 files changed, 176 insertions(+), 54 deletions(-) diff --git a/ios/.env.example b/ios/.env.example index 818be5b..40b8b20 100644 --- a/ios/.env.example +++ b/ios/.env.example @@ -5,6 +5,8 @@ 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 +SYBIL_SIGNING_CERTIFICATE_ID= +SYBIL_SIGNING_KEYCHAIN= # App Store Connect API key settings for TestFlight upload and signing setup. APP_STORE_CONNECT_API_KEY_ID= diff --git a/ios/fastlane/CI.md b/ios/fastlane/CI.md index ed16ba6..cd584b3 100644 --- a/ios/fastlane/CI.md +++ b/ios/fastlane/CI.md @@ -42,6 +42,12 @@ certificate and provisioning profile values into the repository secrets listed above. The workflow uses the `Sybil AppStore CI` provisioning profile name by default. +If the Apple team has reached the Distribution certificate limit, set +`SYBIL_SIGNING_CERTIFICATE_ID` to the portal id for a certificate whose private +key exists in the local login keychain before running `create_ci_signing`. The +lane will export the local identity and create the provisioning profile against +that existing certificate instead of creating another Distribution certificate. + 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 diff --git a/ios/fastlane/Fastfile b/ios/fastlane/Fastfile index 6c05a7d..ebc3aba 100644 --- a/ios/fastlane/Fastfile +++ b/ios/fastlane/Fastfile @@ -1,9 +1,13 @@ require "dotenv" require "base64" require "fileutils" +require "json" +require "net/http" require "open3" +require "openssl" require "securerandom" require "shellwords" +require "uri" require "yaml" Dotenv.load(File.expand_path("../.env", __dir__)) @@ -75,6 +79,114 @@ def xcode_build_setting(key, value) "#{key}=#{value.to_s.shellescape}" end +def env_line(key, value) + "#{key}=#{value.to_s.shellescape}" +end + +def base64url(value) + Base64.urlsafe_encode64(value).delete("=") +end + +def integer_to_fixed_bytes(integer, length) + hex = integer.to_s(16) + hex = "0#{hex}" if hex.length.odd? + [hex].pack("H*").rjust(length, "\0")[-length, length] +end + +def app_store_connect_private_key + key_path = ENV["APP_STORE_CONNECT_API_KEY_PATH"] + key_content = ENV["APP_STORE_CONNECT_API_KEY_CONTENT"] + + pem = if present?(key_path) + File.read(key_path) + elsif present?(key_content) + ENV["APP_STORE_CONNECT_API_KEY_CONTENT_BASE64"].to_s == "true" ? Base64.decode64(key_content) : key_content + end + UI.user_error!("App Store Connect API key content is required") unless present?(pem) + + OpenSSL::PKey::EC.new(pem) +end + +def app_store_connect_jwt + 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) + UI.user_error!("App Store Connect API key id and issuer id are required") unless present?(key_id) && present?(issuer_id) + + header = { alg: "ES256", kid: key_id, typ: "JWT" } + payload = { iss: issuer_id, iat: Time.now.to_i, exp: Time.now.to_i + 600, aud: "appstoreconnect-v1" } + unsigned = [base64url(header.to_json), base64url(payload.to_json)].join(".") + asn1_signature = app_store_connect_private_key.dsa_sign_asn1(OpenSSL::Digest::SHA256.digest(unsigned)) + signature_sequence = OpenSSL::ASN1.decode(asn1_signature) + raw_signature = signature_sequence.value.map { |part| integer_to_fixed_bytes(part.value, 32) }.join + [unsigned, base64url(raw_signature)].join(".") +end + +def app_store_connect_request(method, path, payload = nil) + uri = URI("https://api.appstoreconnect.apple.com#{path}") + request_class = Net::HTTP.const_get(method.to_s.capitalize) + request = request_class.new(uri) + request["Authorization"] = "Bearer #{app_store_connect_jwt}" + if payload + request["Content-Type"] = "application/json" + request.body = payload.to_json + end + + response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) } + return {} if response.is_a?(Net::HTTPSuccess) && response.body.to_s.empty? + return JSON.parse(response.body) if response.is_a?(Net::HTTPSuccess) + + UI.user_error!("App Store Connect API request failed: #{method.to_s.upcase} #{path}\n#{response.body}") +end + +def bundle_id_resource_id + response = app_store_connect_request( + :get, + "/v1/bundleIds?filter[identifier]=#{URI.encode_www_form_component(APP_IDENTIFIER)}&limit=1" + ) + id = response.fetch("data", []).first&.fetch("id", nil) + UI.user_error!("Could not find App Store Connect bundle id resource for #{APP_IDENTIFIER}") unless present?(id) + id +end + +def recreate_app_store_profile(certificate_id) + existing = app_store_connect_request( + :get, + "/v1/profiles?filter[name]=#{URI.encode_www_form_component(PROFILE_SPECIFIER)}&limit=200" + ) + existing.fetch("data", []).each do |profile| + app_store_connect_request(:delete, "/v1/profiles/#{profile.fetch("id")}") + end + + payload = { + data: { + type: "profiles", + attributes: { + name: PROFILE_SPECIFIER, + profileType: "IOS_APP_STORE" + }, + relationships: { + bundleId: { + data: { type: "bundleIds", id: bundle_id_resource_id } + }, + certificates: { + data: [{ type: "certificates", id: certificate_id }] + } + } + } + } + response = app_store_connect_request(:post, "/v1/profiles", payload) + profile_content = response.dig("data", "attributes", "profileContent") + UI.user_error!("App Store Connect profile response did not include profileContent") unless present?(profile_content) + + profile_path = File.join(SIGNING_OUTPUT_DIR, "Sybil_AppStore_CI.mobileprovision") + File.binwrite(profile_path, Base64.decode64(profile_content)) + install_dir = File.expand_path("~/Library/MobileDevice/Provisioning Profiles") + FileUtils.mkdir_p(install_dir) + FileUtils.cp(profile_path, File.join(install_dir, "Sybil_AppStore_CI.mobileprovision")) + profile_path +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"] @@ -121,79 +233,81 @@ platform :ios do 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) + cert_id = ENV["SYBIL_SIGNING_CERTIFICATE_ID"].to_s + keychain_path = nil + keychain_password = nil + p12_path = File.join(SIGNING_OUTPUT_DIR, "appstore-signing.p12") 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" - ) + if present?(cert_id) + UI.message("Using existing signing certificate id #{cert_id}") + export_keychain = ENV["SYBIL_SIGNING_KEYCHAIN"].to_s + export_keychain = File.expand_path("~/Library/Keychains/login.keychain-db") unless present?(export_keychain) + run_silent( + "security", "export", "-k", export_keychain, "-t", "identities", "-f", "pkcs12", "-P", p12_password, "-o", p12_path, + error_message: "Could not export the local CI signing identity" + ) + else + keychain_path = File.join(SIGNING_OUTPUT_DIR, "sybil_ci_signing.keychain-db") + keychain_password = SecureRandom.base64(24) + 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" + ) - cert_id = lane_context[SharedValues::CERT_CERTIFICATE_ID] - UI.user_error!("Could not resolve generated certificate id") unless present?(cert_id) + 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" + ) - 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 - ) + cert_id = lane_context[SharedValues::CERT_CERTIFICATE_ID] + UI.user_error!("Could not resolve generated certificate id") unless present?(cert_id) + run_silent( + "security", "export", "-k", keychain_path, "-t", "identities", "-f", "pkcs12", "-P", p12_password, "-o", p12_path, + error_message: "Could not export the generated CI signing identity" + ) + end - 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) + profile_path = recreate_app_store_profile(cert_id) + UI.user_error!("Could not resolve generated provisioning profile path") unless present?(profile_path) && File.exist?(profile_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}" + env_line("APPSTORE_CERTIFICATES_FILE_BASE64", Base64.strict_encode64(File.binread(p12_path))), + env_line("APPSTORE_CERTIFICATES_PASSWORD", p12_password), + env_line("APPSTORE_PROVISIONING_PROFILE_BASE64", Base64.strict_encode64(File.binread(profile_path))), + env_line("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) + system("security", "delete-keychain", keychain_path, out: File::NULL, err: File::NULL) if present?(keychain_path) && File.exist?(keychain_path) end UI.success("Created CI signing files in #{SIGNING_OUTPUT_DIR}")