Skip to content
Closed
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
28 changes: 24 additions & 4 deletions docs/releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <tag>` 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/<n>` 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/<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).

### 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] <xcframework.zip> <checksum.txt>` 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] \
<xcframework.zip> <checksum.txt>
```

`--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`
Expand Down
86 changes: 83 additions & 3 deletions fastlane/Fastfile
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -160,12 +167,17 @@ 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 are generated against an explicit previous tag rather than via
# `is_generate_release_notes`. Our tags are unreachable from one another, so
# GitHub's own inference walks back to the last tag on `trunk` and restates
# every release since. 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]
)
Expand Down Expand Up @@ -252,6 +264,74 @@ def github_token!(options = {})
end
end

# Resolve the tag that release notes for `version` should be generated against.
#
# GitHub's own "generate notes" inference picks the most recent tag whose commit
# is an *ancestor* of the target commit. Our release tags never satisfy that:
# `publish_release_to_github` tags a `Package.swift` rewrite committed on a local
# `release/vX.Y.Z` branch that is never pushed, so every release tag is
# unreachable from every other. GitHub therefore falls back to the last tag that
# does sit on `trunk` (`v0.16.0`) and re-lists months of merged PRs. Passing an
# explicit `previous_tag_name` sidesteps the inference entirely.
#
# Prereleases are excluded as candidates, matching GitHub's default behavior: a
# stable release reports everything since the last stable release, including the
# work already listed in any intervening alphas.
#
# Reads the Releases API rather than local tags: CI checkouts may not have
# fetched every tag, the API reports `prerelease` authoritatively instead of us
# inferring it from the tag name, and it naturally ignores stray tags that were
# never published as releases (e.g. `vtest-s3-xcframework-*`).
def previous_release_tag(version:, token:)
# Single unpaginated page: the API returns releases newest-first, so the most
# recent 100 always contain the immediately-preceding stable release. This
# holds until the repo accumulates 100 consecutive prereleases, at which point
# the lookup would need to paginate.
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 we call the endpoint directly and pass the result
# through as `description`.
#
# Fails loudly rather than falling back to auto-generated notes: a silently wrong
# base tag looks like a successful release and is only caught by someone reading
# the release 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
Expand Down
Loading