diff --git a/.buildkite/cleanup-pr-build-branches.sh b/.buildkite/cleanup-pr-build-branches.sh new file mode 100755 index 000000000..13db49b6b --- /dev/null +++ b/.buildkite/cleanup-pr-build-branches.sh @@ -0,0 +1,86 @@ +#!/bin/bash + +set -euo pipefail + +# Deletes `pr-build/` branches whose PR is closed (merged or rejected). +# Runs on trunk pushes so the just-merged PR's branch gets cleaned up +# immediately, and any orphans accumulated from prior failures get swept too. + +if [[ "${BUILDKITE_BRANCH:-}" != "trunk" ]]; then + echo "Not a trunk build (branch=${BUILDKITE_BRANCH:-unset}), skipping" + exit 0 +fi + +if [[ -z "${GITHUB_TOKEN:-}" ]]; then + echo "GITHUB_TOKEN not set, cannot query PR state" >&2 + exit 1 +fi + +GITHUB_REPO="wordpress-mobile/GutenbergKit" + +echo '--- :robot_face: Use bot for Git operations' +source use-bot-for-git + +echo "--- :mag: Listing pr-build/* branches on origin" +mapfile -t branches < <( + git ls-remote --heads origin 'refs/heads/pr-build/*' \ + | awk '{print $2}' \ + | sed 's|^refs/heads/||' +) + +echo "Found ${#branches[@]} pr-build branches" + +if [[ ${#branches[@]} -eq 0 ]]; then + exit 0 +fi + +echo "--- :github: Checking PR state for each branch" +to_delete=() +for branch in "${branches[@]}"; do + pr_number="${branch#pr-build/}" + if ! [[ "$pr_number" =~ ^[0-9]+$ ]]; then + echo "Skipping $branch (unexpected suffix)" + continue + fi + + response=$( + curl --silent --show-error \ + --write-out $'\n%{http_code}' \ + --header "Authorization: Bearer ${GITHUB_TOKEN}" \ + --header "Accept: application/vnd.github+json" \ + --header "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/repos/${GITHUB_REPO}/pulls/${pr_number}" + ) + http_code=$(printf '%s' "$response" | tail -n1) + body=$(printf '%s' "$response" | sed '$d') + + if [[ "$http_code" != "200" ]]; then + echo "Skipping $branch (HTTP $http_code from GitHub)" + continue + fi + + state=$(printf '%s' "$body" | jq -r '.state') + + if [[ "$state" == "closed" ]]; then + echo "Marking $branch for deletion (PR #$pr_number is closed)" + to_delete+=("$branch") + else + echo "Keeping $branch (PR #$pr_number is $state)" + fi +done + +if [[ ${#to_delete[@]} -eq 0 ]]; then + echo "No closed PR branches to delete" + exit 0 +fi + +echo "--- :wastebasket: Deleting ${#to_delete[@]} stale branches" +chunk_size=50 +for ((i=0; i<${#to_delete[@]}; i+=chunk_size)); do + chunk=("${to_delete[@]:i:chunk_size}") + refspecs=() + for branch in "${chunk[@]}"; do + refspecs+=(":refs/heads/${branch}") + done + git push origin "${refspecs[@]}" +done diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index fac81e13f..4ac399855 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -80,8 +80,8 @@ steps: - label: ':xcode: Build XCFramework' key: build-xcframework depends_on: - - build-react - - swift-test-library + - build-react + - swift-test-library command: | buildkite-agent artifact download dist.tar.gz . tar -xzf dist.tar.gz @@ -97,6 +97,7 @@ steps: - label: ':s3: Publish XCFramework to S3' depends_on: build-xcframework + if: build.pull_request.id == null command: | buildkite-agent artifact download '*.xcframework.zip' . buildkite-agent artifact download '*.xcframework.zip.checksum.txt' . @@ -107,6 +108,12 @@ steps: bundle exec fastlane publish_to_s3 version:${NEW_VERSION:-${BUILDKITE_TAG:-$BUILDKITE_COMMIT}} plugins: *plugins + - label: ':swift: :package: Publish PR XCFramework' + depends_on: build-xcframework + if: build.pull_request.id != null + command: .buildkite/publish-pr-xcframework.sh + plugins: *plugins + - label: ':ios: Test iOS E2E' depends_on: build-react command: | @@ -149,3 +156,8 @@ steps: - 'android/Gutenberg/build/outputs/androidTest-results/connected/**/*' - 'android/Gutenberg/build/outputs/buildkite-logs/**/*' - 'android/Gutenberg/build/outputs/connected_android_test_additional_output/**/*' + + - label: ':wastebasket: Clean up `pr-build/*` branches for closed PRs' + if: build.branch == "trunk" + command: .buildkite/cleanup-pr-build-branches.sh + plugins: *plugins diff --git a/.buildkite/publish-pr-xcframework.sh b/.buildkite/publish-pr-xcframework.sh new file mode 100755 index 000000000..43ea831be --- /dev/null +++ b/.buildkite/publish-pr-xcframework.sh @@ -0,0 +1,30 @@ +#!/bin/bash +set -euo pipefail + +if [[ "${BUILDKITE_PULL_REQUEST:-false}" == "false" ]]; then + echo "Not a PR build, skipping PR XCFramework publish" + exit 0 +fi + +# Skip on fork PRs: bot credentials and S3 secrets aren't available, and we +# couldn't push the snapshot branch back to the canonical repo anyway. +if [[ -n "${BUILDKITE_PULL_REQUEST_REPO:-}" ]] \ + && [[ "$BUILDKITE_PULL_REQUEST_REPO" != *"wordpress-mobile/GutenbergKit"* ]]; then + echo "PR is from a fork (${BUILDKITE_PULL_REQUEST_REPO}), skipping XCFramework publish" + exit 0 +fi + +PR_NUMBER="$BUILDKITE_PULL_REQUEST" + +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 PR build for PR #${PR_NUMBER}" +bundle exec fastlane publish_pr_xcframework pr_number:"$PR_NUMBER" diff --git a/fastlane/Fastfile b/fastlane/Fastfile index c4af8566f..7cd7960e0 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -4,6 +4,9 @@ PROJECT_ROOT = File.expand_path('..', __dir__) APPLE_TEAM_ID = 'PZYM8XX95Q' +GITHUB_REPO = 'wordpress-mobile/GutenbergKit' +XCFRAMEWORK_COMMENT_MARKER = '' + ASC_API_KEY_ENV_VARS = %w[ APP_STORE_CONNECT_API_KEY_KEY_ID APP_STORE_CONNECT_API_KEY_ISSUER_ID @@ -39,6 +42,21 @@ lane :publish_to_s3 do |options| ) end +lane :publish_pr_xcframework do |options| + pr_number = options[:pr_number].to_s + UI.user_error!('pr_number is required') if pr_number.empty? + + branch_name = "pr-build/#{pr_number}" + version = "pr-builds/#{pr_number}" + + publish_to_s3(version: version) + push_xcframework_snapshot_branch(branch_name: branch_name, version: version, checksum: xcframework_checksum) + + body = xcframework_comment_body(branch_name: branch_name, commit_sha: ENV.fetch('BUILDKITE_COMMIT', nil)) + upsert_pr_xcframework_comment(pr_number: pr_number, body: body) + post_buildkite_annotation(body: body) +end + lane :xcframework_sign do sh( 'codesign', @@ -123,3 +141,95 @@ def get_required_env!(key) UI.user_error!("Environment variable `#{key}` is not set.") end + +def push_xcframework_snapshot_branch(branch_name:, version:, checksum:) + package_swift = File.join(PROJECT_ROOT, 'Package.swift') + rewrite_resources_mode!(package_swift, version: version, checksum: checksum) + + sh("git checkout -B #{branch_name}") + git_commit(path: package_swift, message: "Update Package.swift for #{version}") + sh("git push -f origin #{branch_name}") +end + +def rewrite_resources_mode!(package_swift, version:, checksum:) + prefix = 'let resourcesMode: DependencyMode =' + replacement = %(#{prefix} .release(version: "#{version}", checksum: "#{checksum}")\n) + + lines = File.readlines(package_swift) + matches = lines.count { |line| line.start_with?(prefix) } + UI.user_error!("Expected exactly one `#{prefix}` line in Package.swift, found #{matches}") unless matches == 1 + + rewritten = lines.map { |line| line.start_with?(prefix) ? replacement : line } + File.write(package_swift, rewritten.join) +end + +def xcframework_comment_body(branch_name:, commit_sha:) + short_sha = (commit_sha || 'unknown')[0, 8] + <<~MARKDOWN + #{XCFRAMEWORK_COMMENT_MARKER} + ## XCFramework Build + + This PR's XCFramework is available for testing. Add to your `Package.swift`: + + ```swift + .package(url: "https://github.com/#{GITHUB_REPO}", branch: "#{branch_name}") + ``` + + Built from #{short_sha} + MARKDOWN +end + +def upsert_pr_xcframework_comment(pr_number:, body:) + github_token = ENV.fetch('GITHUB_TOKEN', nil) + unless github_token + UI.important('GITHUB_TOKEN not set, skipping PR comment') + return + end + + existing = find_existing_xcframework_comment(github_token: github_token, pr_number: pr_number) + if existing + github_api( + server_url: 'https://api.github.com', + api_token: github_token, + http_method: 'PATCH', + path: "/repos/#{GITHUB_REPO}/issues/comments/#{existing['id']}", + body: { body: body } + ) + else + github_api( + server_url: 'https://api.github.com', + api_token: github_token, + http_method: 'POST', + path: "/repos/#{GITHUB_REPO}/issues/#{pr_number}/comments", + body: { body: body } + ) + end +end + +def find_existing_xcframework_comment(github_token:, pr_number:) + page = 1 + loop do + result = github_api( + server_url: 'https://api.github.com', + api_token: github_token, + http_method: 'GET', + path: "/repos/#{GITHUB_REPO}/issues/#{pr_number}/comments?per_page=100&page=#{page}" + ) + comments = result[:json] || [] + return nil if comments.empty? + + found = comments.find { |c| c['body']&.include?(XCFRAMEWORK_COMMENT_MARKER) } + return found if found + return nil if comments.length < 100 + + page += 1 + end +end + +def post_buildkite_annotation(body:) + return unless ENV['BUILDKITE_AGENT_ACCESS_TOKEN'] + + IO.popen(['buildkite-agent', 'annotate', '--context', 'xcframework', '--style', 'info'], 'w') do |io| + io.write(body) + end +end