From 915530b061999e63520210f1f167b0a2b3364748 Mon Sep 17 00:00:00 2001 From: Dominik Kapusta Date: Fri, 13 Sep 2024 13:33:26 +0200 Subject: [PATCH] WIP tag_release_action --- .../actions/asana_find_release_task_action.rb | 1 - .../actions/tag_release_action.rb | 298 ++++++++++++++++++ .../ddg_apple_automation/helper/git_helper.rb | 31 ++ spec/git_helper_spec.rb | 24 ++ 4 files changed, 353 insertions(+), 1 deletion(-) 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/git_helper_spec.rb 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 11b14e9..ffc226c 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 @@ -177,7 +177,6 @@ def self.available_options 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 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..cfc31ca --- /dev/null +++ b/lib/fastlane/plugin/ddg_apple_automation/actions/tag_release_action.rb @@ -0,0 +1,298 @@ +require "fastlane/action" +require "fastlane_core/configuration/config_item" +require "octokit" +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 = { + repo_name: "duckduckgo/macos-browser" + } + end + end + + def self.run(params) + platform = params[:platform] || Actions.lane_context[Actions::SharedValues::PLATFORM_NAME] + setup_constants(platform) + + base_branch = params[:base_branch] + branch = params[:branch] + dmg_url_prefix = params[:dmg_url_prefix] + github_elevated_permissions_token = params[:github_elevated_permissions_token] + github_token = params[:github_token] + is_prerelease = params[:is_prerelease] + + other_action.ensure_git_branch(branch: "^(:?release|hotfix)/.*$") + Helper::GitHelper.setup_git_user + + tag, promoted_tag, tag_created, latest_public_release_tag = create_tag_and_github_release(is_prerelease, github_token) + + Helper::GitHubActionsHelper.set_output("tag", tag) + + merge_or_delete_failed = false + begin + if is_prerelease + merge_branch(branch, base_branch, github_elevated_permissions_token) + else + delete_branch(branch, github_token) + end + rescue StandardError + merge_or_delete_failed = true + end + + # Set environment variables + + ENV['TAG'] = tag + ENV['PROMOTED_TAG'] = promoted_tag + dmg_version = (is_prerelease ? tag : promoted_tag).gsub('-', '.') + ENV['DMG_URL'] = "#{dmg_url_prefix}duckduckgo-#{dmg_version}.dmg" + ENV['RELEASE_URL'] = "https://github.com/#{@constants[:repo_name]}/releases/tag/#{tag}" + unless tag_created + ENV['LAST_RELEASE_TAG'] = latest_public_release_tag + end + + report_status(params, merge_or_delete_failed) + end + + def self.report_status(params, merge_or_delete_failed) + task_template, comment_template = set_up_asana_templates(params, merge_or_delete_failed) + + if task_template + AsanaCreateActionItemAction.run( + asana_access_token: params[:asana_access_token], + task_url: params[:asana_task_url], + template_name: task_template, + github_handle: params[:github_handle], + is_scheduled_release: params[:is_scheduled_release] + ) + + if params[:is_internal_release_bump] + AsanaCreateActionItemAction.run( + asana_access_token: params[:asana_access_token], + task_url: params[:asana_task_url], + template_name: "run-publish-dmg-release", + 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, + is_scheduled_release: params[:is_scheduled_release] + ) + end + + def self.set_up_asana_templates(params, merge_or_delete_failed) + if merge_or_delete_failed + case [tag_created, 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 + else + comment_template = is_prerelease ? "internal-release-ready" : "public-release-tagged" + end + + return task_template, comment_template + end + + def self.create_tag_and_github_release(is_prerelease, github_token) + tag, promoted_tag = compute_tag(is_prerelease) + tag_created = false + + begin + other_action.add_git_tag(tag: tag) + other_action.push_git_tags(tag: tag) + tag_created = true + rescue StandardError => e + UI.warn("Failed to create and push tag: #{e}") + return tag, promoted_tag, tag_created, nil + end + + begin + client = Octokit::Client.new(access_token: github_token) + latest_public_release = client.latest_release(@constants[:repo_name]) + + # 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_token: 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_token: github_token, + tag_name: tag, + name: release_notes&.dig('name'), + description: release_notes&.dig('body'), + is_prerelease: is_prerelease + ) + rescue StandardError => e + UI.warn("Failed to create GitHub release: #{e}") + end + + return tag, promoted_tag, tag_created, latest_public_release.tag_name + 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.merge_branch(branch, base_branch, github_token) + client = Octokit::Client.new(access_token: github_token) + begin + client.merge(@constants[:repo_name], base_branch, branch) + rescue StandardError => e + UI.warn("Failed to merge #{branch} branch to #{base_branch}: #{e}") + raise e + end + end + + def self.delete_branch(branch, github_token) + client = Octokit::Client.new(access_token: github_token) + begin + client.delete_branch(@constants[:repo_name], branch) + rescue StandardError => e + UI.warn("Failed to delete #{branch}: #{e}") + raise e + end + end + + def self.validate_params(task_id, task_url, comment, template_name, workflow_url) + if task_id.to_s.empty? && task_url.to_s.empty? + raise ArgumentError, "Both task_id and task_url cannot be empty. At least one must be provided." + end + + if comment.to_s.empty? && template_name.to_s.empty? + raise ArgumentError, "Both comment and template_name cannot be empty. At least one must be provided." + end + + if comment && workflow_url.to_s.empty? + raise ArgumentError, "If comment is provided, workflow_url cannot be empty" + end + 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.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: :branch, + description: "Release branch name", + optional: false, + type: String), + FastlaneCore::ConfigItem.new(key: :dmg_url_prefix, + description: "Constant prefix of the DMG URL", + optional: false, + type: String), + 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: false, + sensitive: true, + type: String, + verify_block: proc do |value| + UI.user_error!("GITHUB_ELEVATED_PERMISSIONS_TOKEN is not set") if value.to_s.length == 0 + end), + 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), + 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 + + def self.is_supported?(platform) + true + 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..4f234c2 --- /dev/null +++ b/lib/fastlane/plugin/ddg_apple_automation/helper/git_helper.rb @@ -0,0 +1,31 @@ +require "fastlane/action" +require "fastlane_core/ui/ui" + +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 + end + end +end diff --git a/spec/git_helper_spec.rb b/spec/git_helper_spec.rb new file mode 100644 index 0000000..d9f8a78 --- /dev/null +++ b/spec/git_helper_spec.rb @@ -0,0 +1,24 @@ +describe Fastlane::Helper::GitHelper do + describe "#assert_release_or_hotfix_branch" do + it "accepts release or hotfix branch" do + expect(Fastlane::UI).not_to receive(:user_error!) + assert_release_or_hotfix_branch("release/1.0.0") + assert_release_or_hotfix_branch("release/1") + assert_release_or_hotfix_branch("hotfix/1.0.0") + end + + it "shows error for non-release branch" do + expect(Fastlane::UI).to receive(:user_error!).with("Release or hotfix branch required, got 'feature/foo'.") + assert_release_or_hotfix_branch("feature/foo") + end + + it "shows error for main branch" do + expect(Fastlane::UI).to receive(:user_error!).with("Release or hotfix branch required, got 'feature/foo'.") + assert_release_or_hotfix_branch("feature/foo") + end + + def assert_release_or_hotfix_branch(branch) + Fastlane::Helper::GitHelper.assert_release_or_hotfix_branch(branch) + end + end +end