From 60af26f86c1ff856bbb9197b8c5a60298dd4b78b 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] 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',