diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index 4ac399855..a0481bdb4 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,19 @@ steps: - label: ':s3: Publish XCFramework to S3' depends_on: build-xcframework - if: build.pull_request.id == 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 - # 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_COMMIT plugins: *plugins - label: ':swift: :package: Publish PR XCFramework' @@ -114,7 +129,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..48177da63 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,38 @@ 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 - - print_status "Creating GitHub release: v$version" + local sha=$2 + local tag="v$version" + local prefix="" if [ "$DRY_RUN" = "true" ]; then - return + prefix="[DRY RUN] " fi - gh release create "v$version" --generate-notes --title "$version" - print_success "GitHub release created: v$version" + echo + print_status "${prefix}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,35 +365,29 @@ 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 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 4b0118b27..fb9ecaee2 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,38 @@ 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 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 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/` snapshot-branch shape but published under a tag instead of a branch. + +### 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`). -After the release is created, it is ready for integration into the WordPress app. +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/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..33b1a908d 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -71,6 +71,104 @@ 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) + + # 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, 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/ + + UI.user_error!("Tag #{version} already exists on the remote.") \ + if git_tag_exists(tag: version, remote: true, remote_name: 'origin') + + 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 + + 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, + 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) + 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}" + ) + 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 sh( 'codesign', @@ -146,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