Skip to content

ci(ios): publish + prune per-PR XCFramework snapshot branches#495

Merged
jkmassel merged 7 commits into
trunkfrom
jkmassel/xcframework-per-pr
May 11, 2026
Merged

ci(ios): publish + prune per-PR XCFramework snapshot branches#495
jkmassel merged 7 commits into
trunkfrom
jkmassel/xcframework-per-pr

Conversation

@jkmassel
Copy link
Copy Markdown
Contributor

@jkmassel jkmassel commented May 5, 2026

Summary

  • For each PR commit, upload the signed XCFramework to s3://a8c-apps-public-artifacts/gutenbergkit/pr-builds/<n>/, force-push a pr-build/<n> branch with Package.swift rewritten to consume the binary target, and post a sticky PR comment + Buildkite annotation pointing consumers at the branch.
  • On every trunk push, sweep pr-build/* refs whose PR is closed (merged or rejected) so the branches don't accumulate.
  • Existing trunk/tag publish step (Fastfile:25–43) gated on build.pull_request.id == null so PRs don't double-upload to the commit-SHA prefix.
  • Mirrors Automattic/wordpress-rs#1291 (publish) and #1321 (cleanup) — same comment marker pattern (<!-- gutenbergkit-xcframework-build -->), same lane structure, same sweep approach.

How a consumer uses it

The PR comment will look like:

XCFramework Build

This PR's XCFramework is available for testing. Add to your Package.swift:

.package(url: "https://github.com/wordpress-mobile/GutenbergKit", branch: "pr-build/<n>")

Built from <short-sha>

The pr-build/<n> branch points at the PR's HEAD commit with Package.swift:9 rewritten from .local to .release(version: "pr-builds/<n>", checksum: "<sha>"), so SPM resolves the resources via the binary target on CDN.

Changes

Publish

  • .buildkite/pipeline.yml: Gate the existing :s3: Publish XCFramework to S3 step on non-PR builds; add a :swift: :package: Publish PR XCFramework step that runs only when build.pull_request.id != null.
  • .buildkite/publish-pr-xcframework.sh (new): Skip fork PRs (no bot creds), source use-bot-for-git, download the build artifacts, and invoke the new fastlane lane.
  • fastlane/Fastfile: Add publish_pr_xcframework lane that uploads to S3, rewrites Package.swift, force-pushes pr-build/<n>, posts/updates the sticky PR comment, and writes a Buildkite annotation. Helpers paginate the comments lookup so the marker isn't missed on PRs with >100 comments — small fix relative to the wordpress-rs prior art.

Cleanup

  • .buildkite/cleanup-pr-build-branches.sh (new): Guards on BUILDKITE_BRANCH == "trunk", sources use-bot-for-git, enumerates via git ls-remote --heads origin 'refs/heads/pr-build/*', queries GET /repos/wordpress-mobile/GutenbergKit/pulls/<n> with GITHUB_TOKEN, and deletes closed-PR refs with batched git push origin :refs/heads/... (chunks of 50). Skips on non-200 responses so we never delete a ref we couldn't verify.
  • .buildkite/pipeline.yml: New trunk-only :wastebasket: Clean up pr-build/* step.

What we explored

  • Generated-source equivalent. wordpress-rs commits its Swift FFI bindings onto the snapshot branch because they're gitignored Swift source consumed by a regular .target. GutenbergKit's equivalent is the .html/.js bundle, but on the snapshot branch we're switching GutenbergKitResources from a source target with resources: [.copy("Gutenberg")] to a .binaryTarget — the resources live inside the XCFramework on that path, so we don't need to commit them. Simpler than wordpress-rs.
  • Trunk snapshot branch. wordpress-rs also publishes a trunk-build branch keyed by SHA. Deferred to a follow-up — the existing trunk gutenbergkit/<commit-sha>/ upload still runs unchanged, and the use case (downstream apps consuming a known-good trunk binary via SPM branch) isn't load-bearing yet.
  • Dropping the local resource files. .gitignore:200–202 has a comment saying the ios/Sources/GutenbergKitResources/Gutenberg/{assets,index.html} files are checked in temporarily until "this is published like Android in CI." Once consumers migrate to .release(...), those 61 files can finally be ignored — separate follow-up so consumers can flip first.

Test plan

Publish

This PR exercises its own publish path, so several items are confirmed against build #2235.

  • Publish PR XCFramework step runs and succeeds — confirmed: swift-package-publish-pr-xcframework SUCCESS in build #2235.
  • pr-build/<n> branch appears on the remote with Package.swift:9 rewritten to .release(version: "pr-builds/<n>", checksum: "...") — confirmed: pr-build/495 has .release(version: "pr-builds/495", checksum: "d2d8a91c...").
  • <!-- gutenbergkit-xcframework-build --> comment posted with correct branch URL and short SHA — confirmed: comment by wpmobilebot.
  • Push another commit to the same PR; verify the existing comment is updated (PATCHed), not duplicated — confirmed: the cherry-pick force-push triggered a second build, and the GH API shows a single comment with created_at: 23:26:54Z / updated_at: 23:42:30Z and the latest short SHA 8027996c in the body.
  • Buildkite annotation appears in the build summary with the same body — confirmed via the Buildkite API (context: xcframework, style: info).
  • s3://a8c-apps-public-artifacts/gutenbergkit/pr-builds/<n>/ has the zip + checksum — confirmed: cdn.a8c-ci.services/gutenbergkit/pr-builds/495/...zip returns HTTP 200 (18.6 MB) and the .checksum.txt returns 200.
  • Pull the pr-build/<n> branch into a consumer and confirm SPM resolves the XCFramework — confirmed in a scratch SPM consumer: swift package resolve resolved pr-build/495 (51a9bb6), downloaded the binary artifact from CDN, checksum validated, and swift build --triple arm64-apple-ios17.0-simulator linked successfully. The framework contains both ios-arm64 and ios-arm64_x86_64-simulator slices, each with GutenbergKitResources (Mach-O dylib), a Swift module (module.modulemap + .swiftinterface), and a GutenbergKit_GutenbergKitResources.bundle carrying Gutenberg/index.html + 59 web assets.
  • Verify a non-PR push (trunk) still hits the original :s3: step and uploads under gutenbergkit/<commit-sha>/ — verifiable on first trunk push after merge.
  • Confirm a fork PR (or a simulated BUILDKITE_PULL_REQUEST_REPO mismatch) skips cleanly rather than failing on git push — not exercised; covered by the early-exit guard in publish-pr-xcframework.sh.

Cleanup

These all require trunk pushes; not verifiable pre-merge.

  • First trunk merge after this lands sweeps any orphan pr-build/* branches accumulated from publish-side testing.
  • Subsequent trunk merges delete the just-merged PR's pr-build/<n> branch.
  • Step is skipped on PR builds.
  • Open-PR pr-build/* branches are left alone.
  • If GitHub returns non-200 for a PR (deleted, rate-limited, transient), the corresponding branch is preserved and the step continues.

@github-actions github-actions Bot added the [Type] Build Tooling Issues or PRs related to build tooling label May 5, 2026
@wpmobilebot
Copy link
Copy Markdown

wpmobilebot commented May 5, 2026

XCFramework Build

This PR's XCFramework is available for testing. Add the following to your Package.swift:

.package(url: "https://github.com/wordpress-mobile/GutenbergKit", branch: "pr-build/495")

Built from 5457520

@jkmassel jkmassel changed the title ci(ios): publish per-PR XCFramework snapshot branch ci(ios): publish + prune per-PR XCFramework snapshot branches May 5, 2026
@jkmassel jkmassel requested a review from mokagio May 6, 2026 03:37
@jkmassel jkmassel self-assigned this May 6, 2026
@jkmassel jkmassel force-pushed the jkmassel/xcframework-per-pr branch from 8027996 to d7830c9 Compare May 6, 2026 03:37
Comment thread .buildkite/publish-pr-xcframework.sh Outdated
exit 0
fi

PR_NUMBER="$BUILDKITE_PULL_REQUEST"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick. Might as well have read BUILDKITE_PULL_REQUEST directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5457520

Comment thread fastlane/Fastfile Outdated
Comment thread fastlane/Fastfile Outdated
)
else
github_api(
server_url: 'https://api.github.com',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick. This URL is duplicated here and in the lane below. I could be extracted in a constant.

Comment thread fastlane/Fastfile Outdated
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR extends the iOS CI pipeline to publish per-PR signed XCFramework snapshots consumable via SwiftPM branch references, and adds a trunk-only cleanup sweep to delete stale pr-build/* branches for closed PRs. It fits into the existing release/publish automation by separating PR snapshot publishing from the existing trunk/tag S3 publish flow.

Changes:

  • Add a publish_pr_xcframework fastlane lane to upload PR artifacts to S3, force-push a pr-build/<n> branch with Package.swift rewritten to use the binary target, and post/update a sticky PR comment + Buildkite annotation.
  • Update Buildkite pipeline to (a) gate the existing S3 publish step to non-PR builds, (b) add a PR-only publish step, and (c) add a trunk-only cleanup step.
  • Add scripts to publish PR XCFramework artifacts and to prune pr-build/* branches for PRs that are closed.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.

File Description
fastlane/Fastfile Adds publish_pr_xcframework lane plus helpers to rewrite Package.swift, push pr-build/<n> branches, and upsert PR comments/annotations.
.buildkite/publish-pr-xcframework.sh New PR-only wrapper script to download XCFramework artifacts and invoke the new fastlane lane (skips fork PRs).
.buildkite/pipeline.yml Gates the existing S3 publish step to non-PR builds; adds PR publish and trunk cleanup steps.
.buildkite/cleanup-pr-build-branches.sh New trunk-only cleanup script that queries GitHub PR state and deletes pr-build/* branches for closed PRs in batches.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread fastlane/Fastfile Outdated
Comment on lines +45 to +53
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)
Comment thread fastlane/Fastfile
Comment on lines +166 to +179
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}")
```

<sub>Built from #{short_sha}</sub>
MARKDOWN
Comment thread fastlane/Fastfile
Comment on lines +229 to +235
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
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replaced this whole method with the release toolkit version

Comment on lines +57 to +63
if [[ "$http_code" != "200" ]]; then
echo "Skipping $branch (HTTP $http_code from GitHub)"
continue
fi

state=$(printf '%s' "$body" | jq -r '.state')

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is fine – all of our CI is pretty much guaranteed to have jq present

Copy link
Copy Markdown
Contributor

@mokagio mokagio left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Works as advertised.

Image Image

This approach will definitely make it easier to fetch builds from WIP branches. Overriding preceding builds on the same PR branch is a bit of a trade-off in term of reproducibility, but I think it's acceptable this is all in the context of PRs, when the build goes into trunk or a tag, it will no longer be removed (however, we are yet to implement the atomic Package.swift update system for those cases)

Have you considered running the pr-build/* cleanup job on a daily schedule rather than as an appendix of trunk builds? I don't know if it would change much and it might be simpler to manage having it as it is, but just wanted to put it out there. Having it on a schedule would feel neater to me. It somehow seems out of place for a trunk build to do cleanup.

jkmassel and others added 7 commits May 11, 2026 12:18
For each PR commit, upload the signed XCFramework to S3 under
`gutenbergkit/pr-builds/<n>/`, force-push a `pr-build/<n>` branch
with `Package.swift` rewritten to consume that binary target, and
post a sticky PR comment + Buildkite annotation pointing consumers
at the branch. Mirrors the wordpress-rs flow.

The existing trunk/tag publish step is gated on
`build.pull_request.id == null` so we don't double-upload on PRs.
Skips fork PRs explicitly to avoid `git push` 403s.
Each PR build force-pushes a `pr-build/<n>` snapshot branch. Nothing
prunes them, so they accumulate. Add a Buildkite step that runs on
trunk pushes, lists `pr-build/*` refs, queries each PR's state via the
GitHub API, and deletes the refs whose PR is `closed` (covers both
merged and rejected — GitHub collapses them).

Skips a branch on any non-200 response so we never delete a ref we
couldn't verify. Mirrors the wordpress-rs sweep step.
Co-authored-by: Gio Lodi <giovanni.lodi42@gmail.com>
Address review:

- Parse `pr_number` via `Integer(_, 10)` rather than `.to_s + empty?`,
  so non-numeric input fails loudly instead of producing a `pr-build/abc`
  ref. Base 10 is explicit to avoid the `"0042" → 34` octal gotcha.
- Drop the `PR_NUMBER` shell local — `BUILDKITE_PULL_REQUEST` is already
  guarded for non-`"false"` above, and the local doesn't earn its keep
  for one downstream use.
…EQUEST` directly

- Replace ~50 lines of hand-rolled upsert/find/paginate against the
  GitHub comments API with the release-toolkit `comment_on_pr` action,
  which handles the marker-based sticky-comment dance and auto-paginates
  via Octokit.
- Drop the `pr_number:` lane option and read `BUILDKITE_PULL_REQUEST`
  inside the lane, mirroring the pattern in
  `Fastlane::Wpmreleasetoolkit::EnvManager#pull_request_number`. The
  shell script no longer needs to pass it through.

Net -41 lines.
Prefer the canonical helper over the hand-rolled equivalent. `pull_request_number`
already handles the `BUILDKITE_PULL_REQUEST="false"` sentinel and Integer parse;
`commit_hash` wraps `BUILDKITE_COMMIT`. Sets up the manager at file load with a
placeholder env file name — no `.env` is shipped, CI reads straight from the
process environment.
Fastlane discovers plugin actions via `lib/.../actions/**/*.rb`, but doesn't
run the plugin's main entry file — so classes outside `actions/` (like
`Fastlane::Wpmreleasetoolkit::EnvManager`) aren't auto-loaded.

Without this require, file-load of the Fastfile dies with:

    Fastfile:25:in 'parsing_binding': uninitialized constant
    Fastlane::Wpmreleasetoolkit::EnvManager (NameError)

…which fails every lane invocation, including the unrelated
`xcframework_sign` lane on the `Build XCFramework` step.
@jkmassel jkmassel force-pushed the jkmassel/xcframework-per-pr branch from 8db40f3 to 53d0301 Compare May 11, 2026 18:19
@wpmobilebot
Copy link
Copy Markdown

XCFramework Build

This PR's XCFramework is available for testing. Add the following to your Package.swift:

.package(url: "https://github.com/wordpress-mobile/GutenbergKit", branch: "pr-build/495")

Built from 53d0301

@jkmassel
Copy link
Copy Markdown
Contributor Author

Have you considered running the pr-build/* cleanup job on a daily schedule rather than as an appendix of trunk builds? I don't know if it would change much and it might be simpler to manage having it as it is, but just wanted to put it out there. Having it on a schedule would feel neater to me. It somehow seems out of place for a trunk build to do cleanup.

As a rule, I try to avoid having any job on a schedule that doesn't need to be scheduled 🤷.

This particular task is pretty tightly coupled to the PR lifecycle – a PR is closed, a new commit appears on trunk, that commit's CI run cleans up the PR it came from. I wouldn't hate something that runs post-merge and not part of trunk, but then it couldn't be on Buildkite (AFAIK).

I'm open to changing it in the future if something isn't working great though!

@jkmassel jkmassel merged commit 694a424 into trunk May 11, 2026
22 checks passed
@jkmassel jkmassel deleted the jkmassel/xcframework-per-pr branch May 11, 2026 18:36
@mokagio
Copy link
Copy Markdown
Contributor

mokagio commented May 12, 2026

As a rule, I try to avoid having any job on a schedule that doesn't need to be scheduled 🤷.

Fair enough.

I wouldn't hate something that runs post-merge and not part of trunk, but then it couldn't be on Buildkite (AFAIK).

I suppose we could have something similar to the way we use GHA to re-run the Dangermattic step on Buildkite upon label and milestone changes in PRs. But that seems like a lot of work and brittleness for little gain.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Type] Build Tooling Issues or PRs related to build tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants