Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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 (`<org>/<repo>`) 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
71 changes: 71 additions & 0 deletions lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 (<org>/<repo>).
# @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 (<org>/<repo>).
# @param [String] version The release version/tag to upload assets to.
# @param [Array<String>] 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This risks overriding files with the same name but from different folders (ios/app.ipa and tvos/app.ipa).

Is this a legitimate concern? If so, what's a good way to address this? Compare using the full path stripped of the machine-specific path to the project folder maybe?

I added a test for this in #745

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.
#
Expand Down Expand Up @@ -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`
Expand Down
168 changes: 168 additions & 0 deletions spec/github_helper_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading