Skip to content

Commit 8d88607

Browse files
authored
Add GitHub release asset upload action (#743)
2 parents 380f640 + 448e959 commit 8d88607

5 files changed

Lines changed: 419 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ _None_
1010

1111
### New Features
1212

13-
_None_
13+
- `upload_github_release_assets` action: uploads assets on an existing GitHub release without disturbing unrelated assets. If assets exists already, it replaces them. [#743]
1414

1515
### Bug Fixes
1616

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# frozen_string_literal: true
2+
3+
require 'fastlane/action'
4+
require_relative '../../helper/github_helper'
5+
6+
module Fastlane
7+
module Actions
8+
class UploadGithubReleaseAssetsAction < Action
9+
def self.run(params)
10+
repository = params[:repository]
11+
version = params[:version]
12+
assets = params[:release_assets]
13+
replace_existing = params[:replace_existing]
14+
15+
UI.message("Uploading #{assets.count} GitHub Release asset(s) to #{repository} #{version}.")
16+
17+
github_helper = Fastlane::Helper::GithubHelper.new(github_token: params[:github_token])
18+
url = github_helper.upload_release_assets(
19+
repository: repository,
20+
version: version,
21+
assets: assets,
22+
replace_existing: replace_existing
23+
)
24+
25+
UI.success("Successfully uploaded GitHub Release assets. You can see the release at '#{url}'")
26+
url
27+
end
28+
29+
def self.description
30+
'Uploads assets to an existing GitHub Release'
31+
end
32+
33+
def self.authors
34+
['Automattic']
35+
end
36+
37+
def self.return_value
38+
'The URL of the GitHub Release'
39+
end
40+
41+
def self.details
42+
'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.'
43+
end
44+
45+
def self.available_options
46+
[
47+
FastlaneCore::ConfigItem.new(key: :repository,
48+
description: 'The slug (`<org>/<repo>`) of the GitHub repository containing the release',
49+
optional: false,
50+
type: String,
51+
verify_block: proc do |value|
52+
UI.user_error!('Repository cannot be empty') if value.to_s.empty?
53+
end),
54+
FastlaneCore::ConfigItem.new(key: :version,
55+
description: 'The version of the release. Used as the git tag name',
56+
optional: false,
57+
type: String,
58+
verify_block: proc do |value|
59+
UI.user_error!('Version cannot be empty') if value.to_s.empty?
60+
end),
61+
FastlaneCore::ConfigItem.new(key: :release_assets,
62+
description: 'Assets to upload',
63+
type: Array,
64+
optional: false,
65+
verify_block: proc do |value|
66+
UI.user_error!('You must provide at least one release asset') if value.nil? || value.empty?
67+
value.each do |asset|
68+
UI.user_error!('release_assets must contain file paths') unless asset.is_a?(String) && !asset.empty?
69+
end
70+
end),
71+
FastlaneCore::ConfigItem.new(key: :replace_existing,
72+
description: 'True to delete existing release assets with matching filenames before uploading. False to fail if a matching asset exists',
73+
optional: true,
74+
default_value: true,
75+
type: Boolean),
76+
Fastlane::Helper::GithubHelper.github_token_config_item,
77+
]
78+
end
79+
80+
def self.is_supported?(platform)
81+
true
82+
end
83+
end
84+
end
85+
end

lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,58 @@ def create_release(repository:, version:, description:, assets:, prerelease:, is
192192
release[:html_url]
193193
end
194194

195+
# Returns the GitHub release matching a given tag/version, including draft releases.
196+
#
197+
# @param [String] repository The repository to fetch the GitHub release from. Typically a repo slug (<org>/<repo>).
198+
# @param [String] version The release version/tag to fetch.
199+
# @return [Sawyer::Resource] The matching GitHub Release.
200+
# @raise [Fastlane::UI::Error] UI.user_error! if the release does not exist.
201+
#
202+
def get_release(repository:, version:)
203+
release = client.releases(repository).find { |candidate| candidate.tag_name == version }
204+
return release unless release.nil?
205+
206+
UI.user_error!("Could not find GitHub Release for tag #{version} in #{repository}")
207+
end
208+
209+
# Uploads assets to an existing GitHub release, optionally replacing matching filenames.
210+
#
211+
# @param [String] repository The repository to upload the GitHub release assets to. Typically a repo slug (<org>/<repo>).
212+
# @param [String] version The release version/tag to upload assets to.
213+
# @param [Array<String>] assets List of local file paths to attach as release assets.
214+
# @param [TrueClass|FalseClass] replace_existing Delete existing same-filename assets before uploading. When false, fail if a matching asset exists.
215+
# @return [String] URL of the corresponding GitHub Release.
216+
# @raise [Fastlane::UI::Error] UI.user_error! if the release or any local asset file does not exist.
217+
#
218+
def upload_release_assets(repository:, version:, assets:, replace_existing: true)
219+
asset_paths = validate_release_assets!(assets)
220+
release = get_release(repository: repository, version: version)
221+
existing_assets = client.release_assets(release.url)
222+
223+
asset_paths.each do |file_path|
224+
file_name = File.basename(file_path)
225+
matching_assets = existing_assets.select { |asset| asset.name == file_name }
226+
227+
unless matching_assets.empty?
228+
if replace_existing
229+
matching_assets.each do |asset|
230+
UI.message("Deleting existing GitHub Release asset #{asset.name}")
231+
client.delete_release_asset(asset.url)
232+
end
233+
existing_assets -= matching_assets
234+
else
235+
UI.user_error!("GitHub Release #{version} already has an asset named #{file_name}. Set replace_existing: true to replace it.")
236+
end
237+
end
238+
239+
UI.message("Uploading #{file_path} to GitHub Release #{version}")
240+
uploaded_asset = client.upload_asset(release.url, file_path, content_type: 'application/octet-stream')
241+
existing_assets << uploaded_asset unless uploaded_asset.nil?
242+
end
243+
244+
release.html_url
245+
end
246+
195247
# Use the GitHub API to generate release notes based on the list of PRs between current tag and previous tag.
196248
# @note This API uses the `.github/release.yml` config file to classify the PRs by category in the generated list according to PR labels.
197249
#
@@ -368,6 +420,26 @@ def set_branch_protection(repository:, branch:, **options)
368420
client.protect_branch(repository, branch, options)
369421
end
370422

423+
def validate_release_assets!(assets)
424+
asset_paths = Array(assets)
425+
UI.user_error!('You must provide at least one release asset') if asset_paths.empty?
426+
427+
asset_paths.each do |file_path|
428+
UI.user_error!('release_assets must contain file paths') unless file_path.is_a?(String) && !file_path.empty?
429+
end
430+
431+
file_names = asset_paths.map { |file_path| File.basename(file_path) }
432+
UI.user_error!('release_assets must not contain duplicate filenames') if file_names.uniq.length != file_names.length
433+
434+
asset_paths.each do |file_path|
435+
UI.user_error!("Can't find file #{file_path}!") unless File.file?(file_path)
436+
end
437+
438+
asset_paths
439+
end
440+
441+
private :validate_release_assets!
442+
371443
# Convert a response from the `/branch-protection` API endpoint into a Hash
372444
# suitable to be returned and/or reused to pass to a subsequent `/branch-protection` API request
373445
# @param [Sawyer::Resource] response The API response returned by `#get_branch_protection` or `#set_branch_protection`

spec/github_helper_spec.rb

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -619,6 +619,190 @@ def create_release(is_draft:, assets: [], name: nil)
619619
end
620620
end
621621

622+
describe '#upload_release_assets' do
623+
let(:test_repo) { 'repo-test/project-test' }
624+
let(:test_version) { '1.0.0' }
625+
let(:release_url) { 'https://api.github.com/repos/repo-test/project-test/releases/123' }
626+
let(:release_html_url) { 'https://github.com/repo-test/project-test/releases/tag/1.0.0' }
627+
let(:release) { sawyer_resource_stub(url: release_url, html_url: release_html_url, tag_name: test_version) }
628+
let(:existing_assets) { [] }
629+
let(:uploaded_asset) { release_asset(name: 'test-app.zip', url: 'https://api.github.com/repos/repo-test/project-test/releases/assets/999') }
630+
let(:client) do
631+
instance_double(
632+
Octokit::Client,
633+
user: instance_double('User', name: 'test'),
634+
'auto_paginate=': nil
635+
)
636+
end
637+
let(:helper) do
638+
described_class.new(github_token: 'Fake-GitHubToken-123')
639+
end
640+
641+
before do
642+
allow(Octokit::Client).to receive(:new).and_return(client)
643+
allow(client).to receive(:releases).with(test_repo).and_return([release])
644+
allow(client).to receive(:release_assets).with(release_url).and_return(existing_assets)
645+
allow(client).to receive_messages(upload_asset: uploaded_asset, delete_release_asset: true)
646+
end
647+
648+
it 'fails clearly if the release does not exist' do
649+
allow(client).to receive(:releases).with(test_repo).and_return([])
650+
651+
with_tmp_file(named: 'test-app.zip') do |file_path|
652+
expect do
653+
upload_release_assets(assets: [file_path])
654+
end.to raise_error(FastlaneCore::Interface::FastlaneError, "Could not find GitHub Release for tag #{test_version} in #{test_repo}")
655+
end
656+
end
657+
658+
it 'fails clearly if an asset file does not exist' do
659+
expect(client).not_to receive(:releases)
660+
expect(client).not_to receive(:release_assets)
661+
expect(client).not_to receive(:upload_asset)
662+
663+
expect do
664+
upload_release_assets(assets: ['missing-file.zip'])
665+
end.to raise_error(FastlaneCore::Interface::FastlaneError, "Can't find file missing-file.zip!")
666+
end
667+
668+
it 'fails clearly if an asset is not a file path' do
669+
expect(client).not_to receive(:releases)
670+
expect(client).not_to receive(:release_assets)
671+
expect(client).not_to receive(:upload_asset)
672+
673+
expect do
674+
upload_release_assets(assets: [123])
675+
end.to raise_error(FastlaneCore::Interface::FastlaneError, 'release_assets must contain file paths')
676+
end
677+
678+
it 'fails without mutating GitHub when local assets have duplicate filenames' do
679+
in_tmp_dir do |tmpdir|
680+
first_dir = File.join(tmpdir, 'ios')
681+
second_dir = File.join(tmpdir, 'tvos')
682+
Dir.mkdir(first_dir)
683+
Dir.mkdir(second_dir)
684+
685+
first_file_path = File.join(first_dir, 'test-app.zip')
686+
second_file_path = File.join(second_dir, 'test-app.zip')
687+
File.write(first_file_path, 'ios')
688+
File.write(second_file_path, 'tvos')
689+
690+
expect(client).not_to receive(:releases)
691+
expect(client).not_to receive(:release_assets)
692+
expect(client).not_to receive(:delete_release_asset)
693+
expect(client).not_to receive(:upload_asset)
694+
695+
expect do
696+
upload_release_assets(assets: [first_file_path, second_file_path], replace_existing: false)
697+
end.to raise_error(FastlaneCore::Interface::FastlaneError, 'release_assets must not contain duplicate filenames')
698+
end
699+
end
700+
701+
it 'uploads assets to a draft release' do
702+
draft_release = sawyer_resource_stub(url: release_url, html_url: release_html_url, tag_name: test_version, draft: true)
703+
other_release = sawyer_resource_stub(url: 'https://api.github.com/repos/repo-test/project-test/releases/456', html_url: 'https://github.com/repo-test/project-test/releases/tag/0.9.0', tag_name: '0.9.0')
704+
705+
allow(client).to receive(:releases).with(test_repo).and_return([other_release, draft_release])
706+
allow(client).to receive(:release_assets).with(release_url).and_return([])
707+
708+
with_tmp_file(named: 'test-app.zip') do |file_path|
709+
expect(client).to receive(:upload_asset).with(release_url, file_path, { content_type: 'application/octet-stream' })
710+
711+
result = upload_release_assets(assets: [file_path])
712+
713+
expect(result).to eq(release_html_url)
714+
end
715+
end
716+
717+
it 'uploads one asset to the existing release' do
718+
with_tmp_file(named: 'test-app.zip') do |file_path|
719+
expect(client).to receive(:upload_asset).with(release_url, file_path, { content_type: 'application/octet-stream' })
720+
721+
result = upload_release_assets(assets: [file_path])
722+
723+
expect(result).to eq(release_html_url)
724+
end
725+
end
726+
727+
it 'uploads multiple assets to the existing release' do
728+
in_tmp_dir do |tmpdir|
729+
first_file_path = File.join(tmpdir, 'test-ios.zip')
730+
second_file_path = File.join(tmpdir, 'test-tvos.zip')
731+
File.write(first_file_path, 'ios')
732+
File.write(second_file_path, 'tvos')
733+
734+
first_uploaded_asset = release_asset(name: 'test-ios.zip', url: 'https://api.github.com/repos/repo-test/project-test/releases/assets/1000')
735+
second_uploaded_asset = release_asset(name: 'test-tvos.zip', url: 'https://api.github.com/repos/repo-test/project-test/releases/assets/1001')
736+
737+
expect(client).to receive(:upload_asset).with(release_url, first_file_path, { content_type: 'application/octet-stream' }).ordered.and_return(first_uploaded_asset)
738+
expect(client).to receive(:upload_asset).with(release_url, second_file_path, { content_type: 'application/octet-stream' }).ordered.and_return(second_uploaded_asset)
739+
740+
result = upload_release_assets(assets: [first_file_path, second_file_path])
741+
742+
expect(result).to eq(release_html_url)
743+
end
744+
end
745+
746+
it 'replaces an existing asset with the same filename' do
747+
existing_asset = release_asset(name: 'test-app.zip', url: 'https://api.github.com/repos/repo-test/project-test/releases/assets/1234')
748+
allow(client).to receive(:release_assets).with(release_url).and_return([existing_asset])
749+
750+
with_tmp_file(named: 'test-app.zip') do |file_path|
751+
expect(client).to receive(:delete_release_asset).with(existing_asset.url)
752+
expect(client).to receive(:upload_asset).with(release_url, file_path, { content_type: 'application/octet-stream' })
753+
754+
result = upload_release_assets(assets: [file_path])
755+
756+
expect(result).to eq(release_html_url)
757+
end
758+
end
759+
760+
it 'preserves unrelated existing assets' do
761+
matching_asset = release_asset(name: 'test-app.zip', url: 'https://api.github.com/repos/repo-test/project-test/releases/assets/1234')
762+
unrelated_asset = release_asset(name: 'other-platform.zip', url: 'https://api.github.com/repos/repo-test/project-test/releases/assets/5678')
763+
deleted_asset_urls = []
764+
765+
allow(client).to receive(:release_assets).with(release_url).and_return([matching_asset, unrelated_asset])
766+
allow(client).to receive(:delete_release_asset) do |asset_url|
767+
deleted_asset_urls << asset_url
768+
true
769+
end
770+
771+
with_tmp_file(named: 'test-app.zip') do |file_path|
772+
upload_release_assets(assets: [file_path])
773+
end
774+
775+
expect(deleted_asset_urls).to eq([matching_asset.url])
776+
end
777+
778+
it 'fails without deleting or uploading when replace_existing is false and a matching asset exists' do
779+
existing_asset = release_asset(name: 'test-app.zip', url: 'https://api.github.com/repos/repo-test/project-test/releases/assets/1234')
780+
allow(client).to receive(:release_assets).with(release_url).and_return([existing_asset])
781+
782+
expect(client).not_to receive(:delete_release_asset)
783+
expect(client).not_to receive(:upload_asset)
784+
785+
with_tmp_file(named: 'test-app.zip') do |file_path|
786+
expect do
787+
upload_release_assets(assets: [file_path], replace_existing: false)
788+
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.")
789+
end
790+
end
791+
792+
def upload_release_assets(assets:, replace_existing: true)
793+
helper.upload_release_assets(
794+
repository: test_repo,
795+
version: test_version,
796+
assets: assets,
797+
replace_existing: replace_existing
798+
)
799+
end
800+
801+
def release_asset(name:, url:)
802+
sawyer_resource_stub(name: name, url: url)
803+
end
804+
end
805+
622806
describe '#github_token_config_item' do
623807
it 'has the correct key' do
624808
expect(described_class.github_token_config_item.key).to eq(:github_token)

0 commit comments

Comments
 (0)