# rubocop:disable Metrics/AbcSize # rubocop:disable Metrics/MethodLength # rubocop:disable Metrics/ClassLength require 'fastlane/erb_template_helper' require 'ostruct' module Fastlane module Actions module SharedValues S3_IPA_OUTPUT_PATH = :S3_IPA_OUTPUT_PATH S3_DSYM_OUTPUT_PATH = :S3_DSYM_OUTPUT_PATH S3_PLIST_OUTPUT_PATH = :S3_PLIST_OUTPUT_PATH S3_APK_OUTPUT_PATH = :S3_APK_OUTPUT_PATH S3_HTML_OUTPUT_PATH = :S3_HTML_OUTPUT_PATH S3_VERSION_OUTPUT_PATH = :S3_VERSION_OUTPUT_PATH S3_ICON_OUTPUT_PATH = :S3_ICON_OUTPUT_PATH end S3_ARGS_MAP = { ipa: '-f', dsym: '-d', access_key: '-a', secret_access_key: '-s', bucket: '-b', region: '-r', acl: '--acl', path: '-P' } class S3Action < Action def self.run(config) platform = Actions.lane_context[Actions::SharedValues::PLATFORM_NAME].to_sym # Calling fetch on config so that default values will be used params = {} params[:ipa] = config[:ipa] params[:apk] = config[:apk] params[:icon] = config[:icon] params[:dsym] = config[:dsym] params[:access_key] = config[:access_key] params[:secret_access_key] = config[:secret_access_key] params[:bucket] = config[:bucket] params[:region] = config[:region] params[:acl] = config[:acl] params[:upload_metadata] = config[:upload_metadata] params[:plist_template_path] = config[:plist_template_path] params[:html_template_path] = config[:html_template_path] params[:html_file_name] = config[:html_file_name] params[:version_template_path] = config[:version_template_path] params[:version_file_name] = config[:version_file_name] params[:acl] = config[:acl] case platform when :ios upload_ios(params) when :android upload_android(params) end return true end def self.upload_ios(params) # Pulling parameters for other uses s3_region = params[:region] s3_subdomain = params[:region] ? "s3-#{params[:region]}" : "s3" s3_access_key = params[:access_key] s3_secret_access_key = params[:secret_access_key] s3_bucket = params[:bucket] ipa_file = params[:ipa] icon_file = params[:icon] dsym_file = params[:dsym] acl = params[:acl] validate(params) UI.user_error!("No IPA file path given, pass using `ipa: 'ipa path'`") unless ipa_file.to_s.length > 0 bucket = get_bucket(s3_access_key, s3_secret_access_key, s3_region, s3_bucket) # Gets info used for the plist info = FastlaneCore::IpaFileAnalyser.fetch_info_plist_file(ipa_file) build_number = info['CFBundleVersion'] bundle_id = info['CFBundleIdentifier'] bundle_version = info['CFBundleShortVersionString'] app_name = info['CFBundleName'] full_version = "#{bundle_version}.#{build_number}" url_part = get_url_part(app_name, "ios", bundle_version, build_number) plist_template_path = params[:plist_template_path] html_template_path = params[:html_template_path] html_file_name = params[:html_file_name] version_template_path = params[:version_template_path] version_file_name = params[:version_file_name] ipa_file_basename = File.basename(ipa_file) ipa_file_name = "#{url_part}#{ipa_file_basename}" ipa_file_data = File.open(ipa_file, 'rb') ipa_url = self.upload_file(bucket, ipa_file_name, ipa_file_data, acl) # Setting action and environment variables Actions.lane_context[SharedValues::S3_IPA_OUTPUT_PATH] = ipa_url ENV[SharedValues::S3_IPA_OUTPUT_PATH.to_s] = ipa_url if dsym_file dsym_file_basename = File.basename(dsym_file) dsym_file_name = "#{url_part}#{dsym_file_basename}" dsym_file_data = File.open(dsym_file, 'rb') dsym_url = self.upload_file(bucket, dsym_file_name, dsym_file_data, acl) # Setting action and environment variables Actions.lane_context[SharedValues::S3_DSYM_OUTPUT_PATH] = dsym_url ENV[SharedValues::S3_DSYM_OUTPUT_PATH.to_s] = dsym_url end if params[:upload_metadata] == false return true end ##################################### # # html and plist building # ##################################### # Creating plist and html names plist_file_name = "#{url_part}#{app_name.delete(' ')}.plist" plist_url = "https://#{s3_subdomain}.amazonaws.com/#{s3_bucket}/#{plist_file_name}" html_file_name ||= "#{url_part}index.html" html_resources_name = "#{url_part}installation-page" version_file_name ||= "#{url_part}version.json" # grabs module eth = Fastlane::ErbTemplateHelper # Creates plist from template if plist_template_path && File.exist?(plist_template_path) plist_template = eth.load_from_path(plist_template_path) else plist_template = eth.load("s3_plist_template") end plist_render = eth.render(plist_template, { url: ipa_url, ipa_url: ipa_url, build_number: build_number, bundle_id: bundle_id, bundle_version: bundle_version, title: app_name }) # Gets icon from ipa and uploads it icon_url = self.upload_icon(icon_file, url_part, bucket, acl) # Creates html from template if html_template_path && File.exist?(html_template_path) html_template = eth.load_from_path(html_template_path) else html_template = eth.load_from_path("#{__dir__}/../templates/installation_template.erb") end html_render = eth.render(html_template, { url: "itms-services://?action=download-manifest&url=#{plist_url}", app_version: bundle_version, build_number: build_number, title: app_name, app_icon: icon_url, platform: "ios" }) # Creates version from template if version_template_path && File.exist?(version_template_path) version_template = eth.load_from_path(version_template_path) else version_template = eth.load("s3_version_template") end version_render = eth.render(version_template, { url: plist_url, plist_url: plist_url, ipa_url: ipa_url, build_number: build_number, bundle_version: bundle_version, full_version: full_version }) ##################################### # # html and plist uploading # ##################################### plist_url = self.upload_file(bucket, plist_file_name, plist_render, acl) html_url = self.upload_file(bucket, html_file_name, html_render, acl) self.upload_directory(bucket, html_resources_name, "#{__dir__}/../templates/installation-page", acl) version_url = self.upload_file(bucket, version_file_name, version_render, acl) # Setting action and environment variables Actions.lane_context[SharedValues::S3_PLIST_OUTPUT_PATH] = plist_url ENV[SharedValues::S3_PLIST_OUTPUT_PATH.to_s] = plist_url Actions.lane_context[SharedValues::S3_HTML_OUTPUT_PATH] = html_url ENV[SharedValues::S3_HTML_OUTPUT_PATH.to_s] = html_url Actions.lane_context[SharedValues::S3_VERSION_OUTPUT_PATH] = version_url ENV[SharedValues::S3_VERSION_OUTPUT_PATH.to_s] = version_url Actions.lane_context[SharedValues::S3_ICON_OUTPUT_PATH] = icon_url ENV[SharedValues::S3_ICON_OUTPUT_PATH.to_s] = icon_url UI.success("Successfully uploaded ipa file to '#{Actions.lane_context[SharedValues::S3_IPA_OUTPUT_PATH]}'") UI.success("Successfully uploaded plist file to '#{Actions.lane_context[SharedValues::S3_PLIST_OUTPUT_PATH]}'") UI.success("Successfully uploaded html file to '#{Actions.lane_context[SharedValues::S3_HTML_OUTPUT_PATH]}'") UI.success("Successfully uploaded version file to '#{Actions.lane_context[SharedValues::S3_VERSION_OUTPUT_PATH]}'") UI.success("Successfully uploaded icon file to '#{Actions.lane_context[SharedValues::S3_ICON_OUTPUT_PATH]}'") end def self.upload_android(params) # Pulling parameters for other uses s3_region = params[:region] s3_access_key = params[:access_key] s3_secret_access_key = params[:secret_access_key] s3_bucket = params[:bucket] apk_file = params[:apk] icon_file = params[:icon] acl = params[:acl] validate(params) UI.user_error!("No APK file path given, pass using `apk: 'apk path'`") unless apk_file.to_s.length > 0 bucket = get_bucket(s3_access_key, s3_secret_access_key, s3_region, s3_bucket) # Gets info used from the apk manifest manifest = Android::Apk.new(apk_file).manifest app_name = manifest.label build_number = manifest.version_code app_version = manifest.version_name url_part = get_url_part(app_name, "android", app_version, build_number) html_template_path = params[:html_template_path] html_file_name = params[:html_file_name] apk_file_basename = File.basename(apk_file) apk_file_name = "#{url_part}#{apk_file_basename}" apk_file_data = File.open(apk_file, 'rb') apk_url = self.upload_file(bucket, apk_file_name, apk_file_data, acl) # Setting action and environment variables Actions.lane_context[SharedValues::S3_APK_OUTPUT_PATH] = apk_url ENV[SharedValues::S3_APK_OUTPUT_PATH.to_s] = apk_url ##################################### # # html and plist building # ##################################### # Creating html names html_file_name ||= "#{url_part}index.html" html_resources_name = "#{url_part}installation-page" # grabs module eth = Fastlane::ErbTemplateHelper # Gets icon from ipa and uploads it icon_url = self.upload_icon(icon_file, url_part, bucket, acl) # Creates html from template if html_template_path && File.exist?(html_template_path) html_template = eth.load_from_path(html_template_path) else html_template = eth.load_from_path("#{__dir__}/../templates/installation_template.erb") end html_render = eth.render(html_template, { url: apk_url, app_version: app_version, build_number: build_number, title: app_name, app_icon: icon_url, platform: "android" }) html_url = self.upload_file(bucket, html_file_name, html_render, acl) self.upload_directory(bucket, html_resources_name, "#{__dir__}/../templates/installation-page", acl) Actions.lane_context[SharedValues::S3_HTML_OUTPUT_PATH] = html_url ENV[SharedValues::S3_HTML_OUTPUT_PATH.to_s] = html_url Actions.lane_context[SharedValues::S3_ICON_OUTPUT_PATH] = icon_url ENV[SharedValues::S3_ICON_OUTPUT_PATH.to_s] = icon_url UI.success("Successfully uploaded apk file to '#{Actions.lane_context[SharedValues::S3_APK_OUTPUT_PATH]}'") UI.success("Successfully uploaded html file to '#{Actions.lane_context[SharedValues::S3_HTML_OUTPUT_PATH]}'") UI.success("Successfully uploaded icon file to '#{Actions.lane_context[SharedValues::S3_ICON_OUTPUT_PATH]}'") end def self.validate(params) s3_access_key = params[:access_key] s3_secret_access_key = params[:secret_access_key] s3_bucket = params[:bucket] UI.user_error!("No S3 access key given, pass using `access_key: 'key'`") unless s3_access_key.to_s.length > 0 UI.user_error!("No S3 secret access key given, pass using `secret_access_key: 'secret key'`") unless s3_secret_access_key.to_s.length > 0 UI.user_error!("No S3 bucket given, pass using `bucket: 'bucket'`") unless s3_bucket.to_s.length > 0 end def self.get_url_part(app_name, platform, app_version, build_number) "#{app_name}/#{platform}/#{app_version}_#{build_number}/" end def self.get_bucket(s3_access_key, s3_secret_access_key, s3_region, s3_bucket) self.s3_client(s3_access_key, s3_secret_access_key, s3_region).buckets[s3_bucket] end def self.s3_client(s3_access_key, s3_secret_access_key, s3_region) Actions.verify_gem!('aws-sdk') require 'aws-sdk' if s3_region s3_client = AWS::S3.new( access_key_id: s3_access_key, secret_access_key: s3_secret_access_key, region: s3_region ) else s3_client = AWS::S3.new( access_key_id: s3_access_key, secret_access_key: s3_secret_access_key ) end s3_client end def self.upload_file(bucket, file_name, file_data, acl) obj = bucket.objects.create(file_name, file_data, acl: acl) # When you enable versioning on a S3 bucket, # writing to an object will create an object version # instead of replacing the existing object. # http://docs.aws.amazon.com/AWSRubySDK/latest/AWS/S3/ObjectVersion.html if obj.kind_of? AWS::S3::ObjectVersion obj = obj.object end # Return public url obj.public_url.to_s end def self.upload_directory(bucket, directory_name, directory_path, acl) files = files_at_path(directory_path) files.each do |file| local_path = directory_path + file s3_path = directory_name + file obj = bucket.objects[s3_path] obj.write(file: local_path, content_type: content_type_for_file(local_path), acl: "public_read") end end def self.files_at_path(path) files = Dir.glob(path + "/**/*") to_remove = [] files.each do |file| if File.directory?(file) to_remove.push file else file.slice! path end end to_remove.each do |file| files.delete file end return files end def self.content_type_for_file(file) file_extension = File.extname(file) extensions_to_type = { ".html" => "text/html", ".png" => "image/png", ".jpg" => "text/jpeg", ".gif" => "image/gif", ".svg" => "image/svg", ".log" => "text/plain", ".css" => "text/css", ".js" => "application/javascript" } if extensions_to_type[file_extension].nil? "application/octet-stream" else extensions_to_type[file_extension] end end # # NOT a fan of this as this was taken straight from Shenzhen # https://github.com/nomad/shenzhen/blob/986792db5d4d16a80c865a2748ee96ba63644821/lib/shenzhen/plugins/s3.rb#L32 # # Need to find a way to not use this copied method # # AGAIN, I am not happy about this right now. # Using this for prototype reasons. # def self.expand_path_with_substitutions_from_ipa_plist(ipa, path) substitutions = path.scan(/\{CFBundle[^}]+\}/) return path if substitutions.empty? info = FastlaneCore::IpaFileAnalyser.fetch_info_plist_file(ipa) or return path substitutions.uniq.each do |substitution| key = substitution[1...-1] value = info[key] path.gsub!(Regexp.new(substitution), value) if value end return path end def self.upload_icon(icon_path, url_part, bucket, acl) return unless icon_path icon_file_basename = File.basename(icon_path) icon_file = File.open(icon_path) icon_file_name = "#{url_part}#{icon_file_basename}" self.upload_file(bucket, icon_file_name, icon_file, acl) end def self.description "Generates a plist file and uploads all to AWS S3" end def self.available_options [ FastlaneCore::ConfigItem.new(key: :ipa, env_name: "", description: ".ipa file for the build ", optional: true, default_value: Actions.lane_context[SharedValues::IPA_OUTPUT_PATH]), FastlaneCore::ConfigItem.new(key: :dsym, env_name: "", description: "zipped .dsym package for the build ", optional: true, default_value: Actions.lane_context[SharedValues::DSYM_OUTPUT_PATH]), FastlaneCore::ConfigItem.new(key: :apk, env_name: "", description: ".apk file for the build ", optional: true, default_value: Actions.lane_context[SharedValues::GRADLE_APK_OUTPUT_PATH]), FastlaneCore::ConfigItem.new(key: :icon, env_name: "", description: "app icon file to upload", optional: true, default_value: Actions.lane_context[SharedValues::ICON_OUTPUT_PATH]), FastlaneCore::ConfigItem.new(key: :upload_metadata, env_name: "", description: "Upload relevant metadata for this build", optional: true, default_value: true, is_string: false), FastlaneCore::ConfigItem.new(key: :plist_template_path, env_name: "", description: "plist template path", optional: true), FastlaneCore::ConfigItem.new(key: :html_template_path, env_name: "", description: "html erb template path", optional: true), FastlaneCore::ConfigItem.new(key: :html_file_name, env_name: "", description: "uploaded html filename", optional: true), FastlaneCore::ConfigItem.new(key: :version_template_path, env_name: "", description: "version erb template path", optional: true), FastlaneCore::ConfigItem.new(key: :version_file_name, env_name: "", description: "uploaded version filename", optional: true), FastlaneCore::ConfigItem.new(key: :access_key, env_name: "S3_ACCESS_KEY", description: "AWS Access Key ID ", optional: true, default_value: ENV['AWS_ACCESS_KEY_ID']), FastlaneCore::ConfigItem.new(key: :secret_access_key, env_name: "S3_SECRET_ACCESS_KEY", description: "AWS Secret Access Key ", optional: true, default_value: ENV['AWS_SECRET_ACCESS_KEY']), FastlaneCore::ConfigItem.new(key: :bucket, env_name: "S3_BUCKET", description: "AWS bucket name", optional: true, default_value: ENV['AWS_BUCKET_NAME']), FastlaneCore::ConfigItem.new(key: :region, env_name: "S3_REGION", description: "AWS region (for bucket creation) ", optional: true, default_value: ENV['AWS_REGION']), FastlaneCore::ConfigItem.new(key: :acl, env_name: "S3_ACL", description: "Uploaded object permissions e.g public_read (default), private, public_read_write, authenticated_read ", optional: true, default_value: "public_read") ] end def self.output [ ['S3_IPA_OUTPUT_PATH', 'Direct HTTP link to the uploaded ipa file'], ['S3_DSYM_OUTPUT_PATH', 'Direct HTTP link to the uploaded dsym file'], ['S3_PLIST_OUTPUT_PATH', 'Direct HTTP link to the uploaded plist file'], ['S3_APK_OUTPUT_PATH', 'Direct HTTP link to the uploaded apk file'], ['S3_HTML_OUTPUT_PATH', 'Direct HTTP link to the uploaded HTML file'], ['S3_VERSION_OUTPUT_PATH', 'Direct HTTP link to the uploaded Version file'], ['S3_ICON_OUTPUT_PATH', 'Direct HTTP link to the uploaded icon file'] ] end def self.author "joshdholtz" end def self.is_supported?(platform) [:ios, :android].include? platform end end end end # rubocop:enable Metrics/AbcSize # rubocop:enable Metrics/MethodLength # rubocop:enable Metrics/ClassLength