Skip to content

Commit 528e52e

Browse files
dcalhounclaude
andcommitted
ci(release): generate release notes against an explicit previous tag
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) <noreply@anthropic.com>
1 parent 9c1cf29 commit 528e52e

2 files changed

Lines changed: 101 additions & 7 deletions

File tree

‎docs/releases.md‎

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,26 +60,46 @@ Step 1 prints the SHA of the version-bump commit it just pushed. Trigger a new B
6060

6161
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.
6262

63-
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:
63+
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:
6464

6565
1. Rewrites `Package.swift` to consume the binary target via `.release(version:, checksum:)`
6666
1. Uploads the XCFramework to `s3://a8c-apps-public-artifacts/gutenbergkit/vX.Y.Z/`
6767
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 <tag>` carries the commit along with the tag ref, so the commit becomes reachable on origin via the tag alone
68+
1. Generates release notes against the previous stable release tag (see [Release Notes](#release-notes))
6869
1. Creates the GitHub Release against the now-existing tag, uploading the XCFramework + checksum as assets (adds `--prerelease` when the version contains `-`)
6970

7071
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.
7172

72-
The tag's commit lives off `trunk`'s history (parented on `trunk` but only reachable via the tag ref), matching the `pr-build/<n>` snapshot-branch shape but published under a tag instead of a branch.
73+
The tag's commit lives off `trunk`'s history (parented on `trunk` but only reachable via the tag ref), matching the `pr-build/<n>` 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).
7374

7475
### Recovering from a partial publish
7576

7677
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`).
7778

78-
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] <xcframework.zip> <checksum.txt>` manually against the existing tag — re-running the full Buildkite step would fail at `validate` because the tag now exists.
79+
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.
80+
81+
```bash
82+
gh release create vX.Y.Z \
83+
--title vX.Y.Z \
84+
--generate-notes \
85+
--notes-start-tag vPREVIOUS \
86+
[--prerelease] \
87+
<xcframework.zip> <checksum.txt>
88+
```
89+
90+
`--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).
7991

8092
## Release Notes
8193

82-
GitHub automatically generates release notes when a release is created. Notes are organized into the following categories based on PR labels:
94+
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.
95+
96+
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:
97+
98+
- The most recent **stable** release older than the version being published
99+
- 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
100+
- If no such release exists, the lane fails rather than publishing notes that might restate old releases
101+
102+
Notes are organized into the following categories based on PR labels:
83103

84104
- **Breaking Changes** — `[Type] Breaking Change`
85105
- **Features & Enhancements** — `[Type] Enhancement`

‎fastlane/Fastfile‎

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,15 @@ lane :validate do |options|
119119

120120
UI.user_error!("Release #{version} already exists on GitHub.") unless release.nil?
121121

