From 02d98693a58a9c76cdc0425dda38ca059eecf61c Mon Sep 17 00:00:00 2001 From: Dominik Kapusta Date: Thu, 19 Sep 2024 01:16:14 +0200 Subject: [PATCH] Add TagReleaseAction (#10) Task/Issue URL: https://app.asana.com/0/1203301625297703/1208137627436548/f This change migrates tag_release.yml GHA workflow to a fastlane action. --- .../actions/asana_add_comment_action.rb | 4 + .../asana_create_action_item_action.rb | 15 +- .../actions/asana_find_release_task_action.rb | 10 +- .../actions/asana_log_message_action.rb | 46 +- .../actions/tag_release_action.rb | 254 ++++++++ .../helper/asana_helper.rb | 3 + .../helper/ddg_apple_automation_helper.rb | 23 + .../ddg_apple_automation/helper/git_helper.rb | 54 ++ .../plugin/ddg_apple_automation/version.rb | 2 +- spec/asana_create_action_item_action_spec.rb | 2 +- spec/asana_log_message_action_spec.rb | 76 ++- spec/ddg_apple_automation_helper_spec.rb | 26 + spec/tag_release_action_spec.rb | 579 ++++++++++++++++++ 13 files changed, 1029 insertions(+), 65 deletions(-) create mode 100644 lib/fastlane/plugin/ddg_apple_automation/actions/tag_release_action.rb create mode 100644 lib/fastlane/plugin/ddg_apple_automation/helper/git_helper.rb create mode 100644 spec/tag_release_action_spec.rb diff --git a/lib/fastlane/plugin/ddg_apple_automation/actions/asana_add_comment_action.rb b/lib/fastlane/plugin/ddg_apple_automation/actions/asana_add_comment_action.rb index 030300c..3cfd4b3 100644 --- a/lib/fastlane/plugin/ddg_apple_automation/actions/asana_add_comment_action.rb +++ b/lib/fastlane/plugin/ddg_apple_automation/actions/asana_add_comment_action.rb @@ -108,8 +108,12 @@ def self.validate_params(task_id, task_url, comment, template_name, workflow_url def self.create_story(asana_access_token, task_id, text: nil, html_text: nil) 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 begin + UI.important("Adding comment to task #{task_id}") + UI.important("text: #{text}") + UI.important("html_text: #{html_text}") if text client.stories.create_story_for_task(task_gid: task_id, text: text) else diff --git a/lib/fastlane/plugin/ddg_apple_automation/actions/asana_create_action_item_action.rb b/lib/fastlane/plugin/ddg_apple_automation/actions/asana_create_action_item_action.rb index 3093022..43eeb0d 100644 --- a/lib/fastlane/plugin/ddg_apple_automation/actions/asana_create_action_item_action.rb +++ b/lib/fastlane/plugin/ddg_apple_automation/actions/asana_create_action_item_action.rb @@ -18,17 +18,22 @@ def self.run(params) args = (params[:template_args] || {}).merge(Hash(ENV).transform_keys { |key| key.downcase.gsub('-', '_') }) task_id = Helper::AsanaHelper.extract_asana_task_id(task_url) - automation_subtask_id = Helper::AsanaHelper.get_release_automation_subtask_id(task_url, token) + + automation_task_id = Helper::AsanaHelper.get_release_automation_subtask_id(task_url, token) + args[:automation_task_id] = automation_task_id + assignee_id = fetch_assignee_id( task_id: task_id, github_handle: params[:github_handle], asana_access_token: token, is_scheduled_release: params[:is_scheduled_release] ) + args[:assignee_id] = assignee_id Helper::GitHubActionsHelper.set_output("asana_assignee_id", assignee_id) if (template_name = params[:template_name]) + UI.important("Adding Asana task using #{template_name} template") raw_name, raw_html_notes = process_yaml_template(template_name, args) task_name = Helper::AsanaHelper.sanitize_asana_html_notes(raw_name) @@ -36,22 +41,23 @@ def self.run(params) else task_name = params[:task_name] html_notes = params[:html_notes] + UI.important("Adding Asana task with title: #{params[:task_name]}") end begin subtask = create_subtask( token: token, - task_id: automation_subtask_id, + task_id: automation_task_id, assignee_id: assignee_id, task_name: task_name, notes: params[:notes], html_notes: html_notes ) + Helper::GitHubActionsHelper.set_output("asana_new_task_id", subtask.gid) + subtask.gid rescue StandardError => e UI.user_error!("Failed to create subtask for task: #{e}") end - - Helper::GitHubActionsHelper.set_output("asana_new_task_id", subtask.gid) if subtask&.gid end def self.fetch_assignee_id(task_id:, github_handle:, asana_access_token:, is_scheduled_release:) @@ -145,6 +151,7 @@ def self.create_subtask(token:, task_id:, assignee_id:, task_name:, notes: nil, asana_client = Asana::Client.new do |c| c.authentication(:access_token, token) + c.default_headers("Asana-Enable" => "new_goal_memberships,new_user_task_lists") end asana_client.tasks.create_subtask_for_task(**subtask_options) end diff --git a/lib/fastlane/plugin/ddg_apple_automation/actions/asana_find_release_task_action.rb b/lib/fastlane/plugin/ddg_apple_automation/actions/asana_find_release_task_action.rb index e3bf8cd..a1d80d8 100644 --- a/lib/fastlane/plugin/ddg_apple_automation/actions/asana_find_release_task_action.rb +++ b/lib/fastlane/plugin/ddg_apple_automation/actions/asana_find_release_task_action.rb @@ -85,6 +85,7 @@ def self.validate_semver(version) def self.find_release_task(version, asana_access_token) 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 release_task_id = nil @@ -171,14 +172,7 @@ def self.available_options [ FastlaneCore::ConfigItem.asana_access_token, FastlaneCore::ConfigItem.github_token, - FastlaneCore::ConfigItem.new(key: :platform, - description: "Platform (iOS or macOS) - optionally to override lane context value", - optional: true, - type: String, - verify_block: proc do |value| - UI.user_error!("platform must be equal to 'ios' or 'macos'") unless ['ios', 'macos'].include?(value.to_s) - end) - + FastlaneCore::ConfigItem.platform ] end diff --git a/lib/fastlane/plugin/ddg_apple_automation/actions/asana_log_message_action.rb b/lib/fastlane/plugin/ddg_apple_automation/actions/asana_log_message_action.rb index 23b1a2e..9c7dde5 100644 --- a/lib/fastlane/plugin/ddg_apple_automation/actions/asana_log_message_action.rb +++ b/lib/fastlane/plugin/ddg_apple_automation/actions/asana_log_message_action.rb @@ -13,35 +13,32 @@ def self.run(params) task_url = params[:task_url] template_name = params[:template_name] comment = params[:comment] - is_scheduled_release = params[:is_scheduled_release] - github_handle = params[:github_handle] - args = params[:template_args] + args = (params[:template_args] || {}).merge(Hash(ENV).transform_keys { |key| key.downcase.gsub('-', '_') }) - automation_subtask_id = Helper::AsanaHelper.get_release_automation_subtask_id(task_url, token) - - if is_scheduled_release - task_id = Helper::AsanaHelper.extract_asana_task_id(task_url) - assignee_id = Helper::AsanaHelper.extract_asana_task_assignee(task_id, token) - else - if github_handle.to_s.empty? - UI.user_error!("Github handle cannot be empty for manual release") - return - end - assignee_id = Helper::AsanaHelper.get_asana_user_id_for_github_handle(github_handle) - end + automation_task_id = Helper::AsanaHelper.get_release_automation_subtask_id(task_url, token) + args[:automation_task_id] = automation_task_id + asana_user_id = find_asana_user_id(params) + args[:assignee_id] = asana_user_id asana_client = Asana::Client.new do |c| c.authentication(:access_token, token) + c.default_headers("Asana-Enable" => "new_goal_memberships,new_user_task_lists") end begin - asana_client.tasks.add_followers_for_task(task_gid: automation_subtask_id, followers: [assignee_id]) + UI.important("Adding user #{asana_user_id} as collaborator on release task's 'Automation' subtask") + asana_client.tasks.add_followers_for_task(task_gid: automation_task_id, followers: [asana_user_id]) rescue StandardError => e - UI.user_error!("Failed to add user #{assignee_id} as collaborator on task #{automation_subtask_id}: #{e}") + UI.user_error!("Failed to add user #{asana_user_id} as collaborator on task #{automation_task_id}: #{e}") end + if template_name.to_s.empty? + UI.important("Adding comment to release task's 'Automation' subtask") + else + UI.important("Adding comment to release task's 'Automation' subtask using #{template_name} template") + end AsanaAddCommentAction.run( - task_id: automation_subtask_id, + task_id: automation_task_id, comment: comment, template_name: template_name, template_args: args, @@ -49,6 +46,19 @@ def self.run(params) ) end + def self.find_asana_user_id(params) + if params[:is_scheduled_release] + task_id = Helper::AsanaHelper.extract_asana_task_id(params[:task_url]) + Helper::AsanaHelper.extract_asana_task_assignee(task_id, params[:asana_access_token]) + else + if params[:github_handle].to_s.empty? + UI.user_error!("Github handle cannot be empty for manual release") + return + end + Helper::AsanaHelper.get_asana_user_id_for_github_handle(params[:github_handle]) + end + end + def self.description "Add a Message to Asana Release Automation Task" end diff --git a/lib/fastlane/plugin/ddg_apple_automation/actions/tag_release_action.rb b/lib/fastlane/plugin/ddg_apple_automation/actions/tag_release_action.rb new file mode 100644 index 0000000..dbae8ab --- /dev/null +++ b/lib/fastlane/plugin/ddg_apple_automation/actions/tag_release_action.rb @@ -0,0 +1,254 @@ +require "fastlane/action" +require "fastlane_core/configuration/config_item" +require "octokit" +require_relative "../helper/asana_helper" +require_relative "../helper/ddg_apple_automation_helper" +require_relative "../helper/git_helper" +require_relative "asana_extract_task_id_action" +require_relative "asana_log_message_action" +require_relative "asana_create_action_item_action" + +module Fastlane + module Actions + class TagReleaseAction < Action + @constants = {} + + def self.setup_constants(platform) + case platform + when "ios" + @constants = { + repo_name: "duckduckgo/ios" + } + when "macos" + @constants = { + dmg_url_prefix: "https://staticcdn.duckduckgo.com/macos-desktop-browser/", + repo_name: "duckduckgo/macos-browser" + } + end + end + + def self.run(params) + other_action.ensure_git_branch(branch: "^(:?release|hotfix)/.*$") + Helper::GitHelper.setup_git_user + + params[:platform] ||= Actions.lane_context[Actions::SharedValues::PLATFORM_NAME] + setup_constants(params[:platform]) + + tag_and_release_output = create_tag_and_github_release(params[:is_prerelease], params[:github_token]) + Helper::GitHubActionsHelper.set_output("tag", tag_and_release_output[:tag]) + + begin + merge_or_delete_branch(params) + tag_and_release_output[:merge_or_delete_successful] = true + rescue StandardError + tag_and_release_output[:merge_or_delete_successful] = false + end + + report_status(params.values.merge(tag_and_release_output)) + end + + def self.create_tag_and_github_release(is_prerelease, github_token) + tag, promoted_tag = Helper::DdgAppleAutomationHelper.compute_tag(is_prerelease) + + begin + other_action.add_git_tag(tag: tag) + other_action.push_git_tags(tag: tag) + rescue StandardError => e + UI.important("Failed to create and push tag: #{e}") + return { + tag: tag, + promoted_tag: promoted_tag, + tag_created: false + } + end + + begin + client = Octokit::Client.new(access_token: github_token) + latest_public_release = client.latest_release(@constants[:repo_name]) + UI.message("Latest public release: #{latest_public_release.tag_name}") + UI.message("Generating #{@constants[:repo_name]} release notes for GitHub release for tag: #{tag}") + + # Octokit doesn't provide the API to generate release notes for a specific tag + # So we need to use the GitHub API directly + generate_release_notes = other_action.github_api( + api_bearer: github_token, + http_method: "POST", + path: "/repos/#{@constants[:repo_name]}/releases/generate-notes", + body: { + tag_name: tag, + previous_tag_name: latest_public_release.tag_name + } + ) + + release_notes = JSON.parse(generate_release_notes[:body]) + + other_action.set_github_release( + repository_name: @constants[:repo_name], + api_bearer: github_token, + tag_name: tag, + name: release_notes&.dig('name'), + description: release_notes&.dig('body'), + is_prerelease: is_prerelease + ) + rescue StandardError => e + UI.important("Failed to create GitHub release: #{e}") + end + + { + tag: tag, + promoted_tag: promoted_tag, + tag_created: true, + latest_public_release_tag: latest_public_release&.tag_name + } + end + + def self.merge_or_delete_branch(params) + branch = other_action.git_branch + if params[:is_prerelease] + Helper::GitHelper.merge_branch(@constants[:repo_name], branch, params[:base_branch], params[:github_elevated_permissions_token] || params[:github_token]) + else + Helper::GitHelper.delete_branch(@constants[:repo_name], branch, params[:github_elevated_permissions_token] || params[:github_token]) + end + end + + def self.report_status(params) + template_args = self.template_arguments(params) + task_template, comment_template = setup_asana_templates(params) + + if task_template + UI.important("Adding Asana task for release automation using #{task_template} template") + template_args['task_id'] = AsanaCreateActionItemAction.run( + asana_access_token: params[:asana_access_token], + task_url: params[:asana_task_url], + template_name: task_template, + template_args: template_args, + github_handle: params[:github_handle], + is_scheduled_release: params[:is_scheduled_release] + ) + + if params[:is_internal_release_bump] && params[:platform] == "macos" + AsanaCreateActionItemAction.run( + asana_access_token: params[:asana_access_token], + task_url: params[:asana_task_url], + template_name: "run-publish-dmg-release", + template_args: template_args, + github_handle: params[:github_handle], + is_scheduled_release: params[:is_scheduled_release] + ) + end + end + + AsanaLogMessageAction.run( + asana_access_token: params[:asana_access_token], + task_url: params[:asana_task_url], + template_name: comment_template, + template_args: template_args, + github_handle: params[:github_handle], + is_scheduled_release: params[:is_scheduled_release] + ) + end + + def self.template_arguments(params) + template_args = {} + template_args['base_branch'] = params[:base_branch] + template_args['branch'] = other_action.git_branch + template_args['tag'] = params[:tag] + template_args['promoted_tag'] = params[:promoted_tag] + template_args['release_url'] = "https://github.com/#{@constants[:repo_name]}/releases/tag/#{params[:tag]}" + unless params[:tag_created] + template_args['last_release_tag'] = params[:latest_public_release_tag] + end + if params[:platform] == "macos" + dmg_version = (params[:is_prerelease] ? params[:tag] : params[:promoted_tag])&.gsub('-', '.') + template_args['dmg_url'] = "#{@constants[:dmg_url_prefix]}duckduckgo-#{dmg_version}.dmg" + end + template_args + end + + def self.setup_asana_templates(params) + if params[:merge_or_delete_successful] + comment_template = params[:is_prerelease] ? "internal-release-ready" : "public-release-tagged" + else + case [params[:tag_created], params[:is_prerelease]] + when [true, true] + task_template = "merge-failed" + comment_template = "internal-release-ready-merge-failed" + when [true, false] + task_template = "delete-branch-failed" + comment_template = "public-release-tagged-delete-branch-failed" + when [false, true] + task_template = "internal-release-tag-failed" + comment_template = "internal-release-ready-tag-failed" + when [false, false] + task_template = "public-release-tag-failed" + comment_template = "public-release-tag-failed" + end + end + + return task_template, comment_template + end + + def self.description + "Tags the release in GitHub and merges release branch to main" + end + + def self.authors + ["DuckDuckGo"] + end + + def self.return_value + "" + end + + def self.details + # Optional: + "" + end + + def self.available_options + [ + FastlaneCore::ConfigItem.asana_access_token, + FastlaneCore::ConfigItem.github_token, + FastlaneCore::ConfigItem.platform, + FastlaneCore::ConfigItem.new(key: :asana_task_url, + description: "Asana release task URL", + optional: false, + type: String), + FastlaneCore::ConfigItem.new(key: :base_branch, + description: "Base branch name (defaults to main, only override for testing)", + optional: true, + type: String, + default_value: "main"), + FastlaneCore::ConfigItem.new(key: :github_handle, + description: "Github user handle", + optional: true, + type: String), + FastlaneCore::ConfigItem.new(key: :github_elevated_permissions_token, + env_name: "GITHUB_ELEVATED_PERMISSIONS_TOKEN", + description: "GitHub token with elevated permissions (allowing to bypass branch protections)", + optional: true, + sensitive: true, + type: String), + FastlaneCore::ConfigItem.new(key: :is_internal_release_bump, + description: "Is this an internal release bump? (the subsequent internal release of the current week)", + optional: true, + type: Boolean, + default_value: false), + FastlaneCore::ConfigItem.new(key: :is_prerelease, + description: "Is this a pre-release? (a.k.a. internal release)", + optional: false, + type: Boolean), + FastlaneCore::ConfigItem.new(key: :is_scheduled_release, + description: "Indicates whether the release was scheduled or started manually", + optional: true, + type: Boolean, + default_value: false) + ] + end + + def self.is_supported?(platform) + true + end + end + end +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 f97227a..5142a44 100644 --- a/lib/fastlane/plugin/ddg_apple_automation/helper/asana_helper.rb +++ b/lib/fastlane/plugin/ddg_apple_automation/helper/asana_helper.rb @@ -33,6 +33,7 @@ def self.extract_asana_task_id(task_url) def self.extract_asana_task_assignee(task_id, asana_access_token) 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 begin @@ -57,6 +58,7 @@ def self.get_release_automation_subtask_id(task_url, asana_access_token) 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 begin @@ -91,6 +93,7 @@ def self.get_asana_user_id_for_github_handle(github_handle) def self.upload_file_to_asana_task(task_id, file_path, asana_access_token) 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 begin 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 efe1ab6..ffc4d53 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 @@ -21,6 +21,19 @@ def self.process_erb_template(erb_file_path, args) erb_template.result_with_hash(args) end + def self.compute_tag(is_prerelease) + version = File.read("Configuration/Version.xcconfig").chomp.split(" = ").last + build_number = File.read("Configuration/BuildNumber.xcconfig").chomp.split(" = ").last + if is_prerelease + tag = "#{version}-#{build_number}" + else + tag = version + promoted_tag = "#{version}-#{build_number}" + end + + return tag, promoted_tag + end + def self.path_for_asset_file(file) File.expand_path("../assets/#{file}", __dir__) end @@ -66,5 +79,15 @@ def self.github_token UI.user_error!("GITHUB_TOKEN is not set") if value.to_s.length == 0 end) end + + def self.platform + FastlaneCore::ConfigItem.new(key: :platform, + description: "Platform (iOS or macOS) - optionally to override lane context value", + optional: true, + type: String, + verify_block: proc do |value| + UI.user_error!("platform must be equal to 'ios' or 'macos'") unless ['ios', 'macos'].include?(value.to_s) + end) + end end end diff --git a/lib/fastlane/plugin/ddg_apple_automation/helper/git_helper.rb b/lib/fastlane/plugin/ddg_apple_automation/helper/git_helper.rb new file mode 100644 index 0000000..fd1df0c --- /dev/null +++ b/lib/fastlane/plugin/ddg_apple_automation/helper/git_helper.rb @@ -0,0 +1,54 @@ +require "fastlane/action" +require "fastlane_core/ui/ui" +require "octokit" + +module Fastlane + UI = FastlaneCore::UI unless Fastlane.const_defined?(:UI) + + module Helper + class GitHelper + def self.setup_git_user(name: "Dax the Duck", email: "dax@duckduckgo.com") + Actions.sh("echo \"git config --global user.name '#{name}'\"") + Actions.sh("echo \"git config --global user.email '#{email}'\"") + end + + def self.assert_main_branch(branch) + unless self.assert_branch(branch, allowed_branches: ["main"]) + UI.user_error!("Main branch required, got '#{branch}'.") + end + end + + def self.assert_release_or_hotfix_branch(branch) + unless self.assert_branch(branch, allowed_branches: [%r{release/*}, %r{hotfix/*}]) + UI.user_error!("Release or hotfix branch required, got '#{branch}'.") + end + end + + def self.assert_branch(branch, allowed_branches:) + allowed_branches.any? { |allowed_branch| allowed_branch.match?(branch) } + end + + def self.merge_branch(repo_name, branch, base_branch, github_token) + client = Octokit::Client.new(access_token: github_token) + begin + client.merge(repo_name, base_branch, branch) + UI.success("Merged #{branch} branch to #{base_branch}") + rescue StandardError => e + UI.important("Failed to merge #{branch} branch to #{base_branch}: #{e}") + raise e + end + end + + def self.delete_branch(repo_name, branch, github_token) + client = Octokit::Client.new(access_token: github_token) + begin + client.delete_branch(repo_name, branch) + UI.success("Deleted #{branch}") + rescue StandardError => e + UI.important("Failed to delete #{branch}: #{e}") + raise e + end + end + end + end +end diff --git a/lib/fastlane/plugin/ddg_apple_automation/version.rb b/lib/fastlane/plugin/ddg_apple_automation/version.rb index b6abc8a..45af7df 100644 --- a/lib/fastlane/plugin/ddg_apple_automation/version.rb +++ b/lib/fastlane/plugin/ddg_apple_automation/version.rb @@ -1,5 +1,5 @@ module Fastlane module DdgAppleAutomation - VERSION = "0.10.1" + VERSION = "0.11.0" end end diff --git a/spec/asana_create_action_item_action_spec.rb b/spec/asana_create_action_item_action_spec.rb index 11d9b68..536585d 100644 --- a/spec/asana_create_action_item_action_spec.rb +++ b/spec/asana_create_action_item_action_spec.rb @@ -17,7 +17,7 @@ allow(Fastlane::Helper::AsanaHelper).to receive(:extract_asana_task_assignee).and_return(assignee_id) allow(Fastlane::Helper::AsanaHelper).to receive(:get_release_automation_subtask_id).with(task_url, anything).and_return(automation_subtask_id) allow(Fastlane::Actions::AsanaCreateActionItemAction).to receive(:fetch_assignee_id).and_return(assignee_id) - allow(@asana_client_tasks).to receive(:create_subtask_for_task) + allow(@asana_client_tasks).to receive(:create_subtask_for_task).and_return(double('subtask', gid: "42")) allow(Fastlane::Helper::GitHubActionsHelper).to receive(:set_output) end diff --git a/spec/asana_log_message_action_spec.rb b/spec/asana_log_message_action_spec.rb index e0e4f49..0c2fa27 100644 --- a/spec/asana_log_message_action_spec.rb +++ b/spec/asana_log_message_action_spec.rb @@ -1,12 +1,12 @@ describe Fastlane::Actions::AsanaLogMessageAction do - describe "#run" do - let(:task_url) { "https://example.com" } - let(:task_id) { "1" } - let(:automation_subtask_id) { "2" } - let(:assignee_id) { "11" } - let(:comment) { "comment" } - let(:github_handle) { "user" } + let(:task_url) { "https://example.com" } + let(:task_id) { "1" } + let(:automation_subtask_id) { "2" } + let(:assignee_id) { "11" } + let(:comment) { "comment" } + let(:github_handle) { "user" } + describe "#run" do before do @asana_client_tasks = double asana_client = double("Asana::Client") @@ -14,29 +14,11 @@ allow(asana_client).to receive(:tasks).and_return(@asana_client_tasks) allow(Fastlane::Helper::AsanaHelper).to receive(:get_release_automation_subtask_id).and_return(automation_subtask_id) - allow(Fastlane::Helper::AsanaHelper).to receive(:extract_asana_task_id).and_return(task_id) - allow(Fastlane::Helper::AsanaHelper).to receive(:extract_asana_task_assignee).and_return(assignee_id) - allow(Fastlane::Helper::AsanaHelper).to receive(:get_asana_user_id_for_github_handle).and_return(assignee_id) + allow(Fastlane::Actions::AsanaLogMessageAction).to receive(:find_asana_user_id).and_return(assignee_id) allow(@asana_client_tasks).to receive(:add_followers_for_task) allow(Fastlane::Actions::AsanaAddCommentAction).to receive(:run) end - it "extracts assignee id from release task when is scheduled release" do - expect(Fastlane::Helper::AsanaHelper).to receive(:extract_asana_task_id).with(task_url) - expect(Fastlane::Helper::AsanaHelper).to receive(:extract_asana_task_assignee).with(task_id, anything) - test_action(task_url: task_url, comment: comment, is_scheduled_release: true) - end - - it "takes assignee id from github handle when is manual release" do - expect(Fastlane::Helper::AsanaHelper).to receive(:get_asana_user_id_for_github_handle).with(github_handle) - test_action(task_url: task_url, comment: comment, is_scheduled_release: false, github_handle: github_handle) - end - - it "raises an error when github handle is empty and is manual release" do - expect(Fastlane::UI).to receive(:user_error!).with("Github handle cannot be empty for manual release") - test_action(task_url: task_url, comment: comment, is_scheduled_release: false, github_handle: "") - end - it "adds an assignee as follower to the automation task" do expect(@asana_client_tasks).to receive(:add_followers_for_task).with(task_gid: automation_subtask_id, followers: [assignee_id]) test_action(task_url: task_url, comment: comment, is_scheduled_release: false, github_handle: github_handle) @@ -54,18 +36,46 @@ task_id: automation_subtask_id, comment: comment, template_name: nil, - template_args: nil, + template_args: anything, asana_access_token: anything ) test_action(task_url: task_url, comment: comment, is_scheduled_release: false, github_handle: github_handle) end + + def test_action(task_url:, github_handle: nil, comment: nil, template_name: nil, is_scheduled_release: false) + Fastlane::Actions::AsanaLogMessageAction.run(task_url: task_url, + comment: comment, + template_name: template_name, + is_scheduled_release: is_scheduled_release, + github_handle: github_handle) + end end - def test_action(task_url:, github_handle: nil, comment: nil, template_name: nil, is_scheduled_release: false) - Fastlane::Actions::AsanaLogMessageAction.run(task_url: task_url, - comment: comment, - template_name: template_name, - is_scheduled_release: is_scheduled_release, - github_handle: github_handle) + describe "#find_asana_user_id" do + before do + allow(Fastlane::Helper::AsanaHelper).to receive(:extract_asana_task_id).and_return(task_id) + allow(Fastlane::Helper::AsanaHelper).to receive(:extract_asana_task_assignee).and_return(assignee_id) + allow(Fastlane::Helper::AsanaHelper).to receive(:get_asana_user_id_for_github_handle).and_return(assignee_id) + end + + it "extracts assignee id from release task when is scheduled release" do + expect(Fastlane::Helper::AsanaHelper).to receive(:extract_asana_task_id).with(task_url) + expect(Fastlane::Helper::AsanaHelper).to receive(:extract_asana_task_assignee).with(task_id, anything) + find_asana_user_id(is_scheduled_release: true, task_url: task_url, asana_access_token: "secret-token", github_handle: github_handle) + end + + it "takes assignee id from github handle when is manual release" do + expect(Fastlane::Helper::AsanaHelper).to receive(:get_asana_user_id_for_github_handle).with(github_handle) + find_asana_user_id(is_scheduled_release: false, task_url: task_url, github_handle: github_handle) + end + + it "raises an error when github handle is empty and is manual release" do + expect(Fastlane::UI).to receive(:user_error!).with("Github handle cannot be empty for manual release") + find_asana_user_id(is_scheduled_release: false, task_url: task_url, github_handle: "") + end + + def find_asana_user_id(params) + Fastlane::Actions::AsanaLogMessageAction.find_asana_user_id(params) + end end end diff --git a/spec/ddg_apple_automation_helper_spec.rb b/spec/ddg_apple_automation_helper_spec.rb index 7fcdc78..86ccdee 100644 --- a/spec/ddg_apple_automation_helper_spec.rb +++ b/spec/ddg_apple_automation_helper_spec.rb @@ -19,6 +19,32 @@ def process_erb_template(erb_file_path, args) end end + describe "#compute_tag" do + describe "when is prerelease" do + let(:is_prerelease) { true } + + it "computes tag and returns nil promoted tag" do + allow(File).to receive(:read).with("Configuration/Version.xcconfig").and_return("MARKETING_VERSION = 1.0.0") + allow(File).to receive(:read).with("Configuration/BuildNumber.xcconfig").and_return("CURRENT_PROJECT_VERSION = 123") + expect(compute_tag(is_prerelease)).to eq(["1.0.0-123", nil]) + end + end + + describe "when is public release" do + let(:is_prerelease) { false } + + it "computes tag and promoted tag" do + allow(File).to receive(:read).with("Configuration/Version.xcconfig").and_return("MARKETING_VERSION = 1.0.0") + allow(File).to receive(:read).with("Configuration/BuildNumber.xcconfig").and_return("CURRENT_PROJECT_VERSION = 123") + expect(compute_tag(is_prerelease)).to eq(["1.0.0", "1.0.0-123"]) + end + end + + def compute_tag(is_prerelease) + Fastlane::Helper::DdgAppleAutomationHelper.compute_tag(is_prerelease) + end + end + describe "#load_file" do it "shows error if provided file does not exist" do allow(Fastlane::UI).to receive(:user_error!) diff --git a/spec/tag_release_action_spec.rb b/spec/tag_release_action_spec.rb new file mode 100644 index 0000000..2d1e792 --- /dev/null +++ b/spec/tag_release_action_spec.rb @@ -0,0 +1,579 @@ +shared_context "common setup" do + before do + @params = { + asana_access_token: "asana-token", + asana_task_url: "https://app.asana.com/0/0/1/f", + is_prerelease: true, + github_token: "github-token" + } + @tag_and_release_output = {} + allow(Fastlane::Helper).to receive(:setup_git_user) + end +end + +shared_context "on ios" do + before do + @params[:platform] = "ios" + Fastlane::Actions::TagReleaseAction.setup_constants(@params[:platform]) + end +end + +shared_context "on macos" do + before do + @params[:platform] = "macos" + Fastlane::Actions::TagReleaseAction.setup_constants(@params[:platform]) + end +end + +shared_context "for prerelease" do + before do + @params[:is_prerelease] = true + @tag = "1.1.0-123" + @promoted_tag = nil + allow(Fastlane::Helper::DdgAppleAutomationHelper).to receive(:compute_tag).and_return([@tag, @promoted_tag]) + end +end + +shared_context "for public release" do + before do + @params[:is_prerelease] = false + @tag = "1.1.0" + @promoted_tag = "1.1.0-123" + allow(Fastlane::Helper::DdgAppleAutomationHelper).to receive(:compute_tag).and_return([@tag, @promoted_tag]) + end +end + +shared_context "when merge_or_delete_successful: true" do + before { @params[:merge_or_delete_successful] = true } +end + +shared_context "when merge_or_delete_successful: false" do + before { @params[:merge_or_delete_successful] = false } +end + +shared_context "when is_prerelease: true" do + before { @params[:is_prerelease] = true } +end + +shared_context "when is_prerelease: false" do + before { @params[:is_prerelease] = false } +end + +shared_context "when tag_created: true" do + before { @params[:tag_created] = true } +end + +shared_context "when tag_created: false" do + before { @params[:tag_created] = false } +end + +describe Fastlane::Actions::TagReleaseAction do + describe "#run" do + subject do + configuration = Fastlane::ConfigurationHelper.parse(Fastlane::Actions::TagReleaseAction, @params) + Fastlane::Actions::TagReleaseAction.run(configuration) + end + + include_context "common setup" + + before do + @other_action = double(ensure_git_branch: nil) + allow(Fastlane::Action).to receive(:other_action).and_return(@other_action) + allow(Fastlane::Actions::TagReleaseAction).to receive(:create_tag_and_github_release).and_return(@tag_and_release_output) + allow(Fastlane::Actions::TagReleaseAction).to receive(:merge_or_delete_branch) + allow(Fastlane::Actions::TagReleaseAction).to receive(:report_status) + end + + it "creates tag and release, merges branch and reports status" do + subject + + expect(@tag_and_release_output[:merge_or_delete_successful]).to be_truthy + end + + context "when merge or delete failed" do + before do + allow(Fastlane::Actions::TagReleaseAction).to receive(:merge_or_delete_branch).and_raise(StandardError) + end + + it "reports status" do + subject + expect(@tag_and_release_output[:merge_or_delete_successful]).to be_falsy + end + end + end + + describe "#create_tag_and_github_release" do + subject { Fastlane::Actions::TagReleaseAction.create_tag_and_github_release(@params[:is_prerelease], @params[:github_token]) } + + let (:latest_public_release) { double(tag_name: "1.0.0") } + let (:generated_release_notes) { { body: { "name" => "1.1.0", "body" => "Release notes" } } } + let (:other_action) { double(add_git_tag: nil, push_git_tags: nil, github_api: generated_release_notes, set_github_release: nil) } + let (:octokit_client) { double(latest_release: latest_public_release) } + + shared_context "local setup" do + before(:each) do + allow(Octokit::Client).to receive(:new).and_return(octokit_client) + allow(JSON).to receive(:parse).and_return(generated_release_notes[:body]) + allow(Fastlane::Action).to receive(:other_action).and_return(other_action) + allow(Fastlane::UI).to receive(:message) + allow(Fastlane::UI).to receive(:important) + end + end + + shared_examples "successful execution" do |repo_name| + let (:repo_name) { repo_name } + + it "creates tag and github release" do + expect(subject).to eq({ + tag: @tag, + promoted_tag: @promoted_tag, + tag_created: true, + latest_public_release_tag: latest_public_release.tag_name + }) + + expect(Fastlane::UI).to have_received(:message).with("Latest public release: #{latest_public_release.tag_name}").ordered + expect(Fastlane::UI).to have_received(:message).with("Generating #{repo_name} release notes for GitHub release for tag: #{@tag}").ordered + expect(other_action).to have_received(:add_git_tag).with(tag: @tag) + expect(other_action).to have_received(:push_git_tags).with(tag: @tag) + + expect(other_action).to have_received(:github_api).with( + api_bearer: @params[:github_token], + http_method: "POST", + path: "/repos/#{repo_name}/releases/generate-notes", + body: { + tag_name: @tag, + previous_tag_name: latest_public_release.tag_name + } + ) + + expect(JSON).to have_received(:parse).with(generated_release_notes[:body]) + + expect(other_action).to have_received(:set_github_release).with( + repository_name: repo_name, + api_bearer: @params[:github_token], + tag_name: @tag, + name: generated_release_notes[:body]["name"], + description: generated_release_notes[:body]["body"], + is_prerelease: @params[:is_prerelease] + ) + expect(Fastlane::UI).not_to have_received(:important) + end + end + + shared_context "when failed to create tag" do + before do + allow(other_action).to receive(:add_git_tag).and_raise(StandardError) + end + end + + shared_context "when failed to push tag" do + before do + allow(other_action).to receive(:push_git_tags).and_raise(StandardError) + end + end + + shared_context "when failed to fetch latest GitHub release" do + before do + allow(octokit_client).to receive(:latest_release).and_raise(StandardError) + end + end + + shared_context "when failed to generate GitHub release notes" do + before do + allow(other_action).to receive(:github_api).and_raise(StandardError) + end + end + + shared_context "when failed to parse GitHub response" do + before do + allow(JSON).to receive(:parse).and_raise(StandardError) + end + end + + shared_context "when failed to create GitHub release" do + before do + allow(other_action).to receive(:set_github_release).and_raise(StandardError) + end + end + + shared_examples "gracefully handling tagging error" do + it "handles tagging error" do + expect(subject).to eq({ + tag: @tag, + promoted_tag: @promoted_tag, + tag_created: false + }) + expect(Fastlane::UI).to have_received(:important).with("Failed to create and push tag: StandardError") + end + end + + shared_examples "gracefully handling GitHub release error" do |reports_latest_public_release_tag| + let (:reports_latest_public_release_tag) { reports_latest_public_release_tag } + + it "handles GitHub release error" do + expect(subject).to eq({ + tag: @tag, + promoted_tag: @promoted_tag, + tag_created: true, + latest_public_release_tag: reports_latest_public_release_tag ? latest_public_release.tag_name : nil + }) + expect(Fastlane::UI).to have_received(:important).with("Failed to create GitHub release: StandardError") + end + end + + platform_contexts = [ + { name: "on ios", repo_name: "duckduckgo/ios" }, + { name: "on macos", repo_name: "duckduckgo/macos-browser" } + ] + release_type_contexts = ["for prerelease", "for public release"] + tag_contexts = ["when failed to create tag", "when failed to push tag"] + github_release_contexts = [ + { name: "when failed to fetch latest GitHub release", includes_latest_public_release_tag: false }, + { name: "when failed to generate GitHub release notes", includes_latest_public_release_tag: true }, + { name: "when failed to parse GitHub response", includes_latest_public_release_tag: true }, + { name: "when failed to create GitHub release", includes_latest_public_release_tag: true } + ] + + include_context "common setup" + include_context "local setup" + + platform_contexts.each do |platform_context| + context platform_context[:name] do + include_context platform_context[:name] + + release_type_contexts.each do |release_type_context| + context release_type_context do + include_context release_type_context + it_behaves_like "successful execution", platform_context[:repo_name] + + tag_contexts.each do |tag_context| + context tag_context do + include_context tag_context + it_behaves_like "gracefully handling tagging error" + end + end + + github_release_contexts.each do |github_release_context| + context github_release_context[:name] do + include_context github_release_context[:name] + it_behaves_like "gracefully handling GitHub release error", github_release_context[:includes_latest_public_release_tag] + end + end + end + end + end + end + end + + describe "#merge_or_delete_branch" do + subject { Fastlane::Actions::TagReleaseAction.merge_or_delete_branch(@params) } + + let (:branch) { "release/1.1.0" } + let (:other_action) { double(git_branch: branch) } + + platform_contexts = [ + { name: "on ios", repo_name: "duckduckgo/ios" }, + { name: "on macos", repo_name: "duckduckgo/macos-browser" } + ] + + include_context "common setup" + + before do + @params[:base_branch] = "base_branch" + allow(Fastlane::Action).to receive(:other_action).and_return(other_action) + allow(Fastlane::Helper::GitHelper).to receive(:merge_branch) + allow(Fastlane::Helper::GitHelper).to receive(:delete_branch) + end + + platform_contexts.each do |platform_context| + context platform_context[:name] do + include_context platform_context[:name] + + context "for prerelease" do + include_context "for prerelease" + + it "merges branch" do + subject + expect(other_action).to have_received(:git_branch) + expect(Fastlane::Helper::GitHelper).to have_received(:merge_branch) + .with(platform_context[:repo_name], branch, @params[:base_branch], @params[:github_token]) + end + + it "uses elevated permissions GitHub token if provided" do + @params[:github_elevated_permissions_token] = "elevated-permissions-token" + subject + expect(other_action).to have_received(:git_branch) + expect(Fastlane::Helper::GitHelper).to have_received(:merge_branch) + .with(platform_context[:repo_name], branch, @params[:base_branch], @params[:github_elevated_permissions_token]) + end + end + + context "for public release" do + include_context "for public release" + + it "deletes branch" do + subject + expect(other_action).to have_received(:git_branch) + expect(Fastlane::Helper::GitHelper).to have_received(:delete_branch) + .with(platform_context[:repo_name], branch, @params[:github_token]) + end + + it "uses elevated permissions GitHub token if provided" do + @params[:github_elevated_permissions_token] = "elevated-permissions-token" + subject + expect(other_action).to have_received(:git_branch) + expect(Fastlane::Helper::GitHelper).to have_received(:delete_branch) + .with(platform_context[:repo_name], branch, @params[:github_elevated_permissions_token]) + end + end + end + end + end + + describe "#report_status" do + subject { Fastlane::Actions::TagReleaseAction.report_status(@params) } + + let (:task_template) { "task-template" } + let (:comment_template) { "comment-template" } + let (:created_task_id) { "12345" } + let (:template_args) { {} } + + include_context "common setup" + + before do + @params[:is_internal_release_bump] = false + + allow(Fastlane::Actions::TagReleaseAction).to receive(:template_arguments).and_return(template_args) + allow(Fastlane::UI).to receive(:important) + allow(Fastlane::Actions::AsanaCreateActionItemAction).to receive(:run).and_return(created_task_id) + allow(Fastlane::Actions::AsanaLogMessageAction).to receive(:run) + end + + shared_examples "logging a message without creating Asana task" do + it "logs a message creating Asana task" do + subject + expect(Fastlane::Actions::TagReleaseAction).to have_received(:template_arguments).with(@params) + expect(Fastlane::Actions::TagReleaseAction).to have_received(:setup_asana_templates).with(@params) + expect(Fastlane::Actions::AsanaCreateActionItemAction).not_to have_received(:run) + expect(Fastlane::Actions::AsanaLogMessageAction).to have_received(:run) + end + end + + shared_examples "creating Asana task and logging a message" do + it "creates Asana task and logs a message" do + subject + expect(Fastlane::Actions::TagReleaseAction).to have_received(:template_arguments).with(@params) + expect(Fastlane::Actions::TagReleaseAction).to have_received(:setup_asana_templates).with(@params) + expect(Fastlane::Actions::AsanaCreateActionItemAction).to have_received(:run) + expect(Fastlane::Actions::AsanaLogMessageAction).to have_received(:run) + expect(template_args['task_id']).to eq(created_task_id) + end + end + + context "when task template is defined" do + before do + allow(Fastlane::Actions::TagReleaseAction).to receive(:setup_asana_templates).and_return([task_template, comment_template]) + end + + context "when internal release bump" do + before { @params[:is_internal_release_bump] = true } + + context "on ios" do + include_context "on ios" + it_behaves_like "creating Asana task and logging a message" + end + + context "on macos" do + include_context "on macos" + + it "creates 2 Asana tasks where last one uses run-publish-dmg-release template and logs a message" do + subject + expect(Fastlane::Actions::TagReleaseAction).to have_received(:template_arguments).with(@params) + expect(Fastlane::Actions::TagReleaseAction).to have_received(:setup_asana_templates).with(@params) + + calls = [] + expect(Fastlane::Actions::AsanaCreateActionItemAction).to have_received(:run).twice do |*args| + calls << args.first + end + + expect(calls.last).to include(template_name: "run-publish-dmg-release") + expect(Fastlane::Actions::AsanaLogMessageAction).to have_received(:run) + expect(template_args['task_id']).to eq(created_task_id) + end + end + end + + context "when not internal release bump" do + before { @params[:is_internal_release_bump] = false } + + ["on ios", "on macos"].each do |platform_context| + context platform_context do + include_context platform_context + it_behaves_like "creating Asana task and logging a message" + end + end + end + end + + context "when task template is not defined" do + before do + allow(Fastlane::Actions::TagReleaseAction).to receive(:setup_asana_templates).and_return([nil, comment_template]) + end + + context "when internal release bump" do + before { @params[:is_internal_release_bump] = true } + + ["on ios", "on macos"].each do |platform_context| + include_context platform_context + it_behaves_like "logging a message without creating Asana task" + end + end + + context "when not internal release bump" do + before { @params[:is_internal_release_bump] = false } + + ["on ios", "on macos"].each do |platform_context| + context platform_context do + include_context platform_context + it_behaves_like "logging a message without creating Asana task" + end + end + end + end + end + + describe "#template_arguments" do + subject { Fastlane::Actions::TagReleaseAction.template_arguments(@params) } + + include_context "common setup" + + before do + @params[:base_branch] = "base_branch" + @params[:tag] = "1.1.0" + @params[:promoted_tag] = "1.1.0-123" + @params[:latest_public_release_tag] = "1.0.0" + @params[:is_prerelease] = true + allow(Fastlane::Action).to receive(:other_action).and_return(double(git_branch: "release/1.1.0")) + end + + platform_contexts = [ + { name: "on ios", repo_name: "duckduckgo/ios" }, + { name: "on macos", repo_name: "duckduckgo/macos-browser" } + ] + + shared_examples "populating tag, promoted_tag and release_url" do |repo_name| + it "populates tag, promoted_tag and release_url when tag_created is true" do + @params[:tag_created] = true + expect(subject).to include({ + 'base_branch' => @params[:base_branch], + 'branch' => "release/1.1.0", + 'tag' => @params[:tag], + 'promoted_tag' => @params[:promoted_tag], + 'release_url' => "https://github.com/#{repo_name}/releases/tag/#{@params[:tag]}" + }) + expect(subject).not_to include("last_release_tag") + end + + it "populates last_release_tag when tag_created is false" do + @params[:tag_created] = false + expect(subject).to include({ + 'last_release_tag' => @params[:latest_public_release_tag] + }) + end + end + + platform_contexts.each do |platform_context| + context platform_context[:name] do + include_context platform_context[:name] + it_behaves_like "populating tag, promoted_tag and release_url", platform_context[:repo_name] + + case platform_context[:name] + when "on ios" + it "does not populate dmg_url" do + expect(subject).not_to include("dmg_url") + end + when "on macos" + it "populates dmg_url using tag for prerelease" do + @params[:is_prerelease] = true + expect(subject).to include({ + 'dmg_url' => "https://staticcdn.duckduckgo.com/macos-desktop-browser/duckduckgo-1.1.0.dmg" + }) + end + + it "populates dmg_url using promoted tag for public release" do + @params[:is_prerelease] = false + expect(subject).to include({ + 'dmg_url' => "https://staticcdn.duckduckgo.com/macos-desktop-browser/duckduckgo-1.1.0.123.dmg" + }) + end + end + end + end + end + + describe "#setup_asana_templates" do + subject { Fastlane::Actions::TagReleaseAction.setup_asana_templates(@params) } + + include_context "common setup" + + context "when merge_or_delete_successful: true" do + include_context "when merge_or_delete_successful: true" + + context "when is_prerelase: true" do + include_context "when is_prerelease: true" + + it "comment_template = internal-release-ready" do + expect(subject).to eq([nil, "internal-release-ready"]) + end + end + + context "when is_prerelase: false" do + include_context "when is_prerelease: false" + + it "comment_template = public-release-tagged" do + expect(subject).to eq([nil, "public-release-tagged"]) + end + end + end + + context "when merge_or_delete_successful: false" do + include_context "when merge_or_delete_successful: false" + + context "when tag_created: true, is_prerelase = true" do + include_context "when tag_created: true" + include_context "when is_prerelease: true" + + it "task_template = merge-failed, comment_template = internal-release-ready-merge-failed" do + expect(subject).to eq(["merge-failed", "internal-release-ready-merge-failed"]) + end + end + + context "when tag_created: true, is_prerelase = false" do + include_context "when tag_created: true" + include_context "when is_prerelease: false" + + it "task_template = delete-branch-failed, comment_template = public-release-tagged-delete-branch-failed" do + expect(subject).to eq(["delete-branch-failed", "public-release-tagged-delete-branch-failed"]) + end + end + + context "when tag_created: false, is_prerelase = true" do + include_context "when tag_created: false" + include_context "when is_prerelease: true" + + it "task_template = internal-release-tag-failed, comment_template = internal-release-ready-tag-failed" do + expect(subject).to eq(["internal-release-tag-failed", "internal-release-ready-tag-failed"]) + end + end + + context "when tag_created: false, is_prerelase = false" do + include_context "when tag_created: false" + include_context "when is_prerelease: false" + + it "task_template = public-release-tag-failed, comment_template = public-release-tag-failed" do + expect(subject).to eq(["public-release-tag-failed", "public-release-tag-failed"]) + end + end + end + end +end