require "fileutils" require "open3" require "shellwords" require "tempfile" default_platform(:ios) BUNDLE_IDENTIFIER = "net.buzzert.QueueCube" DEVELOPMENT_TEAM = "DQQH5H6GBD" PROVISIONING_PROFILE_NAME = "QueueCube AppStore CI" CI_KEYCHAIN_NAME = "queuecube_ci_keychain" CI_KEYCHAIN_PASSWORD = "queuecube-ci-keychain-password" CI_KEYCHAIN_DB_PATH = File.expand_path("~/Library/Keychains/#{CI_KEYCHAIN_NAME}-db") APP_ROOT = File.expand_path("..", File.expand_path(__dir__)) IOS_PROJECT_DIR = File.join(APP_ROOT, "ios") XCODE_PROJECT = File.join(IOS_PROJECT_DIR, "QueueCube.xcodeproj") SCHEME = "QueueCube" ARCHIVE_PATH = File.join(IOS_PROJECT_DIR, "build", "#{SCHEME}.xcarchive") EXPORT_PATH = File.join(IOS_PROJECT_DIR, "build", "upload") def shell_command(*parts) parts.flatten.map { |part| part.to_s.shellescape }.join(" ") end def archive_path ARCHIVE_PATH end def export_path EXPORT_PATH end def git_output(*args) stdout, stderr, status = Open3.capture3("git", *args, chdir: APP_ROOT) UI.user_error!("git #{args.join(' ')} failed: #{stderr.strip}") unless status.success? stdout.strip end 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+){0,2})\z}, %r{\Av?(\d+(?:\.\d+){0,2})\z} ] patterns.each do |pattern| match = tag.to_s.match(pattern) return match[1] if match end nil end def app_version candidates = [ ENV["QUEUECUBE_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 = git_output("describe", "--tags", "--abbrev=0") version = version_from_tag(latest_tag) return version if version candidates << latest_tag UI.user_error!("Release tag must look like 3.2, v3.2, or release/ios/v3.2; got #{candidates.compact.inspect}") end def build_number value = ENV["QUEUECUBE_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 upload_export_options <<~PLIST method app-store-connect signingStyle manual teamID #{DEVELOPMENT_TEAM} signingCertificate Apple Distribution provisioningProfiles #{BUNDLE_IDENTIFIER} #{PROVISIONING_PROFILE_NAME} manageAppVersionAndBuildNumber stripSwiftSymbols uploadSymbols PLIST 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 imports the distribution identity into a # fresh keychain. Put it first because this shared runner may have locked # keychains from other projects containing the same identity. 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: BUNDLE_IDENTIFIER, team_id: DEVELOPMENT_TEAM, profile_name: PROVISIONING_PROFILE_NAME, git_url: ENV.fetch("MATCH_GIT_URL"), git_branch: ENV.fetch("MATCH_GIT_BRANCH", "master"), git_full_name: "QueueCube 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 private_lane :build_release do prepare_ci_keychain api_key = app_store_api_key version = app_version build = build_number UI.message("Using QueueCube version #{version} (build #{build}) from git") sync_signing(api_key: api_key, readonly: true) FileUtils.rm_rf(archive_path) FileUtils.rm_rf(export_path) Dir.chdir(IOS_PROJECT_DIR) do sh(shell_command( "xcodebuild", "-project", XCODE_PROJECT, "-scheme", SCHEME, "-configuration", "Release", "-destination", "generic/platform=iOS", "-archivePath", archive_path, "-allowProvisioningUpdates", "clean", "archive", "DEVELOPMENT_TEAM=#{DEVELOPMENT_TEAM}", "PRODUCT_BUNDLE_IDENTIFIER=#{BUNDLE_IDENTIFIER}", "MARKETING_VERSION=#{version}", "CURRENT_PROJECT_VERSION=#{build}", "CODE_SIGN_STYLE=Manual", "CODE_SIGN_IDENTITY=Apple Distribution", "PROVISIONING_PROFILE_SPECIFIER=#{PROVISIONING_PROFILE_NAME}" )) end export_options = Tempfile.new(["queuecube-export-options", ".plist"]) export_options.write(upload_export_options) export_options.close FileUtils.rm_rf(export_path) Dir.chdir(IOS_PROJECT_DIR) do sh(shell_command( "xcodebuild", "-exportArchive", "-archivePath", archive_path, "-exportPath", export_path, "-exportOptionsPlist", export_options.path, "-allowProvisioningUpdates" )) end ipa_path = Dir[File.join(export_path, "*.ipa")].first UI.user_error!("No IPA found in #{export_path}") unless ipa_path { api_key: api_key, ipa_path: ipa_path } ensure export_options&.unlink delete_keychain(name: CI_KEYCHAIN_NAME) if ci? && File.file?(CI_KEYCHAIN_DB_PATH) end desc "Build an App Store-signed QueueCube IPA without uploading it" lane :build do build_release end desc "Build QueueCube for iOS and upload the archive to TestFlight" lane :beta do release = build_release upload_to_testflight( api_key: release.fetch(:api_key), app_identifier: BUNDLE_IDENTIFIER, ipa: release.fetch(:ipa_path), skip_waiting_for_build_processing: true, uses_non_exempt_encryption: false ) end end