From cccfe3d4c483a70ec3d4ca68c67eed41873ff315 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 18 Aug 2026 07:27:58 -0400 Subject: [PATCH 1/4] ci(release): generate release notes against an explicit previous tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Release notes have restated every PR back to v0.16.0 since v0.17.1. GitHub infers the notes base by walking tags newest-to-oldest and taking the first whose commit is an ancestor of the one being released. Our release tags never satisfy that: each points at a Package.swift rewrite committed on a local release/vX.Y.Z branch that is never pushed, so no release tag is reachable from any other. GitHub falls back to v0.16.0 — the last tag that does sit on trunk, created before this flow existed. Resolve the base explicitly instead. set_github_release cannot express previous_tag_name, so call the generate-notes endpoint directly and pass the result through as `description`. The base is the most recent stable release older than the version being published; prereleases are skipped as candidates, matching GitHub's default. Resolution is also run in `validate`, before anything is published, so a wrong base surfaces while a re-run is still free. Co-Authored-By: Claude Opus 5 (1M context) --- docs/releases.md | 28 ++++++++++++++--- fastlane/Fastfile | 80 +++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 101 insertions(+), 7 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index 63e3aa92a..e260e5ae9 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -60,26 +60,46 @@ Step 1 prints the SHA of the version-bump commit it just pushed. Trigger a new B Pinning the commit matters — if you leave it blank, Buildkite resolves `trunk` to HEAD at trigger time, and a concurrent merge would tag the wrong commit. -The build runs a `:white_check_mark: Validate Swift release` step early on (gated on `NEW_VERSION`) that fast-fails if the tag name is malformed, or if the tag or GitHub Release already exists. After that, the `:rocket: Publish Swift release` step: +The build runs a `:white_check_mark: Validate Swift release` step early on (gated on `NEW_VERSION`) that fast-fails if the tag name is malformed, if the tag or GitHub Release already exists, or if no previous release tag can be resolved to generate notes against. It logs the tag the notes will be based on, so a wrong base surfaces before anything is published. After that, the `:rocket: Publish Swift release` step: 1. Rewrites `Package.swift` to consume the binary target via `.release(version:, checksum:)` 1. Uploads the XCFramework to `s3://a8c-apps-public-artifacts/gutenbergkit/vX.Y.Z/` 1. Commits the rewrite on a local `release/vX.Y.Z` branch (never pushed to origin), tags `vX.Y.Z`, and pushes **only the tag** — `git push ` carries the commit along with the tag ref, so the commit becomes reachable on origin via the tag alone +1. Generates release notes against the previous stable release tag (see [Release Notes](#release-notes)) 1. Creates the GitHub Release against the now-existing tag, uploading the XCFramework + checksum as assets (adds `--prerelease` when the version contains `-`) The tag is pushed before the GitHub Release is created. Once the tag is on origin, SPM consumers pinning `vX.Y.Z` can resolve a `Package.swift` that fetches the prebuilt XCFramework from CDN — the GH Release is metadata and an asset mirror on top of that. -The tag's commit lives off `trunk`'s history (parented on `trunk` but only reachable via the tag ref), matching the `pr-build/` snapshot-branch shape but published under a tag instead of a branch. +The tag's commit lives off `trunk`'s history (parented on `trunk` but only reachable via the tag ref), matching the `pr-build/` snapshot-branch shape but published under a tag instead of a branch. One consequence: release tags are not reachable from one another, so GitHub cannot infer which tag to generate release notes against and the release lane must pass one explicitly. See [Release Notes](#release-notes). ### Recovering from a partial publish If the build fails before the tag is pushed (validate, Package.swift rewrite, S3 upload, or local commit/tag), no tag exists and no consumer can resolve `vX.Y.Z`. Re-run Step 2 with the same `NEW_VERSION` once the underlying issue is fixed — `validate` will pass (no tag, no release), and S3 uploads are idempotent (`if_exists: :replace`). -If the build fails specifically on `gh release create` (tag pushed, but GH Release missing), the tag is the source of truth: SPM consumers resolving `vX.Y.Z` already work. To create the missing Release page, re-run `gh release create vX.Y.Z --title vX.Y.Z --generate-notes [--prerelease] ` manually against the existing tag — re-running the full Buildkite step would fail at `validate` because the tag now exists. +If the build fails specifically on `gh release create` (tag pushed, but GH Release missing), the tag is the source of truth: SPM consumers resolving `vX.Y.Z` already work. To create the missing Release page, run the following manually against the existing tag — re-running the full Buildkite step would fail at `validate` because the tag now exists. + +```bash +gh release create vX.Y.Z \ + --title vX.Y.Z \ + --generate-notes \ + --notes-start-tag vPREVIOUS \ + [--prerelease] \ + +``` + +`--notes-start-tag` is required, and `vPREVIOUS` must be the previous **stable** release (skip any intervening prereleases). Omitting it silently restates every release back to `v0.16.0` — see [Release Notes](#release-notes). ## Release Notes -GitHub automatically generates release notes when a release is created. Notes are organized into the following categories based on PR labels: +GitHub generates the release notes, but the release lane tells it explicitly which tag to generate them against — it does not let GitHub infer the base. + +GitHub's inference picks the most recent tag whose commit is an **ancestor** of the one being released. Our release tags never satisfy that: each one points at a `Package.swift` rewrite committed on a local `release/vX.Y.Z` branch that is never pushed, so no release tag is reachable from any other. Left to infer, GitHub falls back to the last tag that does sit on `trunk` — `v0.16.0` — and restates every PR merged since. So `previous_release_tag` in the `Fastfile` resolves the base instead: + +- The most recent **stable** release older than the version being published +- Prereleases are skipped as candidates, matching GitHub's default. A stable release therefore reports everything since the last stable release, including work already listed in its own alphas +- If no such release exists, the lane fails rather than publishing notes that might restate old releases + +Notes are organized into the following categories based on PR labels: - **Breaking Changes** — `[Type] Breaking Change` - **Features & Enhancements** — `[Type] Enhancement` diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 1b9ef3d35..57fe9057d 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -119,8 +119,15 @@ lane :validate do |options| UI.user_error!("Release #{version} already exists on GitHub.") unless release.nil? - # Clear lane-context values populated by `get_github_release` so a later - # action doesn't see stale state from this probe call. + # Resolve the release-notes base now, while nothing has been published yet. + # A wrong base produces notes that restate old releases — a silent failure + # once the release is live, but a cheap re-run if it surfaces here. + previous_tag = previous_release_tag(version: version, token: token) + UI.user_error!("Could not resolve a previous release tag for #{version}.") if previous_tag.nil? + UI.success("Release notes for #{version} will be generated against #{previous_tag}.") + + # Clear lane-context values populated by the probe calls above so a later + # action doesn't see stale state from them. [ SharedValues::GITHUB_API_RESPONSE, SharedValues::GITHUB_API_STATUS_CODE, @@ -160,12 +167,15 @@ lane :publish_release_to_github do |options| # metadata + an asset mirror — if this call fails the tag is unaffected # and an operator can recreate the Release manually against the existing # tag (see docs/releases.md). + # + # Notes use an explicit previous tag rather than `is_generate_release_notes`, + # which cannot express one. See `previous_release_tag`. set_github_release( api_token: token, repository_name: GITHUB_REPO, name: version, tag_name: version, - is_generate_release_notes: true, + description: generated_release_notes(version: version, token: token), is_prerelease: version.include?('-'), upload_assets: [xcframework_file_path, xcframework_checksum_file_path] ) @@ -252,6 +262,70 @@ def github_token!(options = {}) end end +# Resolve the tag that release notes for `version` should be generated against. +# +# GitHub's own inference picks the most recent tag whose commit is an *ancestor* +# of the target. Our release tags are each committed on a local `release/vX.Y.Z` +# branch that is never pushed, so no release tag is reachable from any other and +# the inference falls back to the last tag on `trunk` (`v0.16.0`), re-listing +# months of merged PRs. An explicit `previous_tag_name` sidesteps it. +# +# Prereleases are excluded as candidates, matching GitHub's default: a stable +# release reports everything since the last stable one, including work already +# listed in intervening alphas. +# +# Reads the Releases API rather than local tags: CI checkouts may not have +# fetched every tag, the API reports `prerelease` authoritatively, and it ignores +# stray tags never published as releases (e.g. `vtest-s3-xcframework-*`). +def previous_release_tag(version:, token:) + # Single unpaginated page. Releases come back newest-first, so this misses the + # preceding stable release only after 100 *consecutive* prereleases — far off + # at the current ratio. It fails safe: no candidate returns nil, and both + # callers hard-error rather than publishing notes against a wrong base. + releases = github_api( + api_token: token, + http_method: 'GET', + path: "/repos/#{GITHUB_REPO}/releases?per_page=100" + )[:json] + + candidates = releases.reject { |release| release['draft'] || release['prerelease'] } + .map { |release| release['tag_name'] } + .reject { |tag| tag == version } + .select { |tag| tag =~ /\Av\d+\.\d+\.\d+\z/ } + + target = Gem::Version.new(version.delete_prefix('v').split('-').first) + candidates.select { |tag| Gem::Version.new(tag.delete_prefix('v')) < target } + .max_by { |tag| Gem::Version.new(tag.delete_prefix('v')) } +end + +# Build the release body via GitHub's notes generator, pinned to an explicit +# previous tag. `set_github_release`'s `is_generate_release_notes` cannot express +# `previous_tag_name`, so call the endpoint directly and pass the result through +# as `description`. +# +# Fails loudly rather than falling back to auto-generated notes: a wrong base +# looks like a successful release, caught only by someone reading the page later. +def generated_release_notes(version:, token:) + previous_tag = previous_release_tag(version: version, token: token) + UI.user_error!("Could not resolve a previous release tag for #{version}; refusing to publish notes that may restate old releases.") \ + if previous_tag.nil? + + UI.message("Generating release notes for #{version} against previous tag #{previous_tag}.") + + response = github_api( + api_token: token, + http_method: 'POST', + path: "/repos/#{GITHUB_REPO}/releases/generate-notes", + body: { tag_name: version, previous_tag_name: previous_tag } + ) + + notes = response[:json]['body'] + UI.user_error!("GitHub returned empty release notes for #{version} (status #{response[:status]}).") \ + if notes.nil? || notes.strip.empty? + + notes +end + def require_env_vars!(*keys) keys.each { |key| get_required_env!(key) } end From c608c5e4e5015e0fc02ae68bcfb71679da28c60d Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 18 Aug 2026 16:02:39 -0400 Subject: [PATCH 2/4] ci(release): generate notes via the release-toolkit helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hand-rolled `releases/generate-notes` API call with the release-toolkit's `GithubHelper#generate_release_notes`, and fold the duplicated previous-tag nil check into a `previous_release_tag!` wrapper shared by `validate` and the publish lane. Uses `GithubHelper` rather than the `get_prs_between_tags` action that wraps it: the action rescues every `StandardError` and returns the message as the changelog, which would publish a release whose notes read "❌ Error computing the list of PRs…". The tag is already pushed by that point, so the failure needs to fail the step instead. Co-Authored-By: Claude Opus 5 (1M context) --- fastlane/Fastfile | 41 +++++++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 57fe9057d..64ae51e4a 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -122,8 +122,7 @@ lane :validate do |options| # Resolve the release-notes base now, while nothing has been published yet. # A wrong base produces notes that restate old releases — a silent failure # once the release is live, but a cheap re-run if it surfaces here. - previous_tag = previous_release_tag(version: version, token: token) - UI.user_error!("Could not resolve a previous release tag for #{version}.") if previous_tag.nil? + previous_tag = previous_release_tag!(version: version, token: token) UI.success("Release notes for #{version} will be generated against #{previous_tag}.") # Clear lane-context values populated by the probe calls above so a later @@ -298,32 +297,34 @@ def previous_release_tag(version:, token:) .max_by { |tag| Gem::Version.new(tag.delete_prefix('v')) } end -# Build the release body via GitHub's notes generator, pinned to an explicit -# previous tag. `set_github_release`'s `is_generate_release_notes` cannot express -# `previous_tag_name`, so call the endpoint directly and pass the result through -# as `description`. +# `previous_release_tag`, erroring instead of returning nil. # -# Fails loudly rather than falling back to auto-generated notes: a wrong base -# looks like a successful release, caught only by someone reading the page later. -def generated_release_notes(version:, token:) +# Falling back to GitHub's inference would restate every release back to +# `v0.16.0` — a silent failure that looks like a successful publish and is only +# caught by someone reading the release page later. +def previous_release_tag!(version:, token:) previous_tag = previous_release_tag(version: version, token: token) UI.user_error!("Could not resolve a previous release tag for #{version}; refusing to publish notes that may restate old releases.") \ if previous_tag.nil? UI.message("Generating release notes for #{version} against previous tag #{previous_tag}.") + previous_tag +end - response = github_api( - api_token: token, - http_method: 'POST', - path: "/repos/#{GITHUB_REPO}/releases/generate-notes", - body: { tag_name: version, previous_tag_name: previous_tag } +# Build the release body via GitHub's notes generator, pinned to an explicit +# previous tag. `set_github_release`'s `is_generate_release_notes` cannot express +# one. +# +# Uses `GithubHelper` rather than the `get_prs_between_tags` action that wraps it: +# the action rescues every `StandardError` and returns the message as the +# changelog, publishing a release whose notes read "❌ Error computing the list of +# PRs…". The tag is already pushed by this point, so this must fail the step. +def generated_release_notes(version:, token:) + Fastlane::Helper::GithubHelper.new(github_token: token).generate_release_notes( + repository: GITHUB_REPO, + tag_name: version, + previous_tag: previous_release_tag!(version: version, token: token) ) - - notes = response[:json]['body'] - UI.user_error!("GitHub returned empty release notes for #{version} (status #{response[:status]}).") \ - if notes.nil? || notes.strip.empty? - - notes end def require_env_vars!(*keys) From 223f319983b0afce20e9e960339ae8cd2845f7ae Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 19 Aug 2026 12:34:43 -0400 Subject: [PATCH 3/4] refactor(release): parse each candidate tag version once `previous_release_tag` built a `Gem::Version` three times per candidate: once to compare against the target, once to rank, and once more in the regex filter's neighbouring block. Pair each tag with its parsed version up front, then destructure in the comparison blocks. The pairing keeps the tag string available, so the method still returns `v0.19.0` rather than the bare `0.19.0` that `previous_tag_name` rejects with a 400. Co-Authored-By: Claude Opus 5 (1M context) --- fastlane/Fastfile | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 64ae51e4a..6cff4257e 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -290,11 +290,13 @@ def previous_release_tag(version:, token:) candidates = releases.reject { |release| release['draft'] || release['prerelease'] } .map { |release| release['tag_name'] } .reject { |tag| tag == version } - .select { |tag| tag =~ /\Av\d+\.\d+\.\d+\z/ } + .grep(/\Av\d+\.\d+\.\d+\z/) + .map { |tag| [tag, Gem::Version.new(tag.delete_prefix('v'))] } target = Gem::Version.new(version.delete_prefix('v').split('-').first) - candidates.select { |tag| Gem::Version.new(tag.delete_prefix('v')) < target } - .max_by { |tag| Gem::Version.new(tag.delete_prefix('v')) } + candidates.select { |_tag, tag_version| tag_version < target } + .max_by { |_tag, tag_version| tag_version } + &.first end # `previous_release_tag`, erroring instead of returning nil. From 7614c35d3b35c7597e96e72dfac038670c8386a0 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 19 Aug 2026 13:15:08 -0400 Subject: [PATCH 4/4] docs: Clarify temporary code Note the trigger for refactoring this code to avoid this code lingering for longer than necessary. Co-authored-by: Olivier Halligon --- fastlane/Fastfile | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 6cff4257e..416f56cb8 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -317,10 +317,9 @@ end # previous tag. `set_github_release`'s `is_generate_release_notes` cannot express # one. # -# Uses `GithubHelper` rather than the `get_prs_between_tags` action that wraps it: -# the action rescues every `StandardError` and returns the message as the -# changelog, publishing a release whose notes read "❌ Error computing the list of -# PRs…". The tag is already pushed by this point, so this must fail the step. +# Uses `GithubHelper` rather than the `get_prs_between_tags` action that wraps it +# until https://github.com/wordpress-mobile/release-toolkit/pull/772 gets ships in the next release-toolkit +# version (at which point we'll be able to use the action and its new `fail_on_error: true` parameter) def generated_release_notes(version:, token:) Fastlane::Helper::GithubHelper.new(github_token: token).generate_release_notes( repository: GITHUB_REPO,