122-
# Clear lane-context values populated by `get_github_release` so a later
123-
# action doesn't see stale state from this probe call.
122+
# Resolve the release-notes base now, while nothing has been published yet.
123+
# A wrong base produces notes that restate old releases — a silent failure
124+
# once the release is live, but a cheap re-run if it surfaces here.
125+
previous_tag = previous_release_tag(version: version, token: token)
126+
UI.user_error!("Could not resolve a previous release tag for #{version}.") if previous_tag.nil?
127+
UI.success("Release notes for #{version} will be generated against #{previous_tag}.")
128+
129+
# Clear lane-context values populated by the probe calls above so a later
130+
# action doesn't see stale state from them.
124131
[
125132
SharedValues::GITHUB_API_RESPONSE,
126133
SharedValues::GITHUB_API_STATUS_CODE,
@@ -160,12 +167,15 @@ lane :publish_release_to_github do |options|
160167
# metadata + an asset mirror — if this call fails the tag is unaffected
161168
# and an operator can recreate the Release manually against the existing
162169
# tag (see docs/releases.md).
170+
#
171+
# Notes use an explicit previous tag rather than `is_generate_release_notes`,
172+
# which cannot express one. See `previous_release_tag`.
163173
set_github_release(
164174
api_token: token,
165175
repository_name: GITHUB_REPO,
166176
name: version,
167177
tag_name: version,
168-
is_generate_release_notes: true,
178+
description: generated_release_notes(version: version, token: token),
169179
is_prerelease: version.include?('-'),
170180
upload_assets: [xcframework_file_path, xcframework_checksum_file_path]
171181
)
@@ -252,6 +262,70 @@ def github_token!(options = {})
252262
end
253263
end
254264

265+
# Resolve the tag that release notes for `version` should be generated against.
266+
#
267+
# GitHub's own inference picks the most recent tag whose commit is an *ancestor*
268+
# of the target. Our release tags are each committed on a local `release/vX.Y.Z`
269+
# branch that is never pushed, so no release tag is reachable from any other and
270+
# the inference falls back to the last tag on `trunk` (`v0.16.0`), re-listing
271+
# months of merged PRs. An explicit `previous_tag_name` sidesteps it.
272+
#
273+
# Prereleases are excluded as candidates, matching GitHub's default: a stable
274+
# release reports everything since the last stable one, including work already
275+
# listed in intervening alphas.
276+
#
277+
# Reads the Releases API rather than local tags: CI checkouts may not have
278+
# fetched every tag, the API reports `prerelease` authoritatively, and it ignores
279+
# stray tags never published as releases (e.g. `vtest-s3-xcframework-*`).
280+
def previous_release_tag(version:, token:)
281+
# Single unpaginated page. Releases come back newest-first, so this misses the
282+
# preceding stable release only after 100 *consecutive* prereleases — far off
283+
# at the current ratio. It fails safe: no candidate returns nil, and both
284+
# callers hard-error rather than publishing notes against a wrong base.
285+
releases = github_api(
286+
api_token: token,
287+
http_method: 'GET',
288+
path: "/repos/#{GITHUB_REPO}/releases?per_page=100"
289+
)[:json]
290+
291+
candidates = releases.reject { |release| release['draft'] || release['prerelease'] }
292+
.map { |release| release['tag_name'] }
293+
.reject { |tag| tag == version }
294+
.select { |tag| tag =~ /\Av\d+\.\d+\.\d+\z/ }
295+
296+
target = Gem::Version.new(version.delete_prefix('v').split('-').first)
297+
candidates.select { |tag| Gem::Version.new(tag.delete_prefix('v')) < target }
298+
.max_by { |tag| Gem::Version.new(tag.delete_prefix('v')) }
299+
end
300+
301+
# Build the release body via GitHub's notes generator, pinned to an explicit
302+
# previous tag. `set_github_release`'s `is_generate_release_notes` cannot express
303+
# `previous_tag_name`, so call the endpoint directly and pass the result through
304+
# as `description`.
305+
#
306+
# Fails loudly rather than falling back to auto-generated notes: a wrong base
307+
# looks like a successful release, caught only by someone reading the page later.
308+
def generated_release_notes(version:, token:)
309+
previous_tag = previous_release_tag(version: version, token: token)
310+
UI.user_error!("Could not resolve a previous release tag for #{version}; refusing to publish notes that may restate old releases.") \
311+
if previous_tag.nil?
312+
313+
UI.message("Generating release notes for #{version} against previous tag #{previous_tag}.")
314+
315+
response = github_api(
316+
api_token: token,
317+
http_method: 'POST',
318+
path: "/repos/#{GITHUB_REPO}/releases/generate-notes",
319+
body: { tag_name: version, previous_tag_name: previous_tag }
320+
)
321+
322+
notes = response[:json]['body']
323+
UI.user_error!("GitHub returned empty release notes for #{version} (status #{response[:status]}).") \
324+
if notes.nil? || notes.strip.empty?
325+
326+
notes
327+
end
328+
255329
def require_env_vars!(*keys)
256330
keys.each { |key| get_required_env!(key) }
257331
end

0 commit comments

Comments
 (0)