From c4f9f418adae3f3d3e3162c708b37a2a0ad8d6e4 Mon Sep 17 00:00:00 2001 From: kshann Date: Tue, 19 Nov 2024 00:04:22 +1100 Subject: [PATCH] Start new release actions (#16) Co-authored-by: Dominik Kapusta --- Gemfile | 6 - fastlane-plugin-ddg_apple_automation.gemspec | 1 + .../asana_create_action_item_action.rb | 13 +- .../actions/asana_find_release_task_action.rb | 19 +- .../actions/asana_log_message_action.rb | 14 +- .../actions/bump_build_number_action.rb | 48 ++ .../actions/start_new_release_action.rb | 80 ++++ .../actions/tag_release_action.rb | 10 +- .../update_asana_for_release_action.rb | 86 ++++ .../validate_internal_release_bump_action.rb | 93 ++++ ...ase_announcement_task_description.html.erb | 21 + .../release_task_description.html.erb | 13 + .../helper/asana_helper.rb | 356 ++++++++++++++- .../helper/ddg_apple_automation_helper.rb | 306 ++++++++++++- .../ddg_apple_automation/helper/git_helper.rb | 28 ++ .../asana_release_notes_extractor.rb | 114 +++++ .../helper/release_task_helper.rb | 37 ++ .../plugin/ddg_apple_automation/version.rb | 2 +- spec/asana_find_release_task_action_spec.rb | 2 +- spec/asana_helper_spec.rb | 418 ++++++++++++++++++ spec/asana_release_notes_extractor_spec.rb | 327 ++++++++++++++ spec/bump_build_number_action_spec.rb | 53 +++ spec/ddg_apple_automation_helper_spec.rb | 201 +++++++++ spec/github_actions_helper_spec.rb | 38 ++ spec/release_task_helper_spec.rb | 146 ++++++ spec/start_new_release_action_spec.rb | 154 +++++++ spec/update_asana_for_release_action_spec.rb | 80 ++++ ...idate_internal_release_bump_action_spec.rb | 138 ++++++ 28 files changed, 2728 insertions(+), 76 deletions(-) create mode 100644 lib/fastlane/plugin/ddg_apple_automation/actions/bump_build_number_action.rb create mode 100644 lib/fastlane/plugin/ddg_apple_automation/actions/start_new_release_action.rb create mode 100644 lib/fastlane/plugin/ddg_apple_automation/actions/update_asana_for_release_action.rb create mode 100644 lib/fastlane/plugin/ddg_apple_automation/actions/validate_internal_release_bump_action.rb create mode 100644 lib/fastlane/plugin/ddg_apple_automation/assets/release_task_helper/templates/release_announcement_task_description.html.erb create mode 100644 lib/fastlane/plugin/ddg_apple_automation/assets/release_task_helper/templates/release_task_description.html.erb create mode 100644 lib/fastlane/plugin/ddg_apple_automation/helper/release_notes/asana_release_notes_extractor.rb create mode 100644 lib/fastlane/plugin/ddg_apple_automation/helper/release_task_helper.rb create mode 100644 spec/asana_release_notes_extractor_spec.rb create mode 100644 spec/bump_build_number_action_spec.rb create mode 100644 spec/release_task_helper_spec.rb create mode 100644 spec/start_new_release_action_spec.rb create mode 100644 spec/update_asana_for_release_action_spec.rb create mode 100644 spec/validate_internal_release_bump_action_spec.rb 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/asana_create_action_item_action.rb b/lib/fastlane/plugin/ddg_apple_automation/actions/asana_create_action_item_action.rb index 43eeb0d..7def925 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 @@ -98,6 +98,7 @@ def self.details def self.available_options [ FastlaneCore::ConfigItem.asana_access_token, + FastlaneCore::ConfigItem.is_scheduled_release, FastlaneCore::ConfigItem.new(key: :task_url, description: "Asana release task URL", optional: false, @@ -127,12 +128,7 @@ def self.available_options FastlaneCore::ConfigItem.new(key: :github_handle, description: "Github user handle", optional: true, - type: String), - FastlaneCore::ConfigItem.new(key: :is_scheduled_release, - description: "Indicates whether the release was scheduled or started manually", - optional: true, - type: Boolean, - default_value: false) + type: String) ] end @@ -149,10 +145,7 @@ def self.create_subtask(token:, task_id:, assignee_id:, task_name:, notes: nil, subtask_options[:notes] = notes unless notes.nil? subtask_options[:html_notes] = html_notes unless html_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 = Helper::AsanaHelper.make_asana_client(token) asana_client.tasks.create_subtask_for_task(**subtask_options) end 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 a1d80d8..6527491 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 @@ -5,6 +5,7 @@ require "time" require_relative "../helper/asana_helper" require_relative "../helper/ddg_apple_automation_helper" +require_relative "../helper/git_helper" require_relative "../helper/github_actions_helper" module Fastlane @@ -16,14 +17,12 @@ def self.setup_constants(platform) case platform when "ios" @constants = { - repo_name: "duckduckgo/ios", release_task_prefix: "iOS App Release", hotfix_task_prefix: "iOS App Hotfix Release", release_section_id: "1138897754570756" } when "macos" @constants = { - repo_name: "duckduckgo/macos-browser", release_task_prefix: "macOS App Release", hotfix_task_prefix: "macOS App Hotfix Release", release_section_id: "1202202395298964" @@ -37,8 +36,12 @@ def self.run(params) platform = params[:platform] || Actions.lane_context[Actions::SharedValues::PLATFORM_NAME] setup_constants(platform) - latest_marketing_version = find_latest_marketing_version(github_token) + UI.message("Checking latest marketing version") + latest_marketing_version = find_latest_marketing_version(github_token, params[:platform]) + UI.success("Latest marketing version: #{latest_marketing_version}") + UI.message("Searching for release task for version #{latest_marketing_version}") release_task_id = find_release_task(latest_marketing_version, asana_access_token) + UI.user_error!("No release task found for version #{latest_marketing_version}") unless release_task_id release_task_url = Helper::AsanaHelper.asana_task_url(release_task_id) release_branch = "release/#{latest_marketing_version}" @@ -55,11 +58,11 @@ def self.run(params) } end - def self.find_latest_marketing_version(github_token) + def self.find_latest_marketing_version(github_token, platform) client = Octokit::Client.new(access_token: github_token) # NOTE: `client.latest_release` returns release marked as "latest", i.e. a public release - latest_internal_release = client.releases(@constants[:repo_name], { per_page: 1 }).first + latest_internal_release = client.releases(Helper::GitHelper.repo_name(platform), { per_page: 1 }).first version = extract_version_from_tag_name(latest_internal_release&.tag_name) if version.to_s.empty? @@ -83,11 +86,7 @@ def self.validate_semver(version) end 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 - + asana_client = Helper::AsanaHelper.make_asana_client(asana_access_token) release_task_id = nil begin 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 9c7dde5..1ee44e6 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 @@ -2,6 +2,7 @@ require "fastlane_core/configuration/config_item" require "asana" require_relative "../helper/asana_helper" +require_relative "../helper/ddg_apple_automation_helper" require_relative "asana_add_comment_action" require_relative "asana_get_user_id_for_github_handle_action" @@ -20,10 +21,7 @@ def self.run(params) 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 + asana_client = Helper::AsanaHelper.make_asana_client(token) begin UI.important("Adding user #{asana_user_id} as collaborator on release task's 'Automation' subtask") @@ -78,6 +76,7 @@ def self.details def self.available_options [ FastlaneCore::ConfigItem.asana_access_token, + FastlaneCore::ConfigItem.is_scheduled_release, FastlaneCore::ConfigItem.new(key: :task_url, description: "Asana release task URL", optional: false, @@ -99,12 +98,7 @@ def self.available_options FastlaneCore::ConfigItem.new(key: :github_handle, description: "Github user handle", optional: true, - type: String), - FastlaneCore::ConfigItem.new(key: :is_scheduled_release, - description: "Indicates whether the release was scheduled or started manually", - optional: true, - type: Boolean, - default_value: false) + type: String) ] end diff --git a/lib/fastlane/plugin/ddg_apple_automation/actions/bump_build_number_action.rb b/lib/fastlane/plugin/ddg_apple_automation/actions/bump_build_number_action.rb new file mode 100644 index 0000000..8d30b9c --- /dev/null +++ b/lib/fastlane/plugin/ddg_apple_automation/actions/bump_build_number_action.rb @@ -0,0 +1,48 @@ +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 "../helper/github_actions_helper" + +module Fastlane + module Actions + class BumpBuildNumberAction < Action + def self.run(params) + Helper::GitHelper.setup_git_user + params[:platform] ||= Actions.lane_context[Actions::SharedValues::PLATFORM_NAME] + options = params.values + Helper::DdgAppleAutomationHelper.increment_build_number(options[:platform], options, other_action) + end + + def self.description + "Prepares a subsequent internal release" + end + + def self.authors + ["DuckDuckGo"] + end + + def self.return_value + "The newly created release task ID" + end + + def self.details + "This action increments the project build number and pushes the changes to the remote repository." + end + + def self.available_options + [ + FastlaneCore::ConfigItem.asana_access_token, + FastlaneCore::ConfigItem.github_token, + FastlaneCore::ConfigItem.platform + ] + end + + def self.is_supported?(platform) + true + end + end + end +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 new file mode 100644 index 0000000..dcae45a --- /dev/null +++ b/lib/fastlane/plugin/ddg_apple_automation/actions/start_new_release_action.rb @@ -0,0 +1,80 @@ +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 "../helper/github_actions_helper" + +module Fastlane + module Actions + class StartNewReleaseAction < Action + def self.run(params) + Helper::GitHelper.setup_git_user + params[:platform] ||= Actions.lane_context[Actions::SharedValues::PLATFORM_NAME] + + options = params.values + options[:asana_user_id] = Helper::AsanaHelper.get_asana_user_id_for_github_handle(options[:github_handle]) + + release_branch_name, new_version = Helper::DdgAppleAutomationHelper.prepare_release_branch( + params[:platform], params[:version], other_action + ) + options[:version] = new_version + options[:release_branch_name] = release_branch_name + + release_task_id = Helper::AsanaHelper.create_release_task(options[:platform], options[:version], options[:asana_user_id], options[:asana_access_token]) + options[:release_task_id] = release_task_id + + Helper::AsanaHelper.update_asana_tasks_for_internal_release(options) + end + + def self.description + "Starts a new release" + end + + def self.authors + ["DuckDuckGo"] + end + + def self.return_value + "The newly created release task ID" + end + + def self.details + <<-DETAILS +This action performs the following tasks: +* creates a new release branch, +* updates version and build number, +* updates embedded files, +* pushes the changes to the remote repository, +* creates a new Asana release task based off the provided task template, +* updates the Asana release task with tasks included in the release. + DETAILS + end + + def self.available_options + [ + FastlaneCore::ConfigItem.asana_access_token, + FastlaneCore::ConfigItem.github_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: :github_handle, + description: "Github user handle", + optional: false, + type: String), + FastlaneCore::ConfigItem.new(key: :target_section_id, + description: "Section ID in Asana where tasks included in the release should be moved", + optional: false, + type: String) + ] + end + + def self.is_supported?(platform) + true + end + end + end +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 index 008e65f..f18796b 100644 --- a/lib/fastlane/plugin/ddg_apple_automation/actions/tag_release_action.rb +++ b/lib/fastlane/plugin/ddg_apple_automation/actions/tag_release_action.rb @@ -28,7 +28,7 @@ def self.setup_constants(platform) end def self.run(params) - other_action.ensure_git_branch(branch: "^(:?release|hotfix)/.*$") + other_action.ensure_git_branch(branch: "^(:?release|hotfix)/.+$") Helper::GitHelper.setup_git_user params[:platform] ||= Actions.lane_context[Actions::SharedValues::PLATFORM_NAME] @@ -198,6 +198,7 @@ def self.available_options [ FastlaneCore::ConfigItem.asana_access_token, FastlaneCore::ConfigItem.github_token, + FastlaneCore::ConfigItem.is_scheduled_release, FastlaneCore::ConfigItem.platform, FastlaneCore::ConfigItem.new(key: :asana_task_url, description: "Asana release task URL", @@ -226,12 +227,7 @@ def self.available_options 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) + type: Boolean) ] end diff --git a/lib/fastlane/plugin/ddg_apple_automation/actions/update_asana_for_release_action.rb b/lib/fastlane/plugin/ddg_apple_automation/actions/update_asana_for_release_action.rb new file mode 100644 index 0000000..5cd20fb --- /dev/null +++ b/lib/fastlane/plugin/ddg_apple_automation/actions/update_asana_for_release_action.rb @@ -0,0 +1,86 @@ +require "fastlane/action" +require "fastlane_core/configuration/config_item" +require "octokit" +require_relative "asana_create_action_item_action" +require_relative "../helper/asana_helper" +require_relative "../helper/ddg_apple_automation_helper" +require_relative "../helper/git_helper" +require_relative "../helper/github_actions_helper" + +module Fastlane + module Actions + class UpdateAsanaForReleaseAction < Action + def self.run(params) + params[:platform] ||= Actions.lane_context[Actions::SharedValues::PLATFORM_NAME] + options = params.values + options[:version] = Helper::DdgAppleAutomationHelper.current_version + + if options[:release_type] == 'internal' + Helper::AsanaHelper.update_asana_tasks_for_internal_release(options) + else + announcement_task_html_notes = Helper::AsanaHelper.update_asana_tasks_for_public_release(options) + Fastlane::Actions::AsanaCreateActionItemAction.run( + asana_access_token: options[:asana_access_token], + task_url: Helper::AsanaHelper.asana_task_url(options[:release_task_id]), + task_name: "Announce the release to the company", + html_notes: announcement_task_html_notes, + github_handle: options[:github_handle], + is_scheduled_release: options[:is_scheduled_release] + ) + end + end + + def self.description + "Processes tasks included in the release and the Asana release task" + end + + def self.authors + ["DuckDuckGo"] + end + + def self.return_value + "" + end + + def self.details + <<-DETAILS +This action performs the following tasks: +* moves tasks included in the release to Validation section, +* updates Asana release task description with tasks included in the release. + DETAILS + end + + def self.available_options + [ + FastlaneCore::ConfigItem.asana_access_token, + FastlaneCore::ConfigItem.github_token, + FastlaneCore::ConfigItem.is_scheduled_release, + FastlaneCore::ConfigItem.platform, + FastlaneCore::ConfigItem.new(key: :github_handle, + description: "Github user handle - required when release_type is 'public'", + optional: true, + type: String), + FastlaneCore::ConfigItem.new(key: :release_task_id, + description: "Asana release task ID", + optional: false, + type: String), + FastlaneCore::ConfigItem.new(key: :release_type, + description: "Release type - 'internal' or 'public' (use 'public' for hotfixes)", + optional: true, + type: String, + verify_block: proc do |value| + UI.user_error!("release_type must be equal to 'internal' or 'public'") unless ['internal', 'public'].include?(value.to_s) + end), + FastlaneCore::ConfigItem.new(key: :target_section_id, + description: "Section ID in Asana where tasks included in the release should be moved", + optional: false, + type: String) + ] + end + + def self.is_supported?(platform) + true + end + end + end +end diff --git a/lib/fastlane/plugin/ddg_apple_automation/actions/validate_internal_release_bump_action.rb b/lib/fastlane/plugin/ddg_apple_automation/actions/validate_internal_release_bump_action.rb new file mode 100644 index 0000000..46ebec8 --- /dev/null +++ b/lib/fastlane/plugin/ddg_apple_automation/actions/validate_internal_release_bump_action.rb @@ -0,0 +1,93 @@ +require "fastlane/action" +require "fastlane_core/configuration/config_item" +require_relative "asana_find_release_task_action" +require_relative "../helper/asana_helper" +require_relative "../helper/git_helper" + +module Fastlane + module Actions + class ValidateInternalReleaseBumpAction < Action + def self.run(params) + Helper::GitHelper.setup_git_user + params[:platform] ||= Actions.lane_context[Actions::SharedValues::PLATFORM_NAME] + + options = params.values + find_release_task_if_needed(options) + + unless Helper::GitHelper.assert_branch_has_changes(options[:release_branch]) + UI.important("No changes to the release branch (or only changes to scripts and workflows). Skipping automatic release.") + Helper::GitHubActionsHelper.set_output("skip_release", true) + return + end + + UI.important("New code changes found in the release branch since the last release. Will bump internal release now.") + + UI.message("Validating release notes") + release_notes = Helper::AsanaHelper.fetch_release_notes(options[:release_task_id], options[:asana_access_token], output_type: "raw") + if release_notes.empty? || release_notes.include?("<-- Add release notes here -->") + UI.user_error!("Release notes are empty or contain a placeholder. Please add release notes to the Asana task and restart the workflow.") + else + UI.message("Release notes are valid: #{release_notes}") + end + end + + def self.find_release_task_if_needed(params) + if params[:release_task_url].to_s.empty? + params.merge!( + Fastlane::Actions::AsanaFindReleaseTaskAction.run( + asana_access_token: params[:asana_access_token], + github_token: params[:github_token], + platform: params[:platform] + ) + ) + else + params[:release_task_id] = Helper::AsanaHelper.extract_asana_task_id(params[:release_task_url], set_gha_output: false) + other_action.ensure_git_branch(branch: "^release/.+$") + params[:release_branch] = other_action.git_branch + + Helper::GitHubActionsHelper.set_output("release_branch", params[:release_branch]) + Helper::GitHubActionsHelper.set_output("release_task_id", params[:release_task_id]) + Helper::GitHubActionsHelper.set_output("release_task_url", params[:release_task_url]) + end + end + + def self.description + "Performs checks to decide if a subsequent internal release should be made" + end + + def self.authors + ["DuckDuckGo"] + end + + def self.return_value + "" + end + + def self.details + <<-DETAILS +This action performs the following tasks: +* finds the git branch and Asana task for the current internal release, +* checks for changes to the release branch, +* ensures that release notes aren't empty or placeholder. + DETAILS + end + + def self.available_options + [ + FastlaneCore::ConfigItem.asana_access_token, + FastlaneCore::ConfigItem.github_token, + FastlaneCore::ConfigItem.is_scheduled_release, + FastlaneCore::ConfigItem.platform, + FastlaneCore::ConfigItem.new(key: :release_task_url, + description: "Asana release task URL", + optional: true, + type: String) + ] + end + + def self.is_supported?(platform) + true + end + end + end +end diff --git a/lib/fastlane/plugin/ddg_apple_automation/assets/release_task_helper/templates/release_announcement_task_description.html.erb b/lib/fastlane/plugin/ddg_apple_automation/assets/release_task_helper/templates/release_announcement_task_description.html.erb new file mode 100644 index 0000000..08d2c47 --- /dev/null +++ b/lib/fastlane/plugin/ddg_apple_automation/assets/release_task_helper/templates/release_announcement_task_description.html.erb @@ -0,0 +1,21 @@ + + As the last step of the process, post a message to REVIEW / RELEASE Asana project: + +
+

Release notes

+ <%= release_notes %> +

This release includes:

+ + Rollout
+ This is now rolling out to users. New users will receive this release immediately, + existing users will receive this gradually over the next few days. You can force an update now + by going to the DuckDuckGo menu in the menu bar and selecting "Check For Updates". +
+ diff --git a/lib/fastlane/plugin/ddg_apple_automation/assets/release_task_helper/templates/release_task_description.html.erb b/lib/fastlane/plugin/ddg_apple_automation/assets/release_task_helper/templates/release_task_description.html.erb new file mode 100644 index 0000000..78f48ea --- /dev/null +++ b/lib/fastlane/plugin/ddg_apple_automation/assets/release_task_helper/templates/release_task_description.html.erb @@ -0,0 +1,13 @@ + + Note: This task's description is managed automatically.
+ Only the Release notes section below should be modified manually.
+ Please do not adjust formatting.
+

Release notes

+ <%= release_notes %> +

This release includes:

+
+ 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..7bbaa52 100644 --- a/lib/fastlane/plugin/ddg_apple_automation/helper/asana_helper.rb +++ b/lib/fastlane/plugin/ddg_apple_automation/helper/asana_helper.rb @@ -1,29 +1,63 @@ require "fastlane_core/ui/ui" require "asana" +require "httparty" +require "octokit" require_relative "ddg_apple_automation_helper" +require_relative "git_helper" require_relative "github_actions_helper" +require_relative "release_task_helper" module Fastlane UI = FastlaneCore::UI unless Fastlane.const_defined?(:UI) module Helper - class AsanaHelper - ASANA_APP_URL = "https://app.asana.com/0/0" + class AsanaHelper # rubocop:disable Metrics/ClassLength + ASANA_API_URL = "https://app.asana.com/api/1.0" + ASANA_TASK_URL_TEMPLATE = "https://app.asana.com/0/0/%s/f" + ASANA_TAG_URL_TEMPLATE = "https://app.asana.com/0/%s/list" 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" + ASANA_WORKSPACE_ID = "137249556945" + + 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" + + INCIDENTS_PARENT_TASK_ID = "1135688560894081" + CURRENT_OBJECTIVES_PROJECT_ID = "72649045549333" + + def self.make_asana_client(asana_access_token) + 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 + end def self.asana_task_url(task_id) if task_id.to_s.empty? UI.user_error!("Task ID cannot be empty") return end - "#{ASANA_APP_URL}/#{task_id}/f" + ASANA_TASK_URL_TEMPLATE % task_id + end + + def self.asana_tag_url(tag_id) + if tag_id.to_s.empty? + UI.user_error!("Tag ID cannot be empty") + return + end + ASANA_TAG_URL_TEMPLATE % tag_id end - def self.extract_asana_task_id(task_url) + def self.extract_asana_task_id(task_url, set_gha_output: true) if (match = task_url.match(ASANA_TASK_URL_REGEX)) task_id = match[1] - Helper::GitHubActionsHelper.set_output("asana_task_id", task_id) + if set_gha_output + Helper::GitHubActionsHelper.set_output("asana_task_id", task_id) + end task_id else UI.user_error!("URL has incorrect format (attempted to match #{ASANA_TASK_URL_REGEX})") @@ -31,13 +65,10 @@ def self.extract_asana_task_id(task_url) end 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 + client = make_asana_client(asana_access_token) begin - task = client.tasks.get_task(task_gid: task_id, options: { fields: ["assignee"] }) + task = client.tasks.get_task(task_gid: task_id, options: { opt_fields: ["assignee"] }) rescue StandardError => e UI.user_error!("Failed to fetch task assignee: #{e}") return @@ -56,13 +87,10 @@ def self.get_release_automation_subtask_id(task_url, asana_access_token) # TODO: To be reworked for local execution. extract_asana_task_assignee(task_id, 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 + asana_client = make_asana_client(asana_access_token) begin - subtasks = asana_client.tasks.get_subtasks_for_task(task_gid: task_id, options: { fields: ["name", "created_at"] }) + subtasks = asana_client.tasks.get_subtasks_for_task(task_gid: task_id, options: { opt_fields: ["name", "created_at"] }) rescue StandardError => e UI.user_error!("Failed to fetch 'Automation' subtasks for task #{task_id}: #{e}") return @@ -91,10 +119,7 @@ def self.get_asana_user_id_for_github_handle(github_handle) end 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 + asana_client = make_asana_client(asana_access_token) begin asana_client.tasks.find_by_id(task_id).attach(filename: file_path, mime: "application/octet-stream") @@ -104,12 +129,303 @@ 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 Hotfix Release #{version}" : "iOS App Release #{version}" + when "macos" + is_hotfix ? "macOS App Hotfix Release #{version}" : "macOS App Release #{version}" + else + UI.user_error!("Unsupported platform: #{platform}") + end + end + + def self.release_tag_name(version, platform) + case platform + when "ios" + "ios-app-release-#{version}" + when "macos" + "macos-app-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) + + UI.message("Creating release task for #{version}") + # 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}", 'Content-Type' => 'application/json' }, + body: { data: { name: task_name } }.to_json + ) + + unless response.success? + UI.user_error!("Failed to instantiate task from template #{template_task_id}: (#{response.code} #{response.message})") + return + end + + 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) + UI.success("Release task for #{version} created at #{task_url}") + + asana_client = make_asana_client(asana_access_token) + + UI.message("Moving release task to section #{section_id}") + asana_client.sections.add_task_for_section(section_gid: section_id, task: task_id) + UI.message("Assigning release task to user #{assignee_id}") + asana_client.tasks.update_task(task_gid: task_id, assignee: assignee_id) + UI.success("Release task ready: #{task_url} ✅") + + task_id + end + + # Updates asana tasks for an internal release + # + # @param github_token [String] GitHub token + # @param asana_access_token [String] Asana access token + # @param release_task_id [String] Asana access token + # @param target_section_id [String] ID of the 'Validation' section in the Asana project + # @param version [String] version number + # + def self.update_asana_tasks_for_internal_release(params) + UI.message("Checking latest public release in GitHub") + client = Octokit::Client.new(access_token: params[:github_token]) + latest_public_release = client.latest_release(Helper::GitHelper.repo_name(params[:platform])) + UI.success("Latest public release: #{latest_public_release.tag_name}") + + UI.message("Extracting task IDs from git log since #{latest_public_release.tag_name} release") + task_ids = get_task_ids_from_git_log(latest_public_release.tag_name) + UI.success("#{task_ids.count} task(s) found.") + + UI.message("Fetching release notes from Asana release task (#{asana_task_url(params[:release_task_id])})") + release_notes = fetch_release_notes(params[:release_task_id], params[:asana_access_token]) + UI.success("Release notes: #{release_notes}") + + UI.message("Generating release task description using fetched release notes and task IDs") + html_notes = Helper::ReleaseTaskHelper.construct_release_task_description(release_notes, task_ids) + + UI.message("Updating release task") + asana_client = make_asana_client(params[:asana_access_token]) + asana_client.tasks.update_task(task_gid: params[:release_task_id], html_notes: html_notes) + UI.success("Release task content updated: #{asana_task_url(params[:release_task_id])}") + + task_ids.append(params[:release_task_id]) + + UI.message("Moving tasks to Validation section") + move_tasks_to_section(task_ids, params[:target_section_id], params[:asana_access_token]) + UI.success("All tasks moved to Validation section") + + tag_name = release_tag_name(params[:version], params[:platform]) + UI.message("Fetching or creating #{tag_name} Asana tag") + tag_id = find_or_create_asana_release_tag(tag_name, params[:release_task_id], params[:asana_access_token]) + UI.success("#{tag_name} tag URL: #{asana_tag_url(tag_id)}") + + UI.message("Tagging tasks with #{tag_name} tag") + tag_tasks(tag_id, task_ids, params[:asana_access_token]) + UI.success("All tasks tagged with #{tag_name} tag") + end + + # Updates asana tasks for a public release + # + # @param github_token [String] GitHub token + # @param asana_access_token [String] Asana access token + # @param release_task_id [String] Asana access token + # @param target_section_id [String] ID of the 'Done' section in the Asana project + # @param version [String] version number + # + def self.update_asana_tasks_for_public_release(params) + # Get the existing Asana tag for the release. + tag_name = release_tag_name(params[:version], params[:platform]) + UI.message("Fetching #{tag_name} Asana tag") + tag_id = find_asana_release_tag(tag_name, params[:release_task_id], params[:asana_access_token]) + UI.success("#{tag_name} tag URL: #{asana_tag_url(tag_id)}") + + # Fetch task IDs for the release tag. + UI.message("Fetching tasks tagged with #{tag_name}") + task_ids = fetch_tasks_for_tag(tag_id, params[:asana_access_token]) + UI.success("#{task_ids.count} task(s) found.") + + # Move all tasks to Done section. + UI.message("Moving tasks to Done section") + move_tasks_to_section(task_ids, params[:target_section_id], params[:asana_access_token]) + UI.success("All tasks moved to Done section") + + # Complete tasks that don't require a post-mortem. + UI.message("Completing tasks") + complete_tasks(task_ids, params[:asana_access_token]) + UI.message("Done completing tasks") + + # Fetch current release notes from Asana release task. + UI.message("Fetching release notes from Asana release task (#{asana_task_url(params[:release_task_id])})") + release_notes = fetch_release_notes(params[:release_task_id], params[:asana_access_token]) + UI.success("Release notes: #{release_notes}") + + # Construct release announcement task description + UI.message("Preparing release announcement task") + task_ids.delete(params[:release_task_id]) + Helper::ReleaseTaskHelper.construct_release_announcement_task_description(params[:version], release_notes, task_ids) + end + + def self.fetch_tasks_for_tag(tag_id, asana_access_token) + asana_client = make_asana_client(asana_access_token) + task_ids = [] + begin + response = asana_client.tasks.get_tasks_for_tag(tag_gid: tag_id, options: { opt_fields: ["gid"] }) + loop do + task_ids += response.map(&:gid) + response = response.next_page + break if response.nil? + end + rescue StandardError => e + UI.user_error!("Failed to fetch tasks for tag: #{e}") + end + task_ids + end + + def self.fetch_subtasks(task_id, asana_access_token) + asana_client = make_asana_client(asana_access_token) + task_ids = [] + begin + response = asana_client.tasks.get_subtasks_for_task(task_gid: task_id, options: { opt_fields: ["gid"] }) + loop do + task_ids += response.map(&:gid) + response = response.next_page + break if response.nil? + end + rescue StandardError => e + UI.user_error!("Failed to fetch subtasks of task #{task_id}: #{e}") + end + task_ids + end + + def self.move_tasks_to_section(task_ids, section_id, asana_access_token) + asana_client = make_asana_client(asana_access_token) + + task_ids.each_slice(10) do |batch| + actions = batch.map do |task_id| + { + method: "post", + relative_path: "/sections/#{section_id}/addTask", + data: { + task: task_id + } + } + end + UI.message("Moving tasks #{batch.join(', ')} to section #{section_id}") + asana_client.batch_apis.create_batch_request(actions: actions) + end + end + + def self.complete_tasks(task_ids, asana_access_token) + asana_client = make_asana_client(asana_access_token) + incident_task_ids = fetch_subtasks(INCIDENTS_PARENT_TASK_ID, asana_access_token) + + task_ids.each do |task_id| + if incident_task_ids.include?(task_id) + UI.important("Not completing task #{task_id} because it's an incident task") + next + end + + projects_ids = asana_client.projects.get_projects_for_task(task_gid: task_id, options: { opt_fields: ["gid"] }).map(&:gid) + if projects_ids.include?(CURRENT_OBJECTIVES_PROJECT_ID) + UI.important("Not completing task #{task_id} because it's a Current Objective") + next + end + + UI.message("Completing task #{task_id}") + asana_client.tasks.update_task(task_gid: task_id, completed: true) + UI.success("Task #{task_id} completed") + end + end + + def self.find_asana_release_tag(tag_name, release_task_id, asana_access_token) + asana_client = make_asana_client(asana_access_token) + release_task_tags = asana_client.tasks.get_task(task_gid: release_task_id, options: { opt_fields: ["tags"] }).tags + + if (tag_id = release_task_tags.find { |t| t.name == tag_name }&.gid) && !tag_id.to_s.empty? + return tag_id + end + end + + def self.find_or_create_asana_release_tag(tag_name, release_task_id, asana_access_token) + tag_id = find_asana_release_tag(tag_name, release_task_id, asana_access_token) + unless tag_id + asana_client = make_asana_client(asana_access_token) + tag_id = asana_client.tags.create_tag_for_workspace(workspace_gid: ASANA_WORKSPACE_ID, name: tag_name).gid + end + tag_id + end + + def self.tag_tasks(tag_id, task_ids, asana_access_token) + asana_client = make_asana_client(asana_access_token) + + task_ids.each_slice(10) do |batch| + actions = batch.map do |task_id| + { + method: "post", + relative_path: "/tasks/#{task_id}/addTag", + data: { + tag: tag_id + } + } + end + UI.message("Tagging tasks #{batch.join(', ')}") + asana_client.batch_apis.create_batch_request(actions: actions) + end + 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 .strip # remove leading and trailing whitespaces .gsub(%r{}, "\n") # replace
tags with newlines end + + def self.get_task_ids_from_git_log(from_ref, to_ref = "HEAD") + git_log = `git log #{from_ref}..#{to_ref}` + + git_log + .gsub("\n", " ") + .scan(%r{\bTask/Issue URL:.*?https://app\.asana\.com[/0-9f]+\b}) + .map { |task_line| task_line.gsub(/.*(https.*)/, '\1') } + .map { |task_url| extract_asana_task_id(task_url, set_gha_output: false) } + end + + def self.fetch_release_notes(release_task_id, asana_access_token, output_type: "asana") + asana_client = make_asana_client(asana_access_token) + release_task_body = asana_client.tasks.get_task(task_gid: release_task_id, options: { opt_fields: ["notes"] }).notes + ReleaseTaskHelper.extract_release_notes(release_task_body, output_type: output_type) + end end end end 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..6ff5f81 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,8 @@ require "fastlane_core/configuration/config_item" require "fastlane_core/ui/ui" +require "httparty" +require "rexml/document" +require "semantic" require_relative "github_actions_helper" module Fastlane @@ -7,8 +10,288 @@ module Fastlane module Helper 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' + + INFO_PLIST = 'DuckDuckGo/Info.plist' + VERSION_CONFIG_PATH = 'Configuration/Version.xcconfig' + BUILD_NUMBER_CONFIG_PATH = '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) + 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.to_s.empty? + 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 self.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 self.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.prepare_release_branch(platform, version, other_action) + code_freeze_prechecks(other_action) unless Helper.is_ci? + new_version = validate_new_version(version) + create_release_branch(new_version) + update_embedded_files(platform, other_action) + update_version_config(new_version, other_action) + other_action.push_to_git_remote + release_branch_name = "#{RELEASE_BRANCH}/#{new_version}" + Helper::GitHubActionsHelper.set_output("release_branch_name", release_branch_name) + + return release_branch_name, new_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(platform, other_action) + Actions.sh("./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[platform].any? do |s| + s.include?(modified_file) + end + end + + # Run tests (CI will run them separately) + # run_tests(scheme: 'DuckDuckGo Privacy Browser') unless Helper.is_ci? + + # Everything looks good: commit and push + unless modified_files.empty? + modified_files.each { |modified_file| Actions.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.increment_build_number(platform, options, other_action) + current_version = Helper::DdgAppleAutomationHelper.current_version + current_build_number = Helper::DdgAppleAutomationHelper.current_build_number + build_number = Helper::DdgAppleAutomationHelper.calculate_next_build_number(platform, options, other_action) + + UI.important("Current version in project settings is #{current_version} (#{current_build_number}).") + UI.important("Will be updated to #{current_version} (#{build_number}).") + + if UI.interactive? && !UI.confirm("Do you want to continue?") + UI.abort_with_message!('Aborted by user.') + end + + update_version_and_build_number_config(current_version, build_number, other_action) + other_action.push_to_git_remote + end + + def self.calculate_next_build_number(platform, options, other_action) + testflight_build_number = fetch_testflight_build_number(platform, options, other_action) + xcodeproj_build_number = current_build_number + if platform == "macos" + appcast_build_number = fetch_appcast_build_number(platform) + current_release_build_number = [testflight_build_number, appcast_build_number].max + else + current_release_build_number = testflight_build_number + end + + UI.message("TestFlight build number: #{testflight_build_number}") + if platform == "macos" + UI.message("Appcast.xml build number: #{appcast_build_number}") + UI.message("Latest release build number (max of TestFlight and appcast): #{current_release_build_number}") + end + UI.message("Xcode project settings build number: #{xcodeproj_build_number}") + + if xcodeproj_build_number <= current_release_build_number + new_build_number = current_release_build_number + else + UI.important("Warning: Build number from Xcode project (#{xcodeproj_build_number}) is higher than the current release (#{current_release_build_number}).") + UI.message(%{This may be an error in the Xcode project settings, or it may mean that there is a hotfix + release in progress and you're making a follow-up internal release that includes the hotfix.}) + if UI.interactive? + build_numbers = { + "Current release (#{current_release_build_number})" => current_release_build_number, + "Xcode project (#{xcodeproj_build_number})" => xcodeproj_build_number + } + choice = UI.select("Please choose which build number to bump:", build_numbers.keys) + new_build_number = build_numbers[choice] + else + UI.important("Shell is non-interactive, so we'll bump the Xcode project build number.") + new_build_number = xcodeproj_build_number + end + end + + new_build_number + 1 + end + + def self.fetch_appcast_build_number(platform) + UI.user_error!("This function is not supported on iOS") if platform == "ios" + url = `plutil -extract SUFeedURL raw #{INFO_PLIST}`.chomp + xml = HTTParty.get(url).body + xml_data = REXML::Document.new(xml) + versions = xml_data.get_elements('//rss/channel/item/sparkle:version').map { |e| e.text.split('.')[0].to_i } + versions.max + end + + def self.fetch_testflight_build_number(platform, options, other_action) + other_action.latest_testflight_build_number( + api_key: get_api_key(other_action), + username: get_username(options), + platform: platform == "macos" ? "osx" : "ios" + ) + end + + def self.get_api_key(other_action) + has_api_key = [ + "APPLE_API_KEY_ID", + "APPLE_API_KEY_ISSUER", + "APPLE_API_KEY_BASE64" + ].map { |x| ENV.key?(x) }.reduce(&:&) + + if has_api_key + other_action.app_store_connect_api_key( + key_id: ENV.fetch("APPLE_API_KEY_ID", nil), + issuer_id: ENV.fetch("APPLE_API_KEY_ISSUER", nil), + key_content: ENV.fetch("APPLE_API_KEY_BASE64", nil), + is_key_content_base64: true + ) + end + end + + def self.get_username(options) + if Helper.is_ci? + nil # not supported in CI + elsif options[:username] + options[:username] + else + git_user_email = `git config user.email`.chomp + if git_user_email.end_with?("@duckduckgo.com") + git_user_email + end + end + end + + def self.update_version_config(version, other_action) + File.write(VERSION_CONFIG_PATH, "#{VERSION_CONFIG_DEFINITION} = #{version}\n") + other_action.git_commit( + path: VERSION_CONFIG_PATH, + message: "Set marketing version to #{version}" + ) + end + + def self.update_version_and_build_number_config(version, build_number, other_action) + File.write(VERSION_CONFIG_PATH, "#{VERSION_CONFIG_DEFINITION} = #{version}\n") + File.write(BUILD_NUMBER_CONFIG_PATH, "#{BUILD_NUMBER_CONFIG_DEFINITION} = #{build_number}\n") + other_action.git_commit( + path: [ + VERSION_CONFIG_PATH, + BUILD_NUMBER_CONFIG_PATH + ], + message: "Bump version to #{version} (#{build_number})" + ) + end def self.process_erb_template(erb_file_path, args) template_content = load_file(erb_file_path) @@ -22,8 +305,8 @@ def self.process_erb_template(erb_file_path, 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 + version = File.read(VERSION_CONFIG_PATH).chomp.split(" = ").last + build_number = File.read(BUILD_NUMBER_CONFIG_PATH).chomp.split(" = ").last if is_prerelease tag = "#{version}-#{build_number}" else @@ -43,13 +326,6 @@ def self.load_file(file) rescue StandardError UI.user_error!("Error: The file '#{file}' does not exist.") 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 - .strip # remove leading and trailing whitespaces - .gsub(%r{}, "\n") # replace
tags with newlines - end end end end @@ -89,5 +365,13 @@ def self.platform UI.user_error!("platform must be equal to 'ios' or 'macos'") unless ['ios', 'macos'].include?(value.to_s) end) end + + def self.is_scheduled_release + 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 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 index a2fa77d..f84da1d 100644 --- a/lib/fastlane/plugin/ddg_apple_automation/helper/git_helper.rb +++ b/lib/fastlane/plugin/ddg_apple_automation/helper/git_helper.rb @@ -7,6 +7,17 @@ module Fastlane module Helper class GitHelper + def self.repo_name(platform) + case platform + when "ios" + "duckduckgo/ios" + when "macos" + "duckduckgo/macos-browser" + else + UI.user_error!("Unsupported platform: #{platform}") + end + end + 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}'\"") @@ -49,6 +60,23 @@ def self.delete_branch(repo_name, branch, github_token) raise e end end + + def self.assert_branch_has_changes(release_branch) + latest_tag = `git describe --tags --abbrev=0`.chomp + latest_tag_sha = `git rev-parse "#{latest_tag}"^{}`.chomp + release_branch_sha = `git rev-parse "origin/#{release_branch}"`.chomp + + if latest_tag_sha == release_branch_sha + UI.important("Release branch's HEAD is already tagged. Skipping automatic release.") + return false + end + + changed_files = `git diff --name-only "#{latest_tag}".."origin/#{release_branch}"` + .split("\n") + .filter { |file| !file.match?(/^(:?\.github|scripts|fastlane)/) } + + changed_files.any? + end end end end diff --git a/lib/fastlane/plugin/ddg_apple_automation/helper/release_notes/asana_release_notes_extractor.rb b/lib/fastlane/plugin/ddg_apple_automation/helper/release_notes/asana_release_notes_extractor.rb new file mode 100644 index 0000000..5beb0c3 --- /dev/null +++ b/lib/fastlane/plugin/ddg_apple_automation/helper/release_notes/asana_release_notes_extractor.rb @@ -0,0 +1,114 @@ +require "fastlane_core/ui/ui" +require_relative "../ddg_apple_automation_helper" +require_relative "../github_actions_helper" + +module Fastlane + UI = FastlaneCore::UI unless Fastlane.const_defined?(:UI) + + module Helper + class AsanaReleaseNotesExtractor + START_MARKER = "release notes" + PP_MARKER = /^for privacy pro subscribers:?$/ + END_MARKER = "this release includes:" + PLACEHOLDER = "add release notes here" + + def initialize(output_type: "html") + @output_type = output_type + @notes = "" + @pp_notes = "" + @is_capturing = false + @is_capturing_pp = false + @has_content = false + end + + def extract_release_notes(task_body) + task_body.each_line do |line| + lowercase_line = line.downcase.strip + + if lowercase_line == START_MARKER + handle_start_marker + elsif lowercase_line =~ PP_MARKER + handle_pp_marker(line) + elsif lowercase_line == END_MARKER + handle_end_marker + return @notes + elsif @is_capturing && !line.strip.empty? + @has_content = true + add_release_note(line.strip) + end + end + + UI.user_error!("No release notes found") unless @has_content + + @notes + end + + private + + def html_escape(input) + input.gsub("&", "&").gsub("<", "<").gsub(">", ">").gsub('"', """) + end + + def make_links(input) + input.gsub(%r{(https://[^\s]*)}) { "
#{$1}" } + end + + def add_to_notes(line) + @notes += line + @notes += "\n" unless @output_type == "asana" + end + + def add_to_pp_notes(line) + @pp_notes += line + @pp_notes += "\n" unless @output_type == "asana" + end + + def add_release_note(release_note) + processed_release_note = + if @output_type == "raw" + release_note + else + "
  • #{make_links(html_escape(release_note))}
  • " + end + + if @is_capturing_pp + add_to_pp_notes(processed_release_note) + else + add_to_notes(processed_release_note) + end + end + + def handle_start_marker + @is_capturing = true + case @output_type + when "asana" + add_to_notes("