diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a44445e4..c82d29e85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ _None_ ### Bug Fixes -_None_ +- `publish_github_release` now publishes the most recently created GitHub Release when several share the same name, instead of whichever one the GitHub API happened to list first. It also warns when it finds more than one match, or when the release it publishes turns out to have been published already. The tag-based release lookup used by `upload_github_release_assets` is deterministic too, preferring the published release that owns the tag, and falling back to the most recently created draft when no published release claims it. [#763] ### Internal Changes diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/publish_github_release_action.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/publish_github_release_action.rb index 1d046d415..3f76bedc7 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/publish_github_release_action.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/publish_github_release_action.rb @@ -35,7 +35,14 @@ def self.return_value end def self.details - 'Publish an existing GitHub Release still in draft mode' + <<~DETAILS + Publish an existing draft GitHub Release. + + If several GitHub Releases share the same `name`, the most recently created one is published, + as it is the one targeting the latest commit of the release branch. + (Multiple releases can exist with the same `name` when the release finalization automation + is run more than once for the same version, each run creating its own draft.) + DETAILS end def self.available_options diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb b/lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb index da4160295..9402794db 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb @@ -244,8 +244,36 @@ def upload_release_assets(repository:, version:, assets:, replace_existing: true release.html_url end + # Returns all the GitHub Releases of a repository matching a given criteria, sorted from the oldest to the most recent one. + # + # @param [String] repository The repository to fetch the GitHub Releases from. Typically a repo slug (/). + # @yield [Sawyer::Resource] Each GitHub Release of the repository, to decide whether it matches the criteria. + # @yieldreturn [TrueClass|FalseClass] `true` to include that GitHub Release in the returned list. + # @return [Array] The matching GitHub Releases, sorted from the oldest to the most recently created one. + # + # @note A repository can legitimately host several GitHub Releases sharing the same name or tag—e.g. when `finalize_release` + # is run more than once for the same version, creating one draft per run—and the GitHub API makes no promise about the + # order in which it returns them. Callers must thus pick explicitly amongst the matches instead of relying on that order. + # @note The sort key is the release `id`, not `created_at`: once a GitHub Release is published, GitHub rewrites its `created_at` + # to the date of the commit it targets, so `created_at` is not a reliable creation timestamp. Release `id`s, on the other + # hand, are assigned by GitHub in increasing order as releases are created, including for drafts. + # + def matching_releases(repository:, &matcher) + client.releases(repository).select(&matcher).sort_by { |release| release[:id] } + end + + # Returns the GitHub Release associated with a given tag, if any, including draft ones. + # + # @note Unlike a lookup by name, "the most recently created match" is not the right answer here: a git tag can + # only ever back a single *published* release, so if one of the matches is not a draft then it is the + # release owning that tag, and a more recent draft sharing the same `tag_name` is a leftover—typically + # from a re-run of the release finalization—rather than a successor. Only when no published release + # claims the tag yet do we fall back to the most recent draft, which is the case when uploading assets + # to a release that has not been published yet. + # def find_release(repository:, version:) - release = client.releases(repository).find { |candidate| candidate.tag_name == version } + matches = matching_releases(repository: repository) { |candidate| candidate.tag_name == version } + release = matches.reject { |candidate| candidate[:draft] }.last || matches.last return release unless release.nil? release_for_tag(repository: repository, version: version) @@ -259,6 +287,7 @@ def release_for_tag(repository:, version:) nil end + private :matching_releases private :find_release private :release_for_tag @@ -306,12 +335,19 @@ def get_release_url(repository:, tag_name:) # @param [Boolean] prerelease Indicates if this should be created as a pre-release (i.e. for alpha/beta) # # @return [String] URL of the corresponding GitHub Release + # @raise [Fastlane::UI::Error] UI.user_error! if no GitHub Release with that name exists. + # + # @note If several GitHub Releases share that same `name`—which happens when `finalize_release` is run more than once + # for the same version, each run creating its own draft—the most recently created one is the one being published, + # as it is the one targeting the latest commit of the release branch. The other, staler ones are left untouched. # def publish_release(repository:, name:, prerelease: nil) - releases = client.releases(repository) - release = releases.find { |r| r.name == name } + releases = matching_releases(repository: repository) { |release| release.name == name } + UI.user_error!("No release found with name #{name}") if releases.empty? - UI.user_error!("No release found with name #{name}") unless release + release = releases.last + UI.important("Found #{releases.count} GitHub Releases named `#{name}`. Publishing the most recently created one, targeting #{release.target_commitish}, and leaving the #{releases.count - 1} older one(s) untouched.") if releases.count > 1 + UI.important("The most recent GitHub Release named `#{name}` (#{release.html_url}) has already been published. Updating it nonetheless.") unless release.draft client.update_release( release.url, diff --git a/spec/github_helper_spec.rb b/spec/github_helper_spec.rb index 90e4e7ede..e6f30b519 100644 --- a/spec/github_helper_spec.rb +++ b/spec/github_helper_spec.rb @@ -733,6 +733,43 @@ def create_release(is_draft:, assets: [], name: nil) end end + it 'uses the most recently created release when several share the same tag' do + stale_release = sawyer_resource_stub(id: 1, url: 'stale-api-url', html_url: 'stale-html-url', tag_name: test_version, draft: true) + latest_release = sawyer_resource_stub(id: 2, url: 'latest-api-url', html_url: 'latest-html-url', tag_name: test_version, draft: true) + + allow(client).to receive(:releases).with(test_repo).and_return([latest_release, stale_release]) + expect(client).not_to receive(:release_for_tag) + allow(client).to receive(:release_assets).with(latest_release.url).and_return([]) + + with_tmp_file(named: 'test-app.zip') do |file_path| + expect(client).to receive(:upload_asset).with(latest_release.url, file_path, { content_type: 'application/octet-stream' }) + + result = upload_release_assets(assets: [file_path]) + + expect(result).to eq(latest_release.html_url) + end + end + + it 'uses the release owning the tag rather than a more recent draft sharing it' do + # The state woocommerce-ios is left in after the 25.1 incident: the release that was published and owns the + # `25.1` tag, plus the never-cleaned-up draft created by the second `finalize_release` run, which has a higher + # id. A tag can only back one published release, so the published one wins despite being the older of the two. + published_release = sawyer_resource_stub(id: 351_929_162, url: 'published-api-url', html_url: 'published-html-url', tag_name: test_version, draft: false) + leftover_draft = sawyer_resource_stub(id: 352_002_205, url: 'draft-api-url', html_url: 'draft-html-url', tag_name: test_version, draft: true) + + allow(client).to receive(:releases).with(test_repo).and_return([published_release, leftover_draft]) + allow(client).to receive(:release_assets).with(published_release.url).and_return([]) + + with_tmp_file(named: 'test-app.zip') do |file_path| + expect(client).to receive(:upload_asset).with(published_release.url, file_path, { content_type: 'application/octet-stream' }) + expect(client).not_to receive(:upload_asset).with(leftover_draft.url, any_args) + + result = upload_release_assets(assets: [file_path]) + + expect(result).to eq(published_release.html_url) + end + end + it 'falls back to the direct release-by-tag lookup when the release list misses' do with_tmp_file(named: 'test-app.zip') do |file_path| allow(client).to receive(:releases).with(test_repo).and_return([]) @@ -848,6 +885,137 @@ def release_asset(name:, url:) end end + describe '#publish_release' do + let(:test_repo) { 'repo-test/project-test' } + let(:test_name) { '25.1' } + let(:client) do + instance_double( + Octokit::Client, + user: instance_double('User', name: 'test'), + 'auto_paginate=': nil + ) + end + let(:helper) { described_class.new(github_token: 'Fake-GitHubToken-123') } + + # Those two mirror the two GitHub Releases both named `25.1` that caused the WCiOS 25.1 incident: one draft per + # `finalize_release` run, the older one targeting the commit of the first run, the newer one that of the second run. + let(:stale_release) { github_release(id: 351_929_162, target_commitish: 'dbd800a', created_at: '2026-07-10T06:18:29Z') } + let(:latest_release) { github_release(id: 352_002_205, target_commitish: '558354d', created_at: '2026-07-10T09:27:00Z') } + + before do + allow(Octokit::Client).to receive(:new).and_return(client) + allow(client).to receive(:update_release) + end + + it 'fails clearly if no release matches the name' do + allow(client).to receive(:releases).with(test_repo).and_return([github_release(id: 1, name: '25.2')]) + + expect(client).not_to receive(:update_release) + expect { publish_release }.to raise_error(FastlaneCore::Interface::FastlaneError, "No release found with name #{test_name}") + end + + it 'publishes the release matching the name, ignoring the ones named differently' do + allow(client).to receive(:releases).with(test_repo).and_return([github_release(id: 1, name: '25.2'), latest_release]) + + expect(client).to receive(:update_release).with(latest_release.url, { draft: false }) + + expect(publish_release).to eq(latest_release.html_url) + end + + it 'publishes the most recently created release when several share the same name' do + allow(client).to receive(:releases).with(test_repo).and_return([stale_release, latest_release]) + + expect(client).to receive(:update_release).with(latest_release.url, { draft: false }) + + expect(publish_release).to eq(latest_release.html_url) + end + + it 'publishes the most recently created release regardless of the order the API lists them in' do + allow(client).to receive(:releases).with(test_repo).and_return([latest_release, stale_release]) + + expect(client).to receive(:update_release).with(latest_release.url, { draft: false }) + + expect(publish_release).to eq(latest_release.html_url) + end + + it 'does not rely on `created_at`, which GitHub rewrites to the target commit date on publish' do + # A published release reports the date of the commit it targets as its `created_at`, which can make it look more + # recent than a draft created after it. Only the release `id` reflects the order in which releases were created. + published_release = github_release(id: 351_929_162, draft: false, created_at: '2026-07-10T23:00:00Z') + newer_draft = github_release(id: 352_002_205, created_at: '2026-07-10T09:27:00Z') + allow(client).to receive(:releases).with(test_repo).and_return([published_release, newer_draft]) + + expect(client).to receive(:update_release).with(newer_draft.url, { draft: false }) + + expect(publish_release).to eq(newer_draft.html_url) + end + + it 'warns when several releases share the same name' do + allow(client).to receive(:releases).with(test_repo).and_return([stale_release, latest_release]) + + expect(Fastlane::UI).to receive(:important).with(/Found 2 GitHub Releases named `25\.1`.*558354d.*1 older one/) + + publish_release + end + + it 'warns when the release it publishes has already been published' do + allow(client).to receive(:releases).with(test_repo).and_return([github_release(id: 1, draft: false)]) + + expect(Fastlane::UI).to receive(:important).with(/has already been published/) + + publish_release + end + + it 'does not warn when publishing a single draft release' do + allow(client).to receive(:releases).with(test_repo).and_return([latest_release]) + + expect(Fastlane::UI).not_to receive(:important) + + publish_release + end + + it 'keeps the prerelease status of the draft when no prerelease value is provided' do + allow(client).to receive(:releases).with(test_repo).and_return([latest_release]) + + expect(client).to receive(:update_release).with(latest_release.url, { draft: false }) + + publish_release + end + + it 'publishes as a prerelease when requested' do + allow(client).to receive(:releases).with(test_repo).and_return([latest_release]) + + expect(client).to receive(:update_release).with(latest_release.url, { draft: false, prerelease: true }) + + publish_release(prerelease: true) + end + + it 'publishes as a final release when prerelease is explicitly false' do + allow(client).to receive(:releases).with(test_repo).and_return([latest_release]) + + expect(client).to receive(:update_release).with(latest_release.url, { draft: false, prerelease: false }) + + publish_release(prerelease: false) + end + + def github_release(id:, name: test_name, draft: true, target_commitish: 'deadbeef', created_at: '2026-07-10T09:27:00Z') + sawyer_resource_stub( + id: id, + name: name, + draft: draft, + target_commitish: target_commitish, + created_at: created_at, + url: "https://api.github.com/repos/#{test_repo}/releases/#{id}", + # GitHub only assigns a tag-based URL to a release once it is published; drafts get an `untagged-*` one + html_url: draft ? "https://github.com/#{test_repo}/releases/tag/untagged-#{id}" : "https://github.com/#{test_repo}/releases/tag/#{name}" + ) + end + + def publish_release(prerelease: nil) + helper.publish_release(repository: test_repo, name: test_name, prerelease: prerelease) + 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)