From b0d4f032456be0dec49a08c3258de8a262e1a65c Mon Sep 17 00:00:00 2001 From: Dominik Kapusta Date: Fri, 27 Sep 2024 18:00:56 +0200 Subject: [PATCH] Implement StartNewReleaseAction up to updating Asana tasks --- Gemfile | 6 - fastlane-plugin-ddg_apple_automation.gemspec | 1 + .../actions/start_new_release_action.rb | 71 ++++---- .../helper/asana_helper.rb | 74 ++++++++ .../helper/ddg_apple_automation_helper.rb | 158 ++++++++++++++++++ 5 files changed, 265 insertions(+), 45 deletions(-) diff --git a/Gemfile b/Gemfile index 36dc2d7..fc7fa4a 100644 --- a/Gemfile +++ b/Gemfile @@ -21,12 +21,6 @@ gem 'rubocop-require_tools' # SimpleCov is a code coverage analysis tool for Ruby. gem 'simplecov' -gem 'asana' -gem 'climate_control' -gem 'httparty' - -gem 'octokit' - gemspec plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile') diff --git a/fastlane-plugin-ddg_apple_automation.gemspec b/fastlane-plugin-ddg_apple_automation.gemspec index ac917d1..3aedf01 100644 --- a/fastlane-plugin-ddg_apple_automation.gemspec +++ b/fastlane-plugin-ddg_apple_automation.gemspec @@ -25,4 +25,5 @@ Gem::Specification.new do |spec| spec.add_dependency('climate_control') spec.add_dependency('httpparty') spec.add_dependency('octokit') + spec.add_dependency('semantic') end diff --git a/lib/fastlane/plugin/ddg_apple_automation/actions/start_new_release_action.rb b/lib/fastlane/plugin/ddg_apple_automation/actions/start_new_release_action.rb index 082a547..97cb5d6 100644 --- a/lib/fastlane/plugin/ddg_apple_automation/actions/start_new_release_action.rb +++ b/lib/fastlane/plugin/ddg_apple_automation/actions/start_new_release_action.rb @@ -1,6 +1,7 @@ require "fastlane/action" require "fastlane_core/configuration/config_item" require_relative "../helper/asana_helper" +require_relative "../helper/ddg_apple_automation_helper" require_relative "../helper/git_helper" require_relative "../helper/github_actions_helper" @@ -9,49 +10,19 @@ module Actions class StartNewReleaseAction < Action def self.run(params) Helper::GitHelper.setup_git_user + params[:platform] ||= Actions.lane_context[Actions::SharedValues::PLATFORM_NAME] + params[:asana_user_id] = Helper::AsanaHelper.get_asana_user_id_for_github_handle(params[:github_handle]) - # macos_codefreeze_prechecks - # new_version = validate_new_version(options) - # macos_create_release_branch(version: new_version) - # macos_update_embedded_files - # macos_update_version_config(version: new_version) - # sh('git', 'push') - # sh("echo \"release_branch_name=#{RELEASE_BRANCH}/#{new_version}\" >> $GITHUB_OUTPUT") if is_ci + release_branch_name, new_version = create_release_branch(params) + params[:version] = new_version + params[:release_branch_name] = release_branch_name - # - name: Get Asana user ID - # id: get-asana-user-id - # shell: bash - # run: bundle exec fastlane run asana_get_user_id_for_github_handle github_handle:"${{ github.actor }}" + Helper::AsanaHelper.create_release_task(params[:platform], params[:version], params[:asana_user_id], params[:asana_access_token]) - # - name: Create release task - # id: create_release_task - # env: - # ASANA_ACCESS_TOKEN: ${{ secrets.ASANA_ACCESS_TOKEN }} - # ASSIGNEE_ID: ${{ steps.get-asana-user-id.outputs.asana_user_id }} - # run: | - # version="$(echo ${{ steps.make_release_branch.outputs.release_branch_name }} | cut -d '/' -f 2)" - # task_name="macOS App Release $version" - # asana_task_id="$(curl -fLSs -X POST "https://app.asana.com/api/1.0/task_templates/${{ vars.MACOS_RELEASE_TASK_TEMPLATE_ID }}/instantiateTask" \ - # -H "Authorization: Bearer ${{ env.ASANA_ACCESS_TOKEN }}" \ - # -H "Content-Type: application/json" \ - # -d "{ \"data\": { \"name\": \"$task_name\" }}" \ - # | jq -r .data.new_task.gid)" - # echo "marketing_version=${version}" >> $GITHUB_OUTPUT - # echo "asana_task_id=${asana_task_id}" >> $GITHUB_OUTPUT - # echo "asana_task_url=https://app.asana.com/0/0/${asana_task_id}/f" >> $GITHUB_OUTPUT - - # curl -fLSs -X POST "https://app.asana.com/api/1.0/sections/${{ vars.MACOS_APP_DEVELOPMENT_RELEASE_SECTION_ID }}/addTask" \ - # -H "Authorization: Bearer ${{ env.ASANA_ACCESS_TOKEN }}" \ - # -H "Content-Type: application/json" \ - # --output /dev/null \ - # -d "{\"data\": {\"task\": \"${asana_task_id}\"}}" - - # curl -fLSs -X PUT "https://app.asana.com/api/1.0/tasks/${asana_task_id}" \ - # -H "Authorization: Bearer ${{ env.ASANA_ACCESS_TOKEN }}" \ - # -H "Content-Type: application/json" \ - # --output /dev/null \ - # -d "{ \"data\": { \"assignee\": \"$ASSIGNEE_ID\" }}" + update_asana_tasks_for_release(params) + end + def self.update_asana_tasks_for_release(params) # - name: Update Asana tasks for the release # env: # ASANA_ACCESS_TOKEN: ${{ secrets.ASANA_ACCESS_TOKEN }} @@ -64,6 +35,18 @@ def self.run(params) # ${{ steps.create_release_task.outputs.marketing_version }} end + def self.create_release_branch(params) + Helper::DdgAppleAutomationHelper.code_freeze_prechecks + new_version = Helper::DdgAppleAutomationHelper.validate_new_version(params[:version]) + create_release_branch(new_version) + update_embedded_files(params) + update_version_config(new_version) + other_action.push_to_git_remote + Helper::GitHubActionsHelper.set_output("release_branch_name", "#{Helper::DdgAppleAutomationHelper::RELEASE_BRANCH}/#{new_version}") + + return release_branch_name, new_version + end + def self.description "Starts a new release" end @@ -89,9 +72,19 @@ def self.details def self.available_options [ + FastlaneCore::ConfigItem.asana_access_token, + FastlaneCore::ConfigItem.platform, + FastlaneCore::ConfigItem.new(key: :version, + description: "Version number to force (calculated automatically if not provided)", + optional: true, + type: String), FastlaneCore::ConfigItem.new(key: :release_task_template_id, description: "Release task template ID", optional: true, + type: String), + FastlaneCore::ConfigItem.new(key: :github_handle, + description: "Github user handle", + optional: false, type: String) ] end diff --git a/lib/fastlane/plugin/ddg_apple_automation/helper/asana_helper.rb b/lib/fastlane/plugin/ddg_apple_automation/helper/asana_helper.rb index 5142a44..f813da2 100644 --- a/lib/fastlane/plugin/ddg_apple_automation/helper/asana_helper.rb +++ b/lib/fastlane/plugin/ddg_apple_automation/helper/asana_helper.rb @@ -1,5 +1,6 @@ require "fastlane_core/ui/ui" require "asana" +require "httparty" require_relative "ddg_apple_automation_helper" require_relative "github_actions_helper" @@ -8,10 +9,19 @@ module Fastlane module Helper class AsanaHelper + ASANA_API_URL = "https://app.asana.com/api/1.0" ASANA_APP_URL = "https://app.asana.com/0/0" ASANA_TASK_URL_REGEX = %r{https://app.asana.com/[0-9]/[0-9]+/([0-9]+)(:/f)?} ERROR_ASANA_ACCESS_TOKEN_NOT_SET = "ASANA_ACCESS_TOKEN is not set" + IOS_HOTFIX_TASK_TEMPLATE_ID = "1205352950253153" + IOS_RELEASE_TASK_TEMPLATE_ID = "1205355281110338" + MACOS_HOTFIX_TASK_TEMPLATE_ID = "1206724592377782" + MACOS_RELEASE_TASK_TEMPLATE_ID = "1206127427850447" + + IOS_APP_DEVELOPMENT_RELEASE_SECTION_ID = "1138897754570756" + MACOS_APP_DEVELOPMENT_RELEASE_SECTION_ID = "1202202395298964" + def self.asana_task_url(task_id) if task_id.to_s.empty? UI.user_error!("Task ID cannot be empty") @@ -104,6 +114,70 @@ def self.upload_file_to_asana_task(task_id, file_path, asana_access_token) end end + def self.release_template_task_id(platform, is_hotfix: false) + case platform + when "ios" + is_hotfix ? IOS_HOTFIX_TASK_TEMPLATE_ID : IOS_RELEASE_TASK_TEMPLATE_ID + when "macos" + is_hotfix ? MACOS_HOTFIX_TASK_TEMPLATE_ID : MACOS_RELEASE_TASK_TEMPLATE_ID + else + UI.user_error!("Unsupported platform: #{platform}") + end + end + + def self.release_task_name(version, platform, is_hotfix: false) + case platform + when "ios" + is_hotfix ? "iOS App Release #{version}" : "iOS App Hotfix Release #{version}" + when "macos" + is_hotfix ? "macOS App Release #{version}" : "macOS App Hotfix Release #{version}" + else + UI.user_error!("Unsupported platform: #{platform}") + end + end + + def self.release_section_id(platform) + case platform + when "ios" + IOS_APP_DEVELOPMENT_RELEASE_SECTION_ID + when "macos" + MACOS_APP_DEVELOPMENT_RELEASE_SECTION_ID + else + UI.user_error!("Unsupported platform: #{platform}") + end + end + + def self.create_release_task(platform, version, assignee_id, asana_access_token) + template_task_id = release_template_task_id(platform) + task_name = release_task_name(version, platform) + section_id = release_section_id(platform) + + # task_templates is unavailable in the Asana client so we need to use the API directly + url = ASANA_API_URL + "/task_templates/#{template_task_id}/instantiateTask" + response = HTTParty.post( + url, + headers: { 'Authorization' => "Bearer #{asana_access_token}" }, + body: { data: { name: task_name } } + ) + + if response.success? + task_id = response.parsed_response.dig('data', 'new_task', 'gid') + task_url = asana_task_url(task_id) + Helper::GitHubActionsHelper.set_output("asana_task_id", task_id) + Helper::GitHubActionsHelper.set_output("asana_task_url", task_url) + else + UI.user_error!("Failed to instantiate task from template #{template_task_id}: (#{response.code} #{response.message})") + end + + asana_client = Asana::Client.new do |c| + c.authentication(:access_token, asana_access_token) + c.default_headers("Asana-Enable" => "new_goal_memberships,new_user_task_lists") + end + + asana_client.sections.add_task_for_section(section_gid: section_id, task_gid: task_id) + asana_client.tasks.update_task(task_gid: task_id, data: { assignee: assignee_id }) + end + def self.sanitize_asana_html_notes(content) content.gsub(/\s+/, ' ') # replace multiple whitespaces with a single space .gsub(/>\s+<') # remove spaces between HTML tags diff --git a/lib/fastlane/plugin/ddg_apple_automation/helper/ddg_apple_automation_helper.rb b/lib/fastlane/plugin/ddg_apple_automation/helper/ddg_apple_automation_helper.rb index ffc4d53..1cbce31 100644 --- a/lib/fastlane/plugin/ddg_apple_automation/helper/ddg_apple_automation_helper.rb +++ b/lib/fastlane/plugin/ddg_apple_automation/helper/ddg_apple_automation_helper.rb @@ -1,5 +1,6 @@ require "fastlane_core/configuration/config_item" require "fastlane_core/ui/ui" +require "semantic" require_relative "github_actions_helper" module Fastlane @@ -10,6 +11,163 @@ class DdgAppleAutomationHelper ASANA_APP_URL = "https://app.asana.com/0/0" ASANA_TASK_URL_REGEX = %r{https://app.asana.com/[0-9]/[0-9]+/([0-9]+)(:/f)?} + DEFAULT_BRANCH = 'main' + RELEASE_BRANCH = 'release' + HOTFIX_BRANCH = 'hotfix' + + PROJECT_ROOT_FOLDER = File.dirname(File.expand_path(__dir__)) + INFO_PLIST = File.join(PROJECT_ROOT_FOLDER, 'DuckDuckGo/Info.plist') + VERSION_CONFIG_PATH = File.join(PROJECT_ROOT_FOLDER, 'Configuration/Version.xcconfig') + BUILD_NUMBER_CONFIG_PATH = File.join(PROJECT_ROOT_FOLDER, 'Configuration/BuildNumber.xcconfig') + VERSION_CONFIG_DEFINITION = 'MARKETING_VERSION' + BUILD_NUMBER_CONFIG_DEFINITION = 'CURRENT_PROJECT_VERSION' + + UPGRADABLE_EMBEDDED_FILES = { + ios: Set.new([ + 'Core/AppPrivacyConfigurationDataProvider.swift', + 'Core/AppTrackerDataSetProvider.swift', + 'Core/ios-config.json', + 'Core/trackerData.json' + ]), + macos: Set.new([ + 'DuckDuckGo/ContentBlocker/AppPrivacyConfigurationDataProvider.swift', + 'DuckDuckGo/ContentBlocker/AppTrackerDataSetProvider.swift', + 'DuckDuckGo/ContentBlocker/trackerData.json', + 'DuckDuckGo/ContentBlocker/macos-config.json' + ]) + }.freeze + + def self.code_freeze_prechecks + other_action.ensure_git_status_clean + other_action.ensure_git_branch(branch: DEFAULT_BRANCH) + other_action.git_pull + + other_action.git_submodule_update(recursive: true, init: true) + other_action.ensure_git_status_clean + end + + def self.validate_new_version(version) + current_version = current_version() + user_version = format_version(version) + new_version = user_version.nil? ? bump_minor_version(current_version) : user_version + + UI.important("Current version in project settings is #{current_version}.") + UI.important("New version is #{new_version}.") + + if UI.interactive? && !UI.confirm("Do you want to continue?") + UI.abort_with_message!('Aborted by user.') + end + new_version + end + + def self.format_version(version) + user_version = nil + + unless version.nil? + version_numbers = version.split('.') + version_numbers[3] = 0 + version_numbers.map! { |element| element.nil? ? 0 : element } + user_version = "#{version_numbers[0]}.#{version_numbers[1]}.#{version_numbers[2]}" + end + + user_version + end + + # Updates version in the config file by bumping the minor (second) number + # + # @param [String] current version + # @return [String] updated version + # + def bump_minor_version(version) + Semantic::Version.new(version).increment!(:minor).to_s + end + + # Updates version in the config file by bumping the patch (third) number + # + # @param [String] current version + # @return [String] updated version + # + def bump_patch_version(version) + Semantic::Version.new(version).increment!(:patch).to_s + end + + # Reads build number from the config file + # + # @return [String] build number read from the file, or nil in case of failure + # + def self.current_build_number + current_build_number = 0 + + file_data = File.read(BUILD_NUMBER_CONFIG_PATH).split("\n") + file_data.each do |line| + current_build_number = line.split('=')[1].strip.to_i if line.start_with?(BUILD_NUMBER_CONFIG_DEFINITION) + end + + current_build_number + end + + # Updates version in the config file + # + # @return [String] version read from the file, or nil in case of failure + # + def self.current_version + current_version = nil + + file_data = File.read(VERSION_CONFIG_PATH).split("\n") + file_data.each do |line| + current_version = line.split('=')[1].strip if line.start_with?(VERSION_CONFIG_DEFINITION) + end + + current_version + end + + def self.create_release_branch(version) + UI.message("Creating new release branch for #{version}") + release_branch = "#{RELEASE_BRANCH}/#{version}" + + # Abort if the branch already exists + UI.abort_with_message!("Branch #{release_branch} already exists in this repository. Aborting.") unless Actions.sh( + 'git', 'branch', '--list', release_branch + ).empty? + + # Create the branch and push + Actions.sh('git', 'checkout', '-b', release_branch) + Actions.sh('git', 'push', '-u', 'origin', release_branch) + end + + def self.update_embedded_files(params) + Actions.sh("cd #{PROJECT_ROOT_FOLDER} && ./scripts/update_embedded.sh") + + # Verify no unexpected files were modified + git_status = Actions.sh('git', 'status') + modified_files = git_status.split("\n").select { |line| line.include?('modified:') } + modified_files = modified_files.map { |str| str.split(':')[1].strip.delete_prefix('../') } + + modified_files.each do |modified_file| + UI.abort_with_message!("Unexpected change to #{modified_file}.") unless UPGRADABLE_EMBEDDED_FILES[params[:platform]].any? do |s| + s.include?(modified_file) + end + end + + # Run tests (CI will run them separately) + # run_tests(scheme: 'DuckDuckGo Privacy Browser') unless is_ci + + # Everything looks good: commit and push + unless modified_files.empty? + modified_files.each { |modified_file| sh('git', 'add', modified_file.to_s) } + Actions.sh('git', 'commit', '-m', 'Update embedded files') + other_action.ensure_git_status_clean + end + end + + def self.update_version_config(version) + File.write(VERSION_CONFIG_PATH, "#{VERSION_CONFIG_DEFINITION} = #{version}\n") + git_commit( + path: VERSION_CONFIG_PATH, + message: "Set marketing version to #{version}" + ) + end + def self.process_erb_template(erb_file_path, args) template_content = load_file(erb_file_path) unless template_content