Skip to content
Merged
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 @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
AliSoftware marked this conversation as resolved.
(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
Expand Down
44 changes: 40 additions & 4 deletions lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 (<org>/<repo>).
# @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<Sawyer::Resource>] 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)
Expand All @@ -259,6 +287,7 @@ def release_for_tag(repository:, version:)
nil
end

private :matching_releases
private :find_release
private :release_for_tag

Expand Down Expand Up @@ -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
Comment thread
AliSoftware marked this conversation as resolved.
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,
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 @@ -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([])
Expand Down Expand Up @@ -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)
Expand Down