diff --git a/CHANGELOG.md b/CHANGELOG.md index ec35d041d..cbad9612e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ _None_ ### New Features -_None_ +- `upload_github_release_assets` action: uploads assets on an existing GitHub release without disturbing unrelated assets. If assets exists already, it replaces them. [#743] ### Bug Fixes diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/upload_github_release_assets_action.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/upload_github_release_assets_action.rb new file mode 100644 index 000000000..7e951c316 --- /dev/null +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/upload_github_release_assets_action.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require 'fastlane/action' +require_relative '../../helper/github_helper' + +module Fastlane + module Actions + class UploadGithubReleaseAssetsAction < Action + def self.run(params) + repository = params[:repository] + version = params[:version] + assets = params[:release_assets] + replace_existing = params[:replace_existing] + + UI.message("Uploading #{assets.count} GitHub Release asset(s) to #{repository} #{version}.") + + github_helper = Fastlane::Helper::GithubHelper.new(github_token: params[:github_token]) + url = github_helper.upload_release_assets( + repository: repository, + version: version, + assets: assets, + replace_existing: replace_existing + ) + + UI.success("Successfully uploaded GitHub Release assets. You can see the release at '#{url}'") + url + end + + def self.description + 'Uploads assets to an existing GitHub Release' + end + + def self.authors + ['Automattic'] + end + + def self.return_value + 'The URL of the GitHub Release' + end + + def self.details + 'Uploads assets to an existing GitHub Release. By default, existing release assets with matching filenames are replaced; when replace_existing is false, matching assets cause the action to fail.' + end + + def self.available_options + [ + FastlaneCore::ConfigItem.new(key: :repository, + description: 'The slug (`/`) of the GitHub repository containing the release', + optional: false, + type: String, + verify_block: proc do |value| + UI.user_error!('Repository cannot be empty') if value.to_s.empty? + end), + FastlaneCore::ConfigItem.new(key: :version, + description: 'The version of the release. Used as the git tag name', + optional: false, + type: String, + verify_block: proc do |value| + UI.user_error!('Version cannot be empty') if value.to_s.empty? + end), + FastlaneCore::ConfigItem.new(key: :release_assets, + description: 'Assets to upload', + type: Array, + optional: false, + verify_block: proc do |value| + UI.user_error!('You must provide at least one release asset') if value.nil? || value.empty? + value.each do |asset| + UI.user_error!('release_assets must contain file paths') unless asset.is_a?(String) && !asset.empty? + end + end), + FastlaneCore::ConfigItem.new(key: :replace_existing, + description: 'True to delete existing release assets with matching filenames before uploading. False to fail if a matching asset exists', + optional: true, + default_value: true, + type: Boolean), + Fastlane::Helper::GithubHelper.github_token_config_item, + ] + end + + def self.is_supported?(platform) + true + end + end + end +end diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb b/lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb index b8a89d27f..4da911573 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb @@ -192,6 +192,57 @@ def create_release(repository:, version:, description:, assets:, prerelease:, is release[:html_url] end + # Returns the GitHub release matching a given tag/version. + # + # @param [String] repository The repository to fetch the GitHub release from. Typically a repo slug (/). + # @param [String] version The release version/tag to fetch. + # @return [Sawyer::Resource] The matching GitHub Release. + # @raise [Fastlane::UI::Error] UI.user_error! if the release does not exist. + # + def get_release(repository:, version:) + client.release_for_tag(repository, version) + rescue Octokit::NotFound + UI.user_error!("Could not find GitHub Release for tag #{version} in #{repository}") + end + + # Uploads assets to an existing GitHub release, optionally replacing matching filenames. + # + # @param [String] repository The repository to upload the GitHub release assets to. Typically a repo slug (/). + # @param [String] version The release version/tag to upload assets to. + # @param [Array] assets List of local file paths to attach as release assets. + # @param [TrueClass|FalseClass] replace_existing Delete existing same-filename assets before uploading. When false, fail if a matching asset exists. + # @return [String] URL of the corresponding GitHub Release. + # @raise [Fastlane::UI::Error] UI.user_error! if the release or any local asset file does not exist. + # + def upload_release_assets(repository:, version:, assets:, replace_existing: true) + asset_paths = validate_release_assets!(assets) + release = get_release(repository: repository, version: version) + existing_assets = client.release_assets(release.url) + + asset_paths.each do |file_path| + file_name = File.basename(file_path) + matching_assets = existing_assets.select { |asset| asset.name == file_name } + + unless matching_assets.empty? + if replace_existing + matching_assets.each do |asset| + UI.message("Deleting existing GitHub Release asset #{asset.name}") + client.delete_release_asset(asset.url) + end + existing_assets -= matching_assets + else + UI.user_error!("GitHub Release #{version} already has an asset named #{file_name}. Set replace_existing: true to replace it.") + end + end + + UI.message("Uploading #{file_path} to GitHub Release #{version}") + uploaded_asset = client.upload_asset(release.url, file_path, content_type: 'application/octet-stream') + existing_assets << uploaded_asset unless uploaded_asset.nil? + end + + release.html_url + end + # Use the GitHub API to generate release notes based on the list of PRs between current tag and previous tag. # @note This API uses the `.github/release.yml` config file to classify the PRs by category in the generated list according to PR labels. # @@ -368,6 +419,26 @@ def set_branch_protection(repository:, branch:, **options) client.protect_branch(repository, branch, options) end + def validate_release_assets!(assets) + asset_paths = Array(assets) + UI.user_error!('You must provide at least one release asset') if asset_paths.empty? + + asset_paths.each do |file_path| + UI.user_error!('release_assets must contain file paths') unless file_path.is_a?(String) && !file_path.empty? + end + + file_names = asset_paths.map { |file_path| File.basename(file_path) } + UI.user_error!('release_assets must not contain duplicate filenames') if file_names.uniq.length != file_names.length + + asset_paths.each do |file_path| + UI.user_error!("Can't find file #{file_path}!") unless File.file?(file_path) + end + + asset_paths + end + + private :validate_release_assets! + # Convert a response from the `/branch-protection` API endpoint into a Hash # suitable to be returned and/or reused to pass to a subsequent `/branch-protection` API request # @param [Sawyer::Resource] response The API response returned by `#get_branch_protection` or `#set_branch_protection` diff --git a/spec/github_helper_spec.rb b/spec/github_helper_spec.rb index f30bacc48..fdb57a349 100644 --- a/spec/github_helper_spec.rb +++ b/spec/github_helper_spec.rb @@ -619,6 +619,174 @@ def create_release(is_draft:, assets: [], name: nil) end end + describe '#upload_release_assets' do + let(:test_repo) { 'repo-test/project-test' } + let(:test_version) { '1.0.0' } + let(:release_url) { 'https://api.github.com/repos/repo-test/project-test/releases/123' } + let(:release_html_url) { 'https://github.com/repo-test/project-test/releases/tag/1.0.0' } + let(:release) { sawyer_resource_stub(url: release_url, html_url: release_html_url) } + let(:existing_assets) { [] } + let(:uploaded_asset) { release_asset(name: 'test-app.zip', url: 'https://api.github.com/repos/repo-test/project-test/releases/assets/999') } + let(:client) do + instance_double( + Octokit::Client, + user: instance_double('User', name: 'test'), + 'auto_paginate=': nil + ) + end + let(:helper) do + described_class.new(github_token: 'Fake-GitHubToken-123') + end + + before do + allow(Octokit::Client).to receive(:new).and_return(client) + allow(client).to receive(:release_for_tag).with(test_repo, test_version).and_return(release) + allow(client).to receive(:release_assets).with(release_url).and_return(existing_assets) + allow(client).to receive_messages(upload_asset: uploaded_asset, delete_release_asset: true) + end + + it 'fails clearly if the release does not exist' do + allow(client).to receive(:release_for_tag).with(test_repo, test_version).and_raise(Octokit::NotFound) + + with_tmp_file(named: 'test-app.zip') do |file_path| + expect do + upload_release_assets(assets: [file_path]) + end.to raise_error(FastlaneCore::Interface::FastlaneError, "Could not find GitHub Release for tag #{test_version} in #{test_repo}") + end + end + + it 'fails clearly if an asset file does not exist' do + expect(client).not_to receive(:release_for_tag) + expect(client).not_to receive(:release_assets) + expect(client).not_to receive(:upload_asset) + + expect do + upload_release_assets(assets: ['missing-file.zip']) + end.to raise_error(FastlaneCore::Interface::FastlaneError, "Can't find file missing-file.zip!") + end + + it 'fails clearly if an asset is not a file path' do + expect(client).not_to receive(:release_for_tag) + expect(client).not_to receive(:release_assets) + expect(client).not_to receive(:upload_asset) + + expect do + upload_release_assets(assets: [123]) + end.to raise_error(FastlaneCore::Interface::FastlaneError, 'release_assets must contain file paths') + end + + it 'fails without mutating GitHub when local assets have duplicate filenames' do + in_tmp_dir do |tmpdir| + first_dir = File.join(tmpdir, 'ios') + second_dir = File.join(tmpdir, 'tvos') + Dir.mkdir(first_dir) + Dir.mkdir(second_dir) + + first_file_path = File.join(first_dir, 'test-app.zip') + second_file_path = File.join(second_dir, 'test-app.zip') + File.write(first_file_path, 'ios') + File.write(second_file_path, 'tvos') + + expect(client).not_to receive(:release_for_tag) + expect(client).not_to receive(:release_assets) + expect(client).not_to receive(:delete_release_asset) + expect(client).not_to receive(:upload_asset) + + expect do + upload_release_assets(assets: [first_file_path, second_file_path], replace_existing: false) + end.to raise_error(FastlaneCore::Interface::FastlaneError, 'release_assets must not contain duplicate filenames') + end + end + + it 'uploads one asset to the existing release' do + with_tmp_file(named: 'test-app.zip') do |file_path| + expect(client).to receive(:upload_asset).with(release_url, file_path, { content_type: 'application/octet-stream' }) + + result = upload_release_assets(assets: [file_path]) + + expect(result).to eq(release_html_url) + end + end + + it 'uploads multiple assets to the existing release' do + in_tmp_dir do |tmpdir| + first_file_path = File.join(tmpdir, 'test-ios.zip') + second_file_path = File.join(tmpdir, 'test-tvos.zip') + File.write(first_file_path, 'ios') + File.write(second_file_path, 'tvos') + + first_uploaded_asset = release_asset(name: 'test-ios.zip', url: 'https://api.github.com/repos/repo-test/project-test/releases/assets/1000') + second_uploaded_asset = release_asset(name: 'test-tvos.zip', url: 'https://api.github.com/repos/repo-test/project-test/releases/assets/1001') + + expect(client).to receive(:upload_asset).with(release_url, first_file_path, { content_type: 'application/octet-stream' }).ordered.and_return(first_uploaded_asset) + expect(client).to receive(:upload_asset).with(release_url, second_file_path, { content_type: 'application/octet-stream' }).ordered.and_return(second_uploaded_asset) + + result = upload_release_assets(assets: [first_file_path, second_file_path]) + + expect(result).to eq(release_html_url) + end + end + + it 'replaces an existing asset with the same filename' do + existing_asset = release_asset(name: 'test-app.zip', url: 'https://api.github.com/repos/repo-test/project-test/releases/assets/1234') + allow(client).to receive(:release_assets).with(release_url).and_return([existing_asset]) + + with_tmp_file(named: 'test-app.zip') do |file_path| + expect(client).to receive(:delete_release_asset).with(existing_asset.url) + expect(client).to receive(:upload_asset).with(release_url, file_path, { content_type: 'application/octet-stream' }) + + result = upload_release_assets(assets: [file_path]) + + expect(result).to eq(release_html_url) + end + end + + it 'preserves unrelated existing assets' do + matching_asset = release_asset(name: 'test-app.zip', url: 'https://api.github.com/repos/repo-test/project-test/releases/assets/1234') + unrelated_asset = release_asset(name: 'other-platform.zip', url: 'https://api.github.com/repos/repo-test/project-test/releases/assets/5678') + deleted_asset_urls = [] + + allow(client).to receive(:release_assets).with(release_url).and_return([matching_asset, unrelated_asset]) + allow(client).to receive(:delete_release_asset) do |asset_url| + deleted_asset_urls << asset_url + true + end + + with_tmp_file(named: 'test-app.zip') do |file_path| + upload_release_assets(assets: [file_path]) + end + + expect(deleted_asset_urls).to eq([matching_asset.url]) + end + + it 'fails without deleting or uploading when replace_existing is false and a matching asset exists' do + existing_asset = release_asset(name: 'test-app.zip', url: 'https://api.github.com/repos/repo-test/project-test/releases/assets/1234') + allow(client).to receive(:release_assets).with(release_url).and_return([existing_asset]) + + expect(client).not_to receive(:delete_release_asset) + expect(client).not_to receive(:upload_asset) + + with_tmp_file(named: 'test-app.zip') do |file_path| + expect do + upload_release_assets(assets: [file_path], replace_existing: false) + end.to raise_error(FastlaneCore::Interface::FastlaneError, "GitHub Release #{test_version} already has an asset named test-app.zip. Set replace_existing: true to replace it.") + end + end + + def upload_release_assets(assets:, replace_existing: true) + helper.upload_release_assets( + repository: test_repo, + version: test_version, + assets: assets, + replace_existing: replace_existing + ) + end + + def release_asset(name:, url:) + sawyer_resource_stub(name: name, url: url) + end + end + describe '#github_token_config_item' do it 'has the correct key' do expect(described_class.github_token_config_item.key).to eq(:github_token) diff --git a/spec/upload_github_release_assets_spec.rb b/spec/upload_github_release_assets_spec.rb new file mode 100644 index 000000000..d53e5f8be --- /dev/null +++ b/spec/upload_github_release_assets_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Fastlane::Actions::UploadGithubReleaseAssetsAction do + let(:test_token) { 'ghp_fake_token' } + let(:test_repo) { 'repo-test/project-test' } + let(:test_version) { '1.0.0' } + let(:test_assets) { ['builds/test-app.zip'] } + let(:test_url) { 'https://github.com/repo-test/project-test/releases/tag/1.0.0' } + let(:github_helper) { instance_double(Fastlane::Helper::GithubHelper) } + + before do + allow(Fastlane::Helper::GithubHelper).to receive(:new).with(github_token: test_token).and_return(github_helper) + end + + it 'uploads release assets and returns the release URL' do + allow(github_helper).to receive(:upload_release_assets).and_return(test_url) + expect(github_helper).to receive(:upload_release_assets).with( + repository: test_repo, + version: test_version, + assets: test_assets, + replace_existing: true + ) + + result = run_described_fastlane_action( + github_token: test_token, + repository: test_repo, + version: test_version, + release_assets: test_assets + ) + + expect(result).to eq(test_url) + end + + it 'forwards replace_existing when provided' do + allow(github_helper).to receive(:upload_release_assets).and_return(test_url) + expect(github_helper).to receive(:upload_release_assets).with( + repository: test_repo, + version: test_version, + assets: test_assets, + replace_existing: false + ) + + result = run_described_fastlane_action( + github_token: test_token, + repository: test_repo, + version: test_version, + release_assets: test_assets, + replace_existing: false + ) + + expect(result).to eq(test_url) + end + + it 'fails when release_assets is empty' do + expect do + run_described_fastlane_action( + github_token: test_token, + repository: test_repo, + version: test_version, + release_assets: [] + ) + end.to raise_error(FastlaneCore::Interface::FastlaneError, 'You must provide at least one release asset') + end + + it 'fails when release_assets contains a non-path value' do + expect do + run_described_fastlane_action( + github_token: test_token, + repository: test_repo, + version: test_version, + release_assets: [123] + ) + end.to raise_error(FastlaneCore::Interface::FastlaneError, 'release_assets must contain file paths') + end +end