From 715c35d874ac86f850d82ab35c2216c8ca623a86 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Mon, 11 May 2026 16:12:33 -0600 Subject: [PATCH 1/3] ci(ios): publish tagged releases as binary targets via Buildkite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the wordpress-rs tag-release flow. `bin/release.sh` stops at bumping versions on trunk; a follow-up Buildkite build kicked off with `NEW_VERSION=v` then rewrites `Package.swift` to `.release(version:, checksum:)`, tags, and creates the GitHub Release. The tag's commit lives off trunk (parented on the release commit but only reachable via the tag ref), so SPM consumers pinning the tag resolve the prebuilt XCFramework from CDN rather than rebuilding from the local source bundle. This is the precondition for ignoring the committed iOS JS bundle at `ios/Sources/GutenbergKitResources/Gutenberg/` — once a tagged release exists in `.release(...)` mode and WordPress-iOS bumps to it, those files can be dropped from trunk. --- .buildkite/pipeline.yml | 35 ++++++++++-- .buildkite/release.sh | 21 ++++++++ bin/release.sh | 89 +++++++++++-------------------- docs/releases.md | 56 ++++++++++++++++--- docs/wordpress-app-integration.md | 24 ++++----- fastlane/Fastfile | 88 ++++++++++++++++++++++++++++++ 6 files changed, 231 insertions(+), 82 deletions(-) create mode 100755 .buildkite/release.sh diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index 4ac399855..bf9dce124 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -15,15 +15,26 @@ steps: - $CI_TOOLKIT_PLUGIN - $NVM_PLUGIN + - label: ':white_check_mark: Validate Swift release ${NEW_VERSION:-(no version)}' + key: validate-release + if: build.env("NEW_VERSION") != null && build.branch == "trunk" && build.pull_request.id == null + command: | + install_gems + bundle exec fastlane validate "version:$NEW_VERSION" + plugins: *plugins + - label: ':eslint: Lint React App' + key: lint-js command: make lint-js plugins: *plugins - label: ':javascript: Test JavaScript' + key: test-js command: make test-js plugins: *plugins - label: ':performing_arts: Test Web E2E' + key: test-web-e2e depends_on: build-react command: | buildkite-agent artifact download dist.tar.gz . @@ -97,15 +108,15 @@ steps: - label: ':s3: Publish XCFramework to S3' depends_on: build-xcframework - if: build.pull_request.id == null + # The `:rocket: Publish Swift release` step handles uploads when + # `NEW_VERSION` is set, so this step only covers per-commit trunk + # uploads keyed by the commit SHA. + if: build.pull_request.id == null && build.env("NEW_VERSION") == null command: | buildkite-agent artifact download '*.xcframework.zip' . buildkite-agent artifact download '*.xcframework.zip.checksum.txt' . install_gems - # Version precedence: explicit `NEW_VERSION` override wins, then a - # tag build publishes under the tag, otherwise fall back to the - # commit SHA so every push gets a stable artifact URL. - bundle exec fastlane publish_to_s3 version:${NEW_VERSION:-${BUILDKITE_TAG:-$BUILDKITE_COMMIT}} + bundle exec fastlane publish_to_s3 version:${BUILDKITE_TAG:-$BUILDKITE_COMMIT} plugins: *plugins - label: ':swift: :package: Publish PR XCFramework' @@ -114,7 +125,21 @@ steps: command: .buildkite/publish-pr-xcframework.sh plugins: *plugins + - label: ':rocket: Publish Swift release ${NEW_VERSION:-(no version)}' + depends_on: + - validate-release + - build-xcframework + - swift-test-swift-package + - lint-js + - test-js + - test-web-e2e + - test-ios-e2e + if: build.env("NEW_VERSION") != null && build.branch == "trunk" && build.pull_request.id == null + command: .buildkite/release.sh + plugins: *plugins + - label: ':ios: Test iOS E2E' + key: test-ios-e2e depends_on: build-react command: | buildkite-agent artifact download dist.tar.gz . diff --git a/.buildkite/release.sh b/.buildkite/release.sh new file mode 100755 index 000000000..87860d570 --- /dev/null +++ b/.buildkite/release.sh @@ -0,0 +1,21 @@ +#!/bin/bash +set -euo pipefail + +if [[ -z "${NEW_VERSION:-}" ]]; then + echo "ERROR: NEW_VERSION is not set or empty." >&2 + echo "Set NEW_VERSION=vX.Y.Z when triggering this build." >&2 + exit 1 +fi + +echo '--- :robot_face: Use bot for Git operations' +source use-bot-for-git + +echo '--- :arrow_down: Downloading XCFramework artifacts' +buildkite-agent artifact download '*.xcframework.zip' . --step "build-xcframework" +buildkite-agent artifact download '*.xcframework.zip.checksum.txt' . --step "build-xcframework" + +echo '--- :rubygems: Setting up Gems' +install_gems + +echo "--- :rocket: Publishing Swift release $NEW_VERSION" +bundle exec fastlane release "version:$NEW_VERSION" diff --git a/bin/release.sh b/bin/release.sh index c00b60b50..e418b7698 100755 --- a/bin/release.sh +++ b/bin/release.sh @@ -79,10 +79,6 @@ check_working_directory() { check_dependencies() { local missing_deps=() - if ! command -v gh &> /dev/null; then - missing_deps+=("gh (GitHub CLI)") - fi - if ! command -v npm &> /dev/null; then missing_deps+=("npm") fi @@ -132,20 +128,6 @@ calculate_new_version() { esac } -# Function to check if a version is a prerelease -is_prerelease() { - local version=$1 - - # Use semver to check if the version has prerelease identifiers - local result=$(node -p "require('semver').prerelease('$version') !== null") - - if [ "$result" = "true" ]; then - return 0 # It is a prerelease - else - return 1 # It is not a prerelease - fi -} - # Function to validate version type validate_version_type() { local version_type=$1 @@ -262,20 +244,6 @@ commit_changes() { print_success "Changes committed with message: chore(release): $version" } -# Function to create git tag -create_tag() { - local version=$1 - - print_status "Creating git tag: v$version" - - if [ "$DRY_RUN" = "true" ]; then - return - fi - - git tag "v$version" - print_success "Tag created: v$version" -} - # Function to push changes push_changes() { local version=$1 @@ -286,22 +254,33 @@ push_changes() { return fi - git push origin trunk --tags + git push origin trunk print_success "Changes pushed successfully" } -# Function to create GitHub release -create_github_release() { +# Function to print the post-push instructions for kicking off the +# Buildkite publish build. CI creates the tag and the GitHub release — +# this script just bumps the version files on trunk. +print_publish_instructions() { local version=$1 + local sha=$2 + local tag="v$version" - print_status "Creating GitHub release: v$version" - - if [ "$DRY_RUN" = "true" ]; then - return - fi - - gh release create "v$version" --generate-notes --title "$version" - print_success "GitHub release created: v$version" + echo + print_status "Next: trigger the Buildkite publish build." + echo + echo " 1. Open https://buildkite.com/automattic/gutenbergkit/builds/new" + echo " 2. Branch: trunk" + echo " 3. Commit: $sha" + echo " 4. Environment Variables: NEW_VERSION=$tag" + echo + echo "Pin the Commit field to the SHA above — otherwise Buildkite resolves" + echo "'trunk' to whatever HEAD is at trigger time, and a concurrent merge" + echo "would tag the wrong commit." + echo + echo "The :rocket: 'Publish Swift release' step will build + sign the" + echo "XCFramework, upload it to S3, and publish the GitHub Release —" + echo "which also creates the $tag tag." } # Main function @@ -381,34 +360,28 @@ main() { commit_changes "$new_version" echo - create_tag "$new_version" - echo - push_changes "$new_version" echo - # Only create GitHub release for non-prerelease versions - if is_prerelease "$new_version"; then - print_status "Skipping GitHub release creation for prerelease version" + # Capture the SHA of the just-pushed release commit so the operator can + # pin it when triggering the Buildkite publish build (avoids drift if + # trunk moves between this push and the build trigger). + local pushed_sha + if [ "$DRY_RUN" = "true" ]; then + pushed_sha="" else - create_github_release "$new_version" + pushed_sha=$(git rev-parse HEAD) fi - echo # Summary - print_success "Release process completed successfully!" + print_success "Version bump completed successfully!" print_status "Version: $current_version -> $new_version" if [ "$DRY_RUN" = "true" ]; then print_warning "This was a dry run. No actual changes were made." print_status "To perform the actual release, run: make release VERSION_TYPE=$version_type" else - if is_prerelease "$new_version"; then - print_status "Prerelease tag v$new_version has been created and pushed." - print_status "No GitHub release was created for this prerelease version." - else - print_status "The release is ready for integration into the WordPress app." - fi + print_publish_instructions "$new_version" "$pushed_sha" fi } diff --git a/docs/releases.md b/docs/releases.md index 4b0118b27..044758884 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1,6 +1,19 @@ # GutenbergKit Release Process -Use the provided release script to automate the entire process: +## How publishing works + +Every push to `trunk` publishes both platforms automatically: + +- **Android**: the `:android: Publish Android Library` step pushes a Maven artifact keyed by the commit (consumable via Git revision pins). +- **iOS**: the `:s3: Publish XCFramework to S3` step uploads the signed XCFramework under `gutenbergkit//`, and `Publish PR XCFramework` does the same on PR builds under a `pr-build/` snapshot branch. + +A **tagged release** is a separate, manually-triggered publish flow on top of that: it produces a stable `vX.Y.Z` tag whose `Package.swift` points at the prebuilt XCFramework on CDN, plus a GitHub Release with the XCFramework attached. SPM consumers pin the tag; everything else can pin a commit/branch. + +The tagged release happens in two steps: a local script bumps the version on `trunk`, then a CI build creates the tag and the GitHub release. + +## Step 1 — Bump versions on trunk + +Run the release script: ```bash # Standard version increments @@ -31,13 +44,42 @@ The script: 1. Ensures required dependencies are installed 1. Increments the version number[^1] 1. Builds the project[^2] -1. Commits changes -1. Creates a Git tag -1. Pushes to `origin/trunk` with tags -1. Creates a GitHub release -1. Creates a new release on GitHub: `gh release create vX.X.X --generate-notes --title "X.X.X"` +1. Commits the version bump as `chore(release): X.Y.Z` +1. Pushes to `origin/trunk` + +It does **not** create the git tag or the GitHub release — that's Step 2. + +## Step 2 — Publish via Buildkite + +Step 1 prints the SHA of the version-bump commit it just pushed. Trigger a new Buildkite build with that SHA pinned: + +1. Open +2. **Branch**: `trunk` +3. **Commit**: the SHA printed by Step 1 +4. **Environment Variables**: `NEW_VERSION=vX.Y.Z` + +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: + +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 `release-staging/vX.Y.Z` branch, pushes it to origin +1. Creates the GitHub Release **as a draft**, uploading the XCFramework + checksum as assets — at this point no tag exists yet +1. Flips the release out of draft state, which atomically creates the `vX.Y.Z` tag pointing at the staging-branch commit +1. Deletes the staging branch (best-effort) + +The two-phase publish means the tag is the last thing created. If anything fails before the draft is flipped — S3 upload, asset upload, staging push — no tag exists and consumers see nothing. + +The tag's commit lives off `trunk`'s history (parented on `trunk` but only reachable via the tag), so SPM consumers pinning `vX.Y.Z` resolve a `Package.swift` that fetches the prebuilt XCFramework from CDN rather than rebuilding from local sources. + +### Recovering from a partial publish + +The flow is designed so that the tag's existence is the only signal of completion: if `vX.Y.Z` exists, the release is real. + +If the build fails before the draft-flip step, no tag was created 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`). You may need to manually delete the leftover draft release in the GitHub UI before re-running. -After the release is created, it is ready for integration into the WordPress app. +If the build fails specifically on the cleanup step (`git push origin --delete release-staging/vX.Y.Z`), the release is fine — the tag exists and is valid — but the staging branch is left behind. Delete it manually with `git push origin --delete release-staging/vX.Y.Z`. ## Release Notes diff --git a/docs/wordpress-app-integration.md b/docs/wordpress-app-integration.md index f0e9f7010..42dae3199 100644 --- a/docs/wordpress-app-integration.md +++ b/docs/wordpress-app-integration.md @@ -31,9 +31,9 @@ Make sure the path points to your local GutenbergKit clone relative to your Word 1. Copy `local-builds.gradle-example` to `local-builds.gradle` 2. Uncomment the `localGutenbergKitPath` line and set it to your local GutenbergKit path: - ```groovy - localGutenbergKitPath = "../GutenbergKit" - ``` + ```groovy + localGutenbergKitPath = "../GutenbergKit" + ``` 3. Run Gradle sync — this substitutes the Maven dependency with the local project ### Git Revision @@ -72,7 +72,7 @@ CI (Buildkite) publishes builds for PRs to the Maven repository automatically. **Use case**: Integrating GutenbergKit work into WordPress app trunk before a formal release. -Pre-releases create alpha version tags without creating a GitHub Release. They're useful for getting changes into the WordPress apps' main branches early. +Pre-releases create alpha version tags with a GitHub Release marked `--prerelease`. They're useful for getting changes into the WordPress apps' main branches early. #### Creating a Pre-release @@ -89,7 +89,7 @@ Available version types: - `premajor` — increments major and adds alpha suffix (0.13.2 → 1.0.0-alpha.0) - `prerelease` — increments the alpha number (0.13.3-alpha.0 → 0.13.3-alpha.1) -This pushes a git tag (e.g., `v0.13.3-alpha.0`) and CI publishes the Android build to the Maven repository. +Every trunk push already publishes per-commit artifacts (Android → Maven, iOS → S3 keyed by commit SHA). This bumps the version on `trunk` so the next per-commit publish carries that version, and the follow-up Buildkite build triggered with `NEW_VERSION=v0.13.3-alpha.0` adds the `vX.Y.Z` tag, the binary-target `Package.swift`, and the GitHub prerelease. See [Release Process](./releases.md) for the full flow. #### iOS @@ -127,7 +127,7 @@ Available version types: - `minor` — new features, backwards compatible (0.13.2 → 0.14.0) - `major` — breaking changes (0.13.2 → 1.0.0) -This creates a GitHub Release with auto-generated notes and CI publishes the Android build to the Maven repository. +Every trunk push already publishes per-commit artifacts (Android → Maven, iOS → S3 keyed by commit SHA). This bumps the version on `trunk` so the next per-commit publish carries that version, and the follow-up Buildkite build triggered with `NEW_VERSION=v0.13.3` adds the `vX.Y.Z` tag, the binary-target `Package.swift`, and the GitHub Release. See [Release Process](./releases.md) for the full flow. #### iOS @@ -147,12 +147,12 @@ gutenberg-kit = '0.13.3' ## Workflow Recommendations -| Scenario | Recommended Method | -| --------------------------------- | ------------------ | -| Active feature development | Local Development | -| PR review / testing | Git Revision | -| Merging to WordPress app trunk | Pre-release | -| WordPress app release | Formal Release | +| Scenario | Recommended Method | +| ------------------------------ | ------------------ | +| Active feature development | Local Development | +| PR review / testing | Git Revision | +| Merging to WordPress app trunk | Pre-release | +| WordPress app release | Formal Release | ## Platform-Specific Notes diff --git a/fastlane/Fastfile b/fastlane/Fastfile index d5984c4ed..dea8b991c 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -71,6 +71,94 @@ lane :publish_pr_xcframework do post_buildkite_annotation(body: body) end +lane :release do |options| + version = required_version!(options) + + # Order matters: do every step that can fail BEFORE the tag is cut, so that + # if anything blows up the tag never gets created. Consumers only see the + # tag, so "tag exists" == "release is real and complete". The validate lane + # runs as an earlier Buildkite step (`validate-release`); it's intentionally + # not re-invoked here. + update_swift_package(version: version) + publish_to_s3(version: version) + publish_release_to_github(version: version) +end + +lane :validate do |options| + version = required_version!(options) + + UI.user_error!("Version #{version.inspect} is not a valid tag name (expected `vMAJOR.MINOR.PATCH` or `vMAJOR.MINOR.PATCH-PRERELEASE`).") \ + unless version =~ /\Av\d+\.\d+\.\d+(-.+)?\z/ + + UI.user_error!("Tag #{version} already exists on the remote.") \ + if git_tag_exists(tag: version, remote: true, remote_name: 'origin') + + UI.user_error!("Release #{version} already exists on GitHub.") \ + unless get_github_release(url: GITHUB_REPO, version: version).nil? + + # `get_github_release` populates these lane-context values; clear them so a + # later action doesn't see stale state from this probe call. + remove_lane_context_values [ + SharedValues::GITHUB_API_RESPONSE, + SharedValues::GITHUB_API_STATUS_CODE, + SharedValues::GITHUB_API_JSON + ] +end + +lane :update_swift_package do |options| + version = required_version!(options) + + rewrite_resources_mode!( + File.join(PROJECT_ROOT, 'Package.swift'), + version: version, + checksum: xcframework_checksum + ) +end + +lane :publish_release_to_github do |options| + version = required_version!(options) + staging_branch = "release-staging/#{version}" + + # Commit the rewritten Package.swift on a staging branch and push it. + # The branch only exists to make the commit reachable on origin so that + # `gh release create --target` can resolve it — we delete the branch at + # the end, leaving only the tag ref as the published handle on this + # commit (matches the `pr-build/` snapshot-branch shape). + sh('git', 'checkout', '-B', staging_branch) + git_commit( + path: File.join(PROJECT_ROOT, 'Package.swift'), + message: "Update Package.swift to use version #{version}" + ) + sh('git', 'push', 'origin', staging_branch) + + # Two-phase publish so the tag is the very last thing created: + # 1. Create the release as a draft — uploads the XCFramework + checksum + # assets, but does NOT create the tag yet (GitHub semantics). + # 2. Flip `draft=false` — atomic with tag creation. Until this call + # succeeds, no consumer can resolve `version`, so a failure in + # step 1 (or anything earlier) leaves nothing for them to see. + create_args = ['gh', 'release', 'create', version, + '--draft', + '--title', version, + '--generate-notes', + '--target', staging_branch] + create_args << '--prerelease' if version.include?('-') + create_args.push(xcframework_file_path, xcframework_checksum_file_path) + sh(*create_args) + + sh('gh', 'release', 'edit', version, '--draft=false') + + # Cleanup is best-effort. The tag ref now holds the commit alive; the + # staging branch has done its job. If this fails (network blip), the + # branch is harmless — operators can `git push origin --delete` it + # later. + begin + sh('git', 'push', 'origin', '--delete', staging_branch) + rescue StandardError + UI.important("Failed to delete staging branch #{staging_branch}; clean up manually with `git push origin --delete #{staging_branch}`.") + end +end + lane :xcframework_sign do sh( 'codesign', From ff062d7a1e6d8e3e69c537e2ad44a2f16bff4543 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 13 May 2026 17:46:55 -0600 Subject: [PATCH 2/3] ci(ios): tighten release lane validate, auth, and tag-build gating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review-feedback follow-up to the publish flow added in this PR. - `validate` lane now passes an explicit `api_token` to `get_github_release` and asserts `GITHUB_API_STATUS_CODE == 200` after the probe. The action returns `nil` for both "no such release" AND for any API failure (401, 404, network), so without the status check an auth-misconfigured probe silently green-lit a publish. - `publish_release_to_github` swaps the `gh release create` shellout for `set_github_release(api_token: …)` (release-toolkit action). No more reliance on the agent's ambient `gh` auth — `use-bot-for-git` only sets `GIT_SSH_COMMAND`, not `GH_TOKEN`. Asset upload, prerelease flag, and auto-generated notes all flow through the action's params. - `release` orchestrator re-invokes `validate(version:, github_token:)` at the top so a misconfigured pipeline can't silently skip it. New `github_token!` helper resolves token from `options` or `ENV`. - Refactored `publish_release_to_github` itself: dropped the staging-branch + draft + flip dance in favour of the simpler `push_git_tags` flow that wordpress-rs uses. `git push ` carries the commit along with the tag ref, so no branch ref ever lives on origin. Tag is the last thing pushed before the GH Release call, so partial failure leaves either nothing (clean re-run) or just the GH Release missing (recoverable manually against the existing tag). - `:s3: Publish XCFramework to S3` step also gated on `build.tag == null` so the auto-triggered tag build doesn't re-upload the same iOS artifact the `:rocket:` step already pushed. Android publish on tag builds is intentionally not gated — it's still load-bearing (produces the canonical `vX.Y.Z` Maven artifact via `--tag-name`). - `bin/release.sh --dry-run` now prints the Buildkite-trigger preview it was previously suppressing, with a `[DRY RUN]` prefix on the leading status line. - Fastfile comment on the `release` lane warns against local invocation (it pushes a tag and creates a real GH Release). --- .buildkite/pipeline.yml | 14 ++++-- bin/release.sh | 11 ++-- docs/releases.md | 16 +++--- fastlane/Fastfile | 108 +++++++++++++++++++++++----------------- 4 files changed, 85 insertions(+), 64 deletions(-) diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index bf9dce124..a0481bdb4 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -108,15 +108,19 @@ steps: - label: ':s3: Publish XCFramework to S3' depends_on: build-xcframework - # The `:rocket: Publish Swift release` step handles uploads when - # `NEW_VERSION` is set, so this step only covers per-commit trunk - # uploads keyed by the commit SHA. - if: build.pull_request.id == null && build.env("NEW_VERSION") == null + # This step only covers per-commit trunk uploads keyed by the commit + # SHA. Releases are handled by `:rocket: Publish Swift release` (gated + # on `NEW_VERSION`), and tag pushes from that step trigger a separate + # tag build whose iOS upload would just duplicate the rocket step's — + # the `build.tag == null` clause skips it. Android publish on tag + # builds is still load-bearing (produces the `vX.Y.Z` Maven artifact) + # and intentionally not gated here. + if: build.pull_request.id == null && build.env("NEW_VERSION") == null && build.tag == null command: | buildkite-agent artifact download '*.xcframework.zip' . buildkite-agent artifact download '*.xcframework.zip.checksum.txt' . install_gems - bundle exec fastlane publish_to_s3 version:${BUILDKITE_TAG:-$BUILDKITE_COMMIT} + bundle exec fastlane publish_to_s3 version:$BUILDKITE_COMMIT plugins: *plugins - label: ':swift: :package: Publish PR XCFramework' diff --git a/bin/release.sh b/bin/release.sh index e418b7698..48177da63 100755 --- a/bin/release.sh +++ b/bin/release.sh @@ -265,9 +265,14 @@ print_publish_instructions() { local version=$1 local sha=$2 local tag="v$version" + local prefix="" + + if [ "$DRY_RUN" = "true" ]; then + prefix="[DRY RUN] " + fi echo - print_status "Next: trigger the Buildkite publish build." + print_status "${prefix}Next: trigger the Buildkite publish build." echo echo " 1. Open https://buildkite.com/automattic/gutenbergkit/builds/new" echo " 2. Branch: trunk" @@ -380,9 +385,9 @@ main() { if [ "$DRY_RUN" = "true" ]; then print_warning "This was a dry run. No actual changes were made." print_status "To perform the actual release, run: make release VERSION_TYPE=$version_type" - else - print_publish_instructions "$new_version" "$pushed_sha" fi + + print_publish_instructions "$new_version" "$pushed_sha" } # Run main function with all arguments diff --git a/docs/releases.md b/docs/releases.md index 044758884..fb9ecaee2 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -64,22 +64,18 @@ The build runs a `:white_check_mark: Validate Swift release` step early on (gate 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 `release-staging/vX.Y.Z` branch, pushes it to origin -1. Creates the GitHub Release **as a draft**, uploading the XCFramework + checksum as assets — at this point no tag exists yet -1. Flips the release out of draft state, which atomically creates the `vX.Y.Z` tag pointing at the staging-branch commit -1. Deletes the staging branch (best-effort) +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. Creates the GitHub Release against the now-existing tag, uploading the XCFramework + checksum as assets (adds `--prerelease` when the version contains `-`) -The two-phase publish means the tag is the last thing created. If anything fails before the draft is flipped — S3 upload, asset upload, staging push — no tag exists and consumers see nothing. +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), so SPM consumers pinning `vX.Y.Z` resolve a `Package.swift` that fetches the prebuilt XCFramework from CDN rather than rebuilding from local sources. +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. ### Recovering from a partial publish -The flow is designed so that the tag's existence is the only signal of completion: if `vX.Y.Z` exists, the release is real. +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 before the draft-flip step, no tag was created 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`). You may need to manually delete the leftover draft release in the GitHub UI before re-running. - -If the build fails specifically on the cleanup step (`git push origin --delete release-staging/vX.Y.Z`), the release is fine — the tag exists and is valid — but the staging branch is left behind. Delete it manually with `git push origin --delete release-staging/vX.Y.Z`. +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. ## Release Notes diff --git a/fastlane/Fastfile b/fastlane/Fastfile index dea8b991c..33b1a908d 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -71,21 +71,32 @@ lane :publish_pr_xcframework do post_buildkite_annotation(body: body) end +# CI orchestrator — invoked by `.buildkite/release.sh` when a build is +# triggered with `NEW_VERSION` set. **Don't run this locally**: it pushes a +# tag to origin, uploads to the public S3 bucket, and creates a real GitHub +# Release. For local diagnosis, invoke `validate`, `update_swift_package`, +# `publish_to_s3`, or `publish_release_to_github` individually. lane :release do |options| version = required_version!(options) + token = github_token!(options) - # Order matters: do every step that can fail BEFORE the tag is cut, so that - # if anything blows up the tag never gets created. Consumers only see the - # tag, so "tag exists" == "release is real and complete". The validate lane - # runs as an earlier Buildkite step (`validate-release`); it's intentionally - # not re-invoked here. + # Defense in depth: re-run validate so a misconfigured pipeline (e.g. a + # future edit that drops the `validate-release` dep) still fast-fails on + # bad input. Cheap no-op when the earlier Buildkite step already passed. + validate(version: version, github_token: token) + + # Upload the XCFramework before the tag is pushed: the tag's `Package.swift` + # references the S3 URL, so consumers resolving the tag need the artifact + # already in place. If S3 upload fails, no tag exists and recovery is a + # clean re-run. update_swift_package(version: version) publish_to_s3(version: version) - publish_release_to_github(version: version) + publish_release_to_github(version: version, github_token: token) end lane :validate do |options| version = required_version!(options) + token = github_token!(options) UI.user_error!("Version #{version.inspect} is not a valid tag name (expected `vMAJOR.MINOR.PATCH` or `vMAJOR.MINOR.PATCH-PRERELEASE`).") \ unless version =~ /\Av\d+\.\d+\.\d+(-.+)?\z/ @@ -93,11 +104,21 @@ lane :validate do |options| UI.user_error!("Tag #{version} already exists on the remote.") \ if git_tag_exists(tag: version, remote: true, remote_name: 'origin') - UI.user_error!("Release #{version} already exists on GitHub.") \ - unless get_github_release(url: GITHUB_REPO, version: version).nil? + release = get_github_release(url: GITHUB_REPO, version: version, api_token: token) + + # `get_github_release` returns nil for both "no such release" (200 with no + # matching tag in the response) AND for any API failure (401, 404, network, + # etc.). Check the status code populated in lane context to distinguish + # the two — otherwise an auth-misconfigured probe silently green-lights a + # publish. + status = lane_context[SharedValues::GITHUB_API_STATUS_CODE] + UI.user_error!("GitHub API returned status #{status.inspect} probing for release #{version}; cannot determine whether it exists.") \ + unless status == 200 - # `get_github_release` populates these lane-context values; clear them so a - # later action doesn't see stale state from this probe call. + 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. remove_lane_context_values [ SharedValues::GITHUB_API_RESPONSE, SharedValues::GITHUB_API_STATUS_CODE, @@ -117,46 +138,35 @@ end lane :publish_release_to_github do |options| version = required_version!(options) - staging_branch = "release-staging/#{version}" - - # Commit the rewritten Package.swift on a staging branch and push it. - # The branch only exists to make the commit reachable on origin so that - # `gh release create --target` can resolve it — we delete the branch at - # the end, leaving only the tag ref as the published handle on this - # commit (matches the `pr-build/` snapshot-branch shape). - sh('git', 'checkout', '-B', staging_branch) + token = github_token!(options) + + # Stage the rewritten Package.swift on a local branch — never pushed to + # origin — then tag the commit and push only the tag. `git push ` + # uploads the commit along with the tag ref, so the tag's commit becomes + # reachable on origin via the tag alone (no branch required). Mirrors the + # wordpress-rs release flow. + sh('git', 'checkout', '-B', "release/#{version}") git_commit( path: File.join(PROJECT_ROOT, 'Package.swift'), message: "Update Package.swift to use version #{version}" ) - sh('git', 'push', 'origin', staging_branch) - - # Two-phase publish so the tag is the very last thing created: - # 1. Create the release as a draft — uploads the XCFramework + checksum - # assets, but does NOT create the tag yet (GitHub semantics). - # 2. Flip `draft=false` — atomic with tag creation. Until this call - # succeeds, no consumer can resolve `version`, so a failure in - # step 1 (or anything earlier) leaves nothing for them to see. - create_args = ['gh', 'release', 'create', version, - '--draft', - '--title', version, - '--generate-notes', - '--target', staging_branch] - create_args << '--prerelease' if version.include?('-') - create_args.push(xcframework_file_path, xcframework_checksum_file_path) - sh(*create_args) - - sh('gh', 'release', 'edit', version, '--draft=false') - - # Cleanup is best-effort. The tag ref now holds the commit alive; the - # staging branch has done its job. If this fails (network blip), the - # branch is harmless — operators can `git push origin --delete` it - # later. - begin - sh('git', 'push', 'origin', '--delete', staging_branch) - rescue StandardError - UI.important("Failed to delete staging branch #{staging_branch}; clean up manually with `git push origin --delete #{staging_branch}`.") - end + add_git_tag(tag: version) + push_git_tags(remote: 'origin', tag: version) + + # The tag is now live on origin and SPM consumers can resolve `version` + # against the prebuilt XCFramework already on S3. The GH Release page is + # 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). + set_github_release( + api_token: token, + repository_name: GITHUB_REPO, + name: version, + tag_name: version, + is_generate_release_notes: true, + is_prerelease: version.include?('-'), + upload_assets: [xcframework_file_path, xcframework_checksum_file_path] + ) end lane :xcframework_sign do @@ -234,6 +244,12 @@ def required_version!(options) version end +def github_token!(options = {}) + options[:github_token] || ENV.fetch('GITHUB_TOKEN') do + UI.user_error!('GITHUB_TOKEN must be set in the environment (or pass `github_token:` to the lane).') + end +end + def require_env_vars!(*keys) keys.each { |key| get_required_env!(key) } end From 27936ada73b339a1329deb194abf2af47474ef52 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 15 May 2026 10:41:23 -0600 Subject: [PATCH 3/3] Apply suggestions from code review Co-authored-by: Gio Lodi --- .buildkite/pipeline.yml | 4 ++-- .buildkite/release.sh | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index a0481bdb4..b0db13abb 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -115,7 +115,7 @@ steps: # the `build.tag == null` clause skips it. Android publish on tag # builds is still load-bearing (produces the `vX.Y.Z` Maven artifact) # and intentionally not gated here. - if: build.pull_request.id == null && build.env("NEW_VERSION") == null && build.tag == null + if: build.env("NEW_VERSION") == null && build.tag == null && build.pull_request.id == null command: | buildkite-agent artifact download '*.xcframework.zip' . buildkite-agent artifact download '*.xcframework.zip.checksum.txt' . @@ -129,7 +129,7 @@ steps: command: .buildkite/publish-pr-xcframework.sh plugins: *plugins - - label: ':rocket: Publish Swift release ${NEW_VERSION:-(no version)}' + - label: ':rocket: Publish Swift release ${NEW_VERSION:-(Error: No version found!)}' depends_on: - validate-release - build-xcframework diff --git a/.buildkite/release.sh b/.buildkite/release.sh index 87860d570..5e35795a6 100755 --- a/.buildkite/release.sh +++ b/.buildkite/release.sh @@ -1,8 +1,9 @@ #!/bin/bash + set -euo pipefail if [[ -z "${NEW_VERSION:-}" ]]; then - echo "ERROR: NEW_VERSION is not set or empty." >&2 + echo "ERROR: NEW_VERSION is not set or is empty." >&2 echo "Set NEW_VERSION=vX.Y.Z when triggering this build." >&2 exit 1 fi