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',