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
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
82 changes: 79 additions & 3 deletions fastlane/Fastfile
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,14 @@ 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.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 +166,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]
)
Expand Down Expand Up @@ -252,6 +261,73 @@ 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:)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ We have an action in release-toolkit for that already

So no need to re-implement via API calls in your Fastfile.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the guidance! 🙇🏻‍♂️

Unfortunately, it seems find_previous_tag may not satisfy the need here. It uses git describe --tags --abbrev=0, which only finds tags reachable from the current commit—the same constraint that breaks GitHub's own inference. Our release tags are each committed on a local release/vX.Y.Z branch that's never pushed, so no release tag is an ancestor of any other and it always falls back to the last tag on trunk:

RELEASE            find_previous_tag   previous_release_tag
v0.17.2            v0.16.0             v0.17.1
v0.18.0            v0.16.0             v0.17.2
v0.18.1            v0.16.0             v0.18.0
v0.19.0            v0.16.0             v0.18.1
v0.20.0-alpha.0    v0.16.0             v0.19.0

For v0.20.0-alpha.0 that's 25 PRs vs. the 3 that were actually new.

previous_release_tag asks a different question ("highest stable release below this version?"), which needs no ancestry. Reading the Releases API rather than local tags also means CI doesn't need every tag fetched, prerelease is authoritative rather than inferred from the name, and stray tags like vtest-s3-xcframework-* are ignored.

WYDT?

# 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 }
.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, tag_version| tag_version < target }
.max_by { |_tag, tag_version| tag_version }
&.first
end

# `previous_release_tag`, erroring instead of returning nil.
#
# 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

# 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
# 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,
tag_name: version,
previous_tag: previous_release_tag!(version: version, token: token)
)
end

def require_env_vars!(*keys)
keys.each { |key| get_required_env!(key) }
end
Expand Down
Loading