Skip to content

ci(ios): publish tagged releases as binary targets via Buildkite#502

Draft
jkmassel wants to merge 2 commits into
trunkfrom
jkmassel/drop-committed-js-build
Draft

ci(ios): publish tagged releases as binary targets via Buildkite#502
jkmassel wants to merge 2 commits into
trunkfrom
jkmassel/drop-committed-js-build

Conversation

@jkmassel
Copy link
Copy Markdown
Contributor

@jkmassel jkmassel commented May 11, 2026

Summary

  • bin/release.sh stops at "bump versions on trunk and push" — no more git tag, no more gh release create.
  • A new :rocket: Publish Swift release $NEW_VERSION Buildkite step, gated on build.env("NEW_VERSION") != null, takes over: builds + signs the XCFramework, uploads to S3, rewrites Package.swift to .release(version:, checksum:), tags, and creates the GitHub Release.
  • Mirrors the wordpress-rs tag-release flow (release/validate/update_swift_package/publish_release_to_github lanes; tag's commit lives off trunk, only the tag ref is pushed).

There's a bit of awkward duplication in this PR – once we can remove the committed JS/HTML files, we can delete a bunch of release code (and align what we have against Ruby/Release Toolkit instead of bash).

Why

Tagged releases currently ship Package.swift in .local mode, so SPM consumers (WordPress-iOS pins v0.15.0) resolve the JS bundle from ios/Sources/GutenbergKitResources/Gutenberg/ — which is why those 61 files are still committed despite #495 wiring up XCFramework distribution for PR builds.

Once a tag's Package.swift points at the prebuilt XCFramework on CDN (this PR), and WordPress-iOS bumps to a tag cut under the new flow, the committed iOS bundle and the two commented-out lines at .gitignore:200-202 can finally be dropped.

How a release works after this

Step 1 — local (same as today, minus the tag/release):

```bash
make release VERSION_TYPE=patch
```

Bumps package.json / GutenbergKitVersion.swift / GutenbergKitVersion.kt, runs make build, commits as chore(release): X.Y.Z, pushes to trunk. Done. The script prints the SHA of the commit it just pushed — pin that SHA when triggering the Buildkite build below.

Step 2 — Buildkite:

  1. Open https://buildkite.com/automattic/gutenbergkit/builds/new
  2. Branch: trunk
  3. Commit: the SHA printed by Step 1 (pin it — leaving it blank lets a concurrent merge tag the wrong commit)
  4. Environment Variables: NEW_VERSION=vX.Y.Z

The build runs:

  1. :white_check_mark: Validate Swift release — fast-fails on malformed tag names or if the tag/Release already exists. Tag check uses git_tag_exists(remote: true); release check uses get_github_release with an explicit api_token and asserts the response was a real 200 (not a swallowed 401/404/network error). Runs early so a bad NEW_VERSION short-circuits before the XCFramework build.
  2. :rocket: Publish Swift release — gated on validate-release + the XCFramework build + lint/test steps. The lane:
    1. Re-invokes validate as defense-in-depth — covers a future pipeline edit that drops the validate-release dependency. Cheap no-op when the earlier Buildkite step already passed.
    2. Rewrites Package.swift to .release(version: "vX.Y.Z", checksum: ...) (reusing the rewrite_resources_mode! helper from ci(ios): publish + prune per-PR XCFramework snapshot branches #495).
    3. Uploads the XCFramework to s3://a8c-apps-public-artifacts/gutenbergkit/vX.Y.Z/.
    4. Checks out a local release/vX.Y.Z branch, commits the rewrite, tags vX.Y.Z, and pushes only the tag via push_git_tags. git push <tag> carries the commit along with the tag ref, so the tag's commit becomes reachable on origin via the tag alone — no branch ref ever lives on remote.
    5. Calls set_github_release (release-toolkit action) against the just-pushed tag — auto-generated notes, marks as prerelease when the version contains -, uploads the XCFramework + checksum as assets. Authenticates with an explicit GITHUB_TOKEN so we don't depend on the agent's ambient gh auth.

The tag is pushed before the GH Release is created. Once the tag is on origin, SPM consumers can already resolve vX.Y.Z against the prebuilt XCFramework on CDN — the GH Release is metadata + an asset mirror on top of that. If set_github_release fails, the tag is unaffected and an operator can recreate the Release manually against the existing tag.

The tag's commit is parented on the release commit but unreachable from trunk's history — same shape as the pr-build/<n> and (deferred) trunk-build snapshot branches, just published under a tag ref instead of a branch ref.

The release lane is the CI orchestrator — don't run it locally: it pushes the tag, 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.

Tag-triggered builds (Android still relies on these)

Pushing the tag triggers a separate Buildkite build (the pipeline has "Build tags" enabled). For Android, that build is still load-bearing — prepareToPublishToS3 resolves the version to vX.Y.Z from --tag-name and produces the canonical vX.Y.Z Maven artifact (the trunk branch-build only produces trunk-<sha>). The plugin hard-fails if the version is already published, so the trunk vs. tag builds aren't competing.

For iOS, the rocket step already uploads to gutenbergkit/vX.Y.Z/, so the tag-build's :s3: would just re-upload the same bytes. This PR adds && build.tag == null to the :s3: gate to skip it on tag builds. A cleaner future refactor would move Android publish into the rocket step too, then "Build tags" can be turned off entirely — out of scope here.

Changes

Fastfile

  • fastlane/Fastfile: Four new lanes — release (orchestrator: validate → rewrite → S3 → tag/Release), validate, update_swift_package, publish_release_to_github. The orchestrator re-invokes validate at the top so a future pipeline edit dropping the validate-release dep can't silently bypass validation. GitHub API calls (get_github_release, set_github_release) take an explicit api_token resolved from ENV['GITHUB_TOKEN'] — no reliance on gh CLI auth (the use-bot-for-git script only sets GIT_SSH_COMMAND). validate checks lane_context[GITHUB_API_STATUS_CODE] after the release probe so a swallowed 401/404 doesn't silently green-light the publish. Reuses the existing rewrite_resources_mode!, required_version!, xcframework_checksum, xcframework_file_path helpers added in ci(ios): publish + prune per-PR XCFramework snapshot branches #495.

Pipeline

  • .buildkite/pipeline.yml: New :white_check_mark: Validate Swift release step (gated on NEW_VERSION) and :rocket: Publish Swift release $NEW_VERSION step (gated on NEW_VERSION + trunk + non-PR), with the rocket step depending on validate plus the XCFramework build, swift package tests, lint, JS tests, and the iOS/web E2E suites. Existing :s3: Publish XCFramework to S3 step now also gated on NEW_VERSION == null && build.tag == null so the rocket step owns the vX.Y.Z/ namespace and the tag-triggered re-build doesn't re-upload the same iOS artifact. Version arg simplified from ${BUILDKITE_TAG:-$BUILDKITE_COMMIT} to $BUILDKITE_COMMIT since the step now never runs on a tag build.
  • .buildkite/release.sh (new): Sources use-bot-for-git, downloads the xcframework + checksum artifacts from build-xcframework, runs bundle exec fastlane release version:$NEW_VERSION. GITHUB_TOKEN for the API calls comes from the agent environment.

Release script

  • bin/release.sh: Dropped create_tag, create_github_release, is_prerelease (unused), and the gh dependency check. Push is now git push origin trunk (no --tags). New print_publish_instructions prints the Buildkite trigger steps and the just-pushed SHA after the trunk push. --dry-run now prints the same instructions with a [DRY RUN] prefix so the operator gets a full preview of a real run.

Behavior change: prereleases now get a GitHub Release

Previously, bin/release.sh skipped gh release create for prereleases (anything with a - suffix) — the tag was pushed but no GH Release existed. The new :rocket: flow does create a GH Release for prereleases, marked as prerelease. This is intentional: every tag now has a corresponding Release page with the XCFramework + checksum attached, and consumers who previously pinned prerelease tags via Git revision can keep doing so.

Docs

  • docs/releases.md: Rewrote into a Step 1 / Step 2 flow with a recovering-from-partial-publish section.
  • docs/wordpress-app-integration.md: Updated two stale "make release creates the tag/GH release" lines.

Test plan

Most of this can only be exercised end-to-end by cutting an actual release, so most of this would have to be tested post-merge.

Local validation (no CI run required)

Exercised locally against the PR branch:

  • Shell syntax: bash -n on release.sh, bin/release.sh, publish-pr-xcframework.sh.
  • Fastfile Ruby syntax: ruby -c fastlane/Fastfile.
  • pipeline.yml YAML parses; every depends_on reference resolves to a declared key:.
  • Version regex /\Av\d+\.\d+\.\d+(-.+)?\z/ — 15 cases (valid vX.Y.Z, vX.Y.Z-prerelease; rejects bare 0.15.0, v0.15, v0.15.0.1, trailing whitespace, trailing -).
  • rewrite_resources_mode! against a copy of Package.swift — correct rewrite, idempotent re-rewrite, errors on zero / multiple matches.
  • Read-only validate-lane probes: git ls-remote --tags origin <tag> and gh release view <tag> against v0.15.0 (exists) and v999.999.999 (doesn't) — both distinguish correctly.
  • bin/release.sh patch --dry-run in a throwaway worktree on trunk — new flow runs end-to-end: pre-flight → version bump → build → commit → git push origin trunk (no --tags) → "Version bump completed successfully!" with the new [DRY RUN]-prefixed Buildkite trigger instructions. Old Creating git tag / Creating GitHub release steps confirmed gone.
  • :rocket: step if: gating walked through 6 scenarios in Ruby — trunk + NEW_VERSION triggers; PR + NEW_VERSION blocked; stale branch + NEW_VERSION blocked.

Pipeline-only checks (pre-merge, require a CI run)

  • A regular trunk push (no NEW_VERSION) hits the existing :s3: step and uploads under gutenbergkit/<commit-sha>/, same as today.
  • A regular trunk push does not trigger the new :rocket: step.
  • A PR push hits Publish PR XCFramework, not the new :rocket: step.

Release dry-run (against a throwaway version)

  • Kick off a Buildkite build on trunk with NEW_VERSION=v0.0.0-test.0. Confirm:
    • :white_check_mark: Validate Swift release v0.0.0-test.0 step runs and passes.
    • :rocket: Publish Swift release v0.0.0-test.0 step runs.
    • S3 has the xcframework at gutenbergkit/v0.0.0-test.0/.
    • Tag v0.0.0-test.0 is created on the remote.
    • The tag's Package.swift:9 reads .release(version: "v0.0.0-test.0", checksum: ...).
    • GH Release v0.0.0-test.0 is created, marked as prerelease, with the xcframework + checksum attached as assets.
    • No release/v0.0.0-test.0 branch (or any other branch) is pushed to the remote — only the tag ref.
    • The existing :s3: step is skipped on the same build.
  • Tag-triggered follow-up build: confirm the auto-triggered build on the new tag skips :s3: (gated out by build.tag == null) but still runs :android: Publish Android Library and produces the v0.0.0-test.0 Maven artifact. This is the regression test for the new tag-gate.
  • Pull the tag into a scratch SPM consumer. swift package resolve downloads the binary artifact from CDN; checksum validates.
  • Re-run the same Buildkite build → validate lane fails fast because the tag already exists.
  • Re-run with a deliberately broken GITHUB_TOKENvalidate errors out with the "GitHub API returned status … cannot determine whether it exists" message rather than silently passing.

Cleanup after dry-run

  • Delete the test tag, GH Release, and S3 artifacts.

Related

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

wpmobilebot commented May 11, 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/502")

Built from 2d598d7

@jkmassel jkmassel force-pushed the jkmassel/drop-committed-js-build branch 3 times, most recently from 27833d2 to 60af26f Compare May 13, 2026 17:50
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<x.y.z>` 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.
@jkmassel jkmassel force-pushed the jkmassel/drop-committed-js-build branch from 60af26f to 00d77af Compare May 13, 2026 22:51
Review-feedback follow-up to the publish flow added in this PR.

- `validate` lane now passes an explicit `api_token` to `get_github_release`
  and asserts `GITHUB_API_STATUS_CODE == 200` after the probe. The action
  returns `nil` for both "no such release" AND for any API failure (401,
  404, network), so without the status check an auth-misconfigured probe
  silently green-lit a publish.
- `publish_release_to_github` swaps the `gh release create` shellout for
  `set_github_release(api_token: …)` (release-toolkit action). No more
  reliance on the agent's ambient `gh` auth — `use-bot-for-git` only sets
  `GIT_SSH_COMMAND`, not `GH_TOKEN`. Asset upload, prerelease flag, and
  auto-generated notes all flow through the action's params.
- `release` orchestrator re-invokes `validate(version:, github_token:)`
  at the top so a misconfigured pipeline can't silently skip it. New
  `github_token!` helper resolves token from `options` or `ENV`.
- Refactored `publish_release_to_github` itself: dropped the staging-branch
  + draft + flip dance in favour of the simpler `push_git_tags` flow that
  wordpress-rs uses. `git push <tag>` carries the commit along with the
  tag ref, so no branch ref ever lives on origin. Tag is the last thing
  pushed before the GH Release call, so partial failure leaves either
  nothing (clean re-run) or just the GH Release missing (recoverable
  manually against the existing tag).
- `:s3: Publish XCFramework to S3` step also gated on `build.tag == null`
  so the auto-triggered tag build doesn't re-upload the same iOS artifact
  the `:rocket:` step already pushed. Android publish on tag builds is
  intentionally not gated — it's still load-bearing (produces the
  canonical `vX.Y.Z` Maven artifact via `--tag-name`).
- `bin/release.sh --dry-run` now prints the Buildkite-trigger preview it
  was previously suppressing, with a `[DRY RUN]` prefix on the leading
  status line.
- Fastfile comment on the `release` lane warns against local invocation
  (it pushes a tag and creates a real GH Release).
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.

2 participants