Skip to content

Commit 228263c

Browse files
committed
Add GitHub release asset upload action
1 parent de7ff74 commit 228263c

5 files changed

Lines changed: 362 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ _None_
1111
### New Features
1212

1313
- Added `find_or_create_pull_request` action and `GithubHelper#find_pull_request`: returns the URL of the open Pull Request for a head branch, creating one only if none exists yet. Useful for "rolling" automations (e.g. a daily translations or dependency-update job) that force-push the same head branch on every run. [#733]
14+
- Added `upload_github_release_assets` action and `GithubHelper#upload_release_assets` to upload or retry replacing assets on an existing GitHub release without disturbing unrelated assets. [#743]
1415

1516
### Bug Fixes
1617

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: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,57 @@ 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.
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+
client.release_for_tag(repository, version)
204+
rescue Octokit::NotFound
205+
UI.user_error!("Could not find GitHub Release for tag #{version} in #{repository}")
206+
end
207+
208+
# Uploads assets to an existing GitHub release, optionally replacing matching filenames.
209+
#
210+
# @param [String] repository The repository to upload the GitHub release assets to. Typically a repo slug (<org>/<repo>).
211+
# @param [String] version The release version/tag to upload assets to.
212+
# @param [Array<String>] assets List of local file paths to attach as release assets.
213+
# @param [TrueClass|FalseClass] replace_existing Delete existing same-filename assets before uploading. When false, fail if a matching asset exists.
214+
# @return [String] URL of the corresponding GitHub Release.
215+
# @raise [Fastlane::UI::Error] UI.user_error! if the release or any local asset file does not exist.
216+
#
217+
def upload_release_assets(repository:, version:, assets:, replace_existing: true)
218+
asset_paths = validate_release_assets!(assets)
219+
release = get_release(repository: repository, version: version)
220+
existing_assets = client.release_assets(release.url)
221+
222+
asset_paths.each do |file_path|
223+
file_name = File.basename(file_path)
224+
matching_assets = existing_assets.select { |asset| asset.name == file_name }
225+
226+
unless matching_assets.empty?
227+
if replace_existing
228+
matching_assets.each do |asset|
229+
UI.message("Deleting existing GitHub Release asset #{asset.name}")
230+
client.delete_release_asset(asset.url)
231+
end
232+
existing_assets -= matching_assets
233+
else
234+
UI.user_error!("GitHub Release #{version} already has an asset named #{file_name}. Set replace_existing: true to replace it.")
235+
end
236+
end
237+
238+
UI.message("Uploading #{file_path} to GitHub Release #{version}")
239+
uploaded_asset = client.upload_asset(release.url, file_path, content_type: 'application/octet-stream')
240+
existing_assets << uploaded_asset unless uploaded_asset.nil?
241+
end
242+
243+
release.html_url
244+
end
245+
195246
# Use the GitHub API to generate release notes based on the list of PRs between current tag and previous tag.
196247
# @note This API uses the `.github/release.yml` config file to classify the PRs by category in the generated list according to PR labels.
197248
#
@@ -368,6 +419,19 @@ def set_branch_protection(repository:, branch:, **options)
368419
client.protect_branch(repository, branch, options)
369420
end
370421

422+
def validate_release_assets!(assets)
423+
asset_paths = Array(assets)
424+
UI.user_error!('You must provide at least one release asset') if asset_paths.empty?
425+
426+
asset_paths.each do |file_path|
427+
UI.user_error!("Can't find file #{file_path}!") unless File.file?(file_path)
428+
end
429+
430+
asset_paths
431+
end
432+
433+
private :validate_release_assets!
434+
371435
# Convert a response from the `/branch-protection` API endpoint into a Hash
372436
# suitable to be returned and/or reused to pass to a subsequent `/branch-protection` API request
373437
# @param [Sawyer::Resource] response The API response returned by `#get_branch_protection` or `#set_branch_protection`

spec/github_helper_spec.rb

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -619,6 +619,141 @@ 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) }
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(:release_for_tag).with(test_repo, test_version).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(:release_for_tag).with(test_repo, test_version).and_raise(Octokit::NotFound)
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(:release_for_tag)
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 'uploads one asset to the existing release' do
669+
with_tmp_file(named: 'test-app.zip') do |file_path|
670+
expect(client).to receive(:upload_asset).with(release_url, file_path, { content_type: 'application/octet-stream' })
671+
672+
result = upload_release_assets(assets: [file_path])
673+
674+
expect(result).to eq(release_html_url)
675+
end
676+
end
677+
678+
it 'uploads multiple assets to the existing release' do
679+
in_tmp_dir do |tmpdir|
680+
first_file_path = File.join(tmpdir, 'test-ios.zip')
681+
second_file_path = File.join(tmpdir, 'test-tvos.zip')
682+
File.write(first_file_path, 'ios')
683+
File.write(second_file_path, 'tvos')
684+
685+
first_uploaded_asset = release_asset(name: 'test-ios.zip', url: 'https://api.github.com/repos/repo-test/project-test/releases/assets/1000')
686+
second_uploaded_asset = release_asset(name: 'test-tvos.zip', url: 'https://api.github.com/repos/repo-test/project-test/releases/assets/1001')
687+
688+
expect(client).to receive(:upload_asset).with(release_url, first_file_path, { content_type: 'application/octet-stream' }).ordered.and_return(first_uploaded_asset)
689+
expect(client).to receive(:upload_asset).with(release_url, second_file_path, { content_type: 'application/octet-stream' }).ordered.and_return(second_uploaded_asset)
690+
691+
result = upload_release_assets(assets: [first_file_path, second_file_path])
692+
693+
expect(result).to eq(release_html_url)
694+
end
695+
end
696+
697+
it 'replaces an existing asset with the same filename' do
698+
existing_asset = release_asset(name: 'test-app.zip', url: 'https://api.github.com/repos/repo-test/project-test/releases/assets/1234')
699+
allow(client).to receive(:release_assets).with(release_url).and_return([existing_asset])
700+
701+
with_tmp_file(named: 'test-app.zip') do |file_path|
702+
expect(client).to receive(:delete_release_asset).with(existing_asset.url)
703+
expect(client).to receive(:upload_asset).with(release_url, file_path, { content_type: 'application/octet-stream' })
704+
705+
result = upload_release_assets(assets: [file_path])
706+
707+
expect(result).to eq(release_html_url)
708+
end
709+
end
710+
711+
it 'preserves unrelated existing assets' do
712+
matching_asset = release_asset(name: 'test-app.zip', url: 'https://api.github.com/repos/repo-test/project-test/releases/assets/1234')
713+
unrelated_asset = release_asset(name: 'other-platform.zip', url: 'https://api.github.com/repos/repo-test/project-test/releases/assets/5678')
714+
deleted_asset_urls = []
715+
716+
allow(client).to receive(:release_assets).with(release_url).and_return([matching_asset, unrelated_asset])
717+
allow(client).to receive(:delete_release_asset) do |asset_url|
718+
deleted_asset_urls << asset_url
719+
true
720+
end
721+
722+
with_tmp_file(named: 'test-app.zip') do |file_path|
723+
upload_release_assets(assets: [file_path])
724+
end
725+
726+
expect(deleted_asset_urls).to eq([matching_asset.url])
727+
end
728+
729+
it 'fails without deleting or uploading when replace_existing is false and a matching asset exists' do
730+
existing_asset = release_asset(name: 'test-app.zip', url: 'https://api.github.com/repos/repo-test/project-test/releases/assets/1234')
731+
allow(client).to receive(:release_assets).with(release_url).and_return([existing_asset])
732+
733+
expect(client).not_to receive(:delete_release_asset)
734+
expect(client).not_to receive(:upload_asset)
735+
736+
with_tmp_file(named: 'test-app.zip') do |file_path|
737+
expect do
738+
upload_release_assets(assets: [file_path], replace_existing: false)
739+
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.")
740+
end
741+
end
742+
743+
def upload_release_assets(assets:, replace_existing: true)
744+
helper.upload_release_assets(
745+
repository: test_repo,
746+
version: test_version,
747+
assets: assets,
748+
replace_existing: replace_existing
749+
)
750+
end
751+
752+
def release_asset(name:, url:)
753+
sawyer_resource_stub(name: name, url: url)
754+
end
755+
end
756+
622757
describe '#github_token_config_item' do
623758
it 'has the correct key' do
624759
expect(described_class.github_token_config_item.key).to eq(:github_token)
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# frozen_string_literal: true
2+
3+
require 'spec_helper'
4+
5+
describe Fastlane::Actions::UploadGithubReleaseAssetsAction do
6+
let(:test_token) { 'ghp_fake_token' }
7+
let(:test_repo) { 'repo-test/project-test' }
8+
let(:test_version) { '1.0.0' }
9+
let(:test_assets) { ['builds/test-app.zip'] }
10+
let(:test_url) { 'https://github.com/repo-test/project-test/releases/tag/1.0.0' }
11+
let(:github_helper) { instance_double(Fastlane::Helper::GithubHelper) }
12+
13+
before do
14+
allow(Fastlane::Helper::GithubHelper).to receive(:new).with(github_token: test_token).and_return(github_helper)
15+
end
16+
17+
it 'uploads release assets and returns the release URL' do
18+
allow(github_helper).to receive(:upload_release_assets).and_return(test_url)
19+
expect(github_helper).to receive(:upload_release_assets).with(
20+
repository: test_repo,
21+
version: test_version,
22+
assets: test_assets,
23+
replace_existing: true
24+
)
25+
26+
result = run_described_fastlane_action(
27+
github_token: test_token,
28+
repository: test_repo,
29+
version: test_version,
30+
release_assets: test_assets
31+
)
32+
33+
expect(result).to eq(test_url)
34+
end
35+
36+
it 'forwards replace_existing when provided' do
37+
allow(github_helper).to receive(:upload_release_assets).and_return(test_url)
38+
expect(github_helper).to receive(:upload_release_assets).with(
39+
repository: test_repo,
40+
version: test_version,
41+
assets: test_assets,
42+
replace_existing: false
43+
)
44+
45+
result = run_described_fastlane_action(
46+
github_token: test_token,
47+
repository: test_repo,
48+
version: test_version,
49+
release_assets: test_assets,
50+
replace_existing: false
51+
)
52+
53+
expect(result).to eq(test_url)
54+
end
55+
56+
it 'fails when release_assets is empty' do
57+
expect do
58+
run_described_fastlane_action(
59+
github_token: test_token,
60+
repository: test_repo,
61+
version: test_version,
62+
release_assets: []
63+
)
64+
end.to raise_error(FastlaneCore::Interface::FastlaneError, 'You must provide at least one release asset')
65+
end
66+
67+
it 'fails when release_assets contains a non-path value' do
68+
expect do
69+
run_described_fastlane_action(
70+
github_token: test_token,
71+
repository: test_repo,
72+
version: test_version,
73+
release_assets: [123]
74+
)
75+
end.to raise_error(FastlaneCore::Interface::FastlaneError, 'release_assets must contain file paths')
76+
end
77+
end

0 commit comments

Comments
 (0)