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? ? "3SJALV9BQ7" : 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"), 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 } } ) upload_to_testflight( api_key: api_key, skip_waiting_for_build_processing: true ) end end