ci(ios): publish tagged releases as binary targets via Buildkite#502
ci(ios): publish tagged releases as binary targets via Buildkite#502jkmassel wants to merge 2 commits into
Conversation
XCFramework BuildThis PR's XCFramework is available for testing. Add the following to your .package(url: "https://github.com/wordpress-mobile/GutenbergKit", branch: "pr-build/502")Built from ff062d7 |
60af26f to
00d77af
Compare
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.
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).
2d598d7 to
ff062d7
Compare
| command: .buildkite/publish-pr-xcframework.sh | ||
| plugins: *plugins | ||
|
|
||
| - label: ':rocket: Publish Swift release ${NEW_VERSION:-(no version)}' |
There was a problem hiding this comment.
Technically, this should never resolve without NEW_VERSION, but since there's no compiler, maybe a louder fallback value might help, just in case?
| - label: ':rocket: Publish Swift release ${NEW_VERSION:-(no version)}' | |
| - label: ':rocket: Publish Swift release ${NEW_VERSION:-(Error: No version found!)}' |
| # 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 |
There was a problem hiding this comment.
Nitpick, the other steps have the env var check first and the PR id last
| if: build.pull_request.id == null && build.env("NEW_VERSION") == null && build.tag == null | |
| if: build.env("NEW_VERSION") == null && build.tag == null && build.pull_request.id == null |
| #!/bin/bash | ||
| set -euo pipefail |
There was a problem hiding this comment.
Nit
| #!/bin/bash | |
| set -euo pipefail | |
| #!/bin/bash | |
| set -euo pipefail |
| set -euo pipefail | ||
|
|
||
| if [[ -z "${NEW_VERSION:-}" ]]; then | ||
| echo "ERROR: NEW_VERSION is not set or empty." >&2 |
There was a problem hiding this comment.
| echo "ERROR: NEW_VERSION is not set or empty." >&2 | |
| echo "ERROR: NEW_VERSION is not set or is empty." >&2 |
Maybe just me, but I read the original as "is neither set nor empty"
| | 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 | |
| # 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| |
There was a problem hiding this comment.
Given the **Don't run this locally** requirement, should we add something like
UI.user_error! "This lane should only run in CI" unless ENV.fetch(["CI"], nil).nil?| 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') |
There was a problem hiding this comment.
Interesting formatting choice over
if ...
...
end
| lane :validate do |options| | ||
| version = required_version!(options) | ||
| token = github_token!(options) |
There was a problem hiding this comment.
Out of scope because require_version! predates this code, but I'd say this options parsing approach is redundant when we can pass name arguments to the block
lane :validate do |version:, token:|
...
We'll still need validation logic for the input, such as version.empty? == false but at least we wouldn't have the opaque options container.
mokagio
left a comment
There was a problem hiding this comment.
Looking good.
I say let's merge and ship a pre-release to see how it behaves IRL in CI?
Summary
bin/release.shstops at "bump versions on trunk and push" — no moregit tag, no moregh release create.:rocket: Publish Swift release $NEW_VERSIONBuildkite step, gated onbuild.env("NEW_VERSION") != null, takes over: builds + signs the XCFramework, uploads to S3, rewritesPackage.swiftto.release(version:, checksum:), tags, and creates the GitHub Release.release/validate/update_swift_package/publish_release_to_githublanes; tag's commit lives offtrunk, only the tag ref is pushed).There's a bit of awkward duplication/mess in this PR – once we can remove the committed JS/HTML files, we can delete a bunch of release code – I'll probably move most of it to Fastlane to align with our other projects, which will iron out a lot of the oddities this PR introduces (like pretending Android doesn't exist...). I wanted to keep this PR small and focused, which resulted in some oddities.
Why
Tagged releases currently ship
Package.swiftin.localmode, so SPM consumers (WordPress-iOS pinsv0.15.0) resolve the JS bundle fromios/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.swiftpoints 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-202can 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, runsmake build, commits aschore(release): X.Y.Z, pushes totrunk. Done. The script prints the SHA of the commit it just pushed — pin that SHA when triggering the Buildkite build below.Step 2 — Buildkite:
trunkNEW_VERSION=vX.Y.ZThe build runs:
:white_check_mark: Validate Swift release— fast-fails on malformed tag names or if the tag/Release already exists. Tag check usesgit_tag_exists(remote: true); release check usesget_github_releasewith an explicitapi_tokenand asserts the response was a real200(not a swallowed 401/404/network error). Runs early so a badNEW_VERSIONshort-circuits before the XCFramework build.:rocket: Publish Swift release— gated onvalidate-release+ the XCFramework build + lint/test steps. The lane:validateas defense-in-depth — covers a future pipeline edit that drops thevalidate-releasedependency. Cheap no-op when the earlier Buildkite step already passed.Package.swiftto.release(version: "vX.Y.Z", checksum: ...)(reusing therewrite_resources_mode!helper from ci(ios): publish + prune per-PR XCFramework snapshot branches #495).s3://a8c-apps-public-artifacts/gutenbergkit/vX.Y.Z/.release/vX.Y.Zbranch, commits the rewrite, tagsvX.Y.Z, and pushes only the tag viapush_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.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 explicitGITHUB_TOKENso we don't depend on the agent's ambientghauth.The tag is pushed before the GH Release is created. Once the tag is on origin, SPM consumers can already resolve
vX.Y.Zagainst the prebuilt XCFramework on CDN — the GH Release is metadata + an asset mirror on top of that. Ifset_github_releasefails, 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 thepr-build/<n>and (deferred)trunk-buildsnapshot branches, just published under a tag ref instead of a branch ref.The
releaselane 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, invokevalidate,update_swift_package,publish_to_s3, orpublish_release_to_githubindividually.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 —
prepareToPublishToS3resolves the version tovX.Y.Zfrom--tag-nameand produces the canonicalvX.Y.ZMaven artifact (the trunk branch-build only producestrunk-<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 == nullto 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-invokesvalidateat the top so a future pipeline edit dropping thevalidate-releasedep can't silently bypass validation. GitHub API calls (get_github_release,set_github_release) take an explicitapi_tokenresolved fromENV['GITHUB_TOKEN']— no reliance onghCLI auth (theuse-bot-for-gitscript only setsGIT_SSH_COMMAND).validatecheckslane_context[GITHUB_API_STATUS_CODE]after the release probe so a swallowed 401/404 doesn't silently green-light the publish. Reuses the existingrewrite_resources_mode!,required_version!,xcframework_checksum,xcframework_file_pathhelpers added in ci(ios): publish + prune per-PR XCFramework snapshot branches #495.Pipeline
.buildkite/pipeline.yml: New:white_check_mark: Validate Swift releasestep (gated onNEW_VERSION) and:rocket: Publish Swift release $NEW_VERSIONstep (gated onNEW_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 S3step now also gated onNEW_VERSION == null && build.tag == nullso the rocket step owns thevX.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_COMMITsince the step now never runs on a tag build..buildkite/release.sh(new): Sourcesuse-bot-for-git, downloads the xcframework + checksum artifacts frombuild-xcframework, runsbundle exec fastlane release version:$NEW_VERSION.GITHUB_TOKENfor the API calls comes from the agent environment.Release script
bin/release.sh: Droppedcreate_tag,create_github_release,is_prerelease(unused), and theghdependency check. Push is nowgit push origin trunk(no--tags). Newprint_publish_instructionsprints the Buildkite trigger steps and the just-pushed SHA after the trunk push.--dry-runnow 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.shskippedgh release createfor 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:
bash -nonrelease.sh,bin/release.sh,publish-pr-xcframework.sh.ruby -c fastlane/Fastfile.pipeline.ymlYAML parses; everydepends_onreference resolves to a declaredkey:./\Av\d+\.\d+\.\d+(-.+)?\z/— 15 cases (validvX.Y.Z,vX.Y.Z-prerelease; rejects bare0.15.0,v0.15,v0.15.0.1, trailing whitespace, trailing-).rewrite_resources_mode!against a copy ofPackage.swift— correct rewrite, idempotent re-rewrite, errors on zero / multiple matches.git ls-remote --tags origin <tag>andgh release view <tag>againstv0.15.0(exists) andv999.999.999(doesn't) — both distinguish correctly.bin/release.sh patch --dry-runin a throwaway worktree ontrunk— 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. OldCreating git tag/Creating GitHub releasesteps confirmed gone.:rocket:stepif:gating walked through 6 scenarios in Ruby — trunk +NEW_VERSIONtriggers; PR +NEW_VERSIONblocked; stale branch +NEW_VERSIONblocked.Pipeline-only checks (pre-merge, require a CI run)
NEW_VERSION) hits the existing:s3:step and uploads undergutenbergkit/<commit-sha>/, same as today.:rocket:step.Publish PR XCFramework, not the new:rocket:step.Release dry-run (against a throwaway version)
trunkwithNEW_VERSION=v0.0.0-test.0. Confirm::white_check_mark: Validate Swift release v0.0.0-test.0step runs and passes.:rocket: Publish Swift release v0.0.0-test.0step runs.gutenbergkit/v0.0.0-test.0/.v0.0.0-test.0is created on the remote.Package.swift:9reads.release(version: "v0.0.0-test.0", checksum: ...).v0.0.0-test.0is created, marked as prerelease, with the xcframework + checksum attached as assets.release/v0.0.0-test.0branch (or any other branch) is pushed to the remote — only the tag ref.:s3:step is skipped on the same build.:s3:(gated out bybuild.tag == null) but still runs:android: Publish Android Libraryand produces thev0.0.0-test.0Maven artifact. This is the regression test for the new tag-gate.swift package resolvedownloads the binary artifact from CDN; checksum validates.validatelane fails fast because the tag already exists.GITHUB_TOKEN→validateerrors out with the "GitHub API returned status … cannot determine whether it exists" message rather than silently passing.Cleanup after dry-run
Related
release/validate/update_swift_package/publish_release_to_githublanes.