Skip to content

ci: migrate iOS tests to CircleCI M4 Pro - #237

Merged
kyleve merged 5 commits into
mainfrom
codex/migrate-ci-to-circleci
Aug 10, 2026
Merged

ci: migrate iOS tests to CircleCI M4 Pro#237
kyleve merged 5 commits into
mainfrom
codex/migrate-ci-to-circleci

Conversation

@kyleve

@kyleve kyleve commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Posted by an AI agent on kyleve's behalf.

Summary

  • migrate the full iOS unit suite and complete serial snapshot suite to two concurrent CircleCI m4pro.medium jobs
  • keep SwiftFormat, Bumper Bowling, and the native-macOS suite on GitHub Actions
  • preserve the canonical ./test entry point, iOS 27 simulator selection, snapshot tolerances, and all 377 references while retiring the checked-in shard map
  • gate every migrated job on Xcode build 27A5228h and an available iOS 27 runtime
  • cut over required checks only after shadow and final workflows passed

Final state

CircleCI owns these stable required contexts:

  • ci/circleci: Build & Test (iOS)
  • ci/circleci: Snapshot Tests (iOS)

GitHub Actions retains:

  • SwiftFormat Lint
  • Bumper Bowling
  • Build & Test (macOS)

The CircleCI project uses its GitHub App pipeline definition, runs for pull requests targeting main and pushes to main, and has auto-cancel enabled for superseded branch workflows.

Implementation

  • define one reusable M4 Pro executor plus explicit concurrent iOS and full-suite snapshot jobs
  • hydrate and verify Git LFS snapshot references before the snapshot run
  • cache only dependency downloads keyed by .mise.toml, Package.swift, and Package.resolved; never cache DerivedData or recorded snapshot output
  • install the repository's pinned toolchain and use ./simulator plus ./test
  • apply real 45- and 60-minute process-group wall-clock limits
  • export xcresult summaries as JUnit
  • copy complete TEST_WORKDIR diagnostics only after failure, avoiding successful multi-gigabyte artifact uploads
  • remove only the long iOS and snapshot jobs from .github/workflows/ci.yml

Environment and security

The validated executors reported Apple M4 Pro (Virtual), 6 logical CPUs, 30,064,771,072 bytes of memory (about 28 GiB), arm64, macOS 26.5.1 build 25F80, Xcode 27 build 27A5228h, an iOS 27 runtime, and an iPhone 17 simulator.

There are no CircleCI project environment variables, organization contexts, additional SSH keys, or project API tokens. Checkout uses HTTPS, and every live job proved that no credential helper/extraheader remained after checkout. The installed CircleCI GitHub App itself has CircleCI's vendor-required repository write scopes, but those installation permissions are not exposed as a retained Git credential or job secret.

Validation

  • circleci config validate .circleci/config.yml
  • circleci config process .circleci/config.yml
  • ./swiftformat --lint — 0/1012 files require formatting
  • sharded CircleCI baseline: all 1,821 iOS tests and 377 snapshot references passed
  • local unsharded ./test --snapshots --review --timings: 41 suites / 377 images passed in 7m04s with no reference changes
  • unified CircleCI workflow: passed in 11m03s overall (iOS 6m40s; snapshots 10m44s)
  • final GitHub Actions workflow: only the three retained jobs ran, all passed

The attached plan listed 381 snapshot references, but the exact migration head contains 377; the shared StuffSnapshotTests scheme runs all 41 suites in one invocation. No references or tolerances changed.

Snapshot compatibility review

The sharded CircleCI baseline and unsharded local run both passed all 377 images at the existing requirement: identical dimensions and at least 99.9% of pixels within Delta E 10. A review-mode pass identified 29 non-byte-identical candidates: 22 had only scattered single-digit antialiasing/color-rounding drift; the seven double-digit cases were manually compared as reference/candidate/diff. Their hierarchy, dimensions, content, and layout matched, with differences confined to antialiasing, thin divider edges, and top-chrome rendering. No reference was re-recorded and no tolerance was loosened.

Provider comparison

Job / path Recent GitHub control Final CircleCI Improvement
iOS unit suite 15m28s 6m40s 8m48s / 57% faster
Snapshot suite 20m22s across two shards 10m44s in one job 9m38s / 47% faster
Workflow critical path 44m40s 11m03s 33m37s / 75% faster (4.04×)

The completed comparison uses GitHub Actions 31416367884 and the final unified CircleCI workflow. The two CircleCI jobs completed successfully in 6m40s and 10m44s, with an 11m03s workflow critical path.

Cost

CircleCI bills m4pro.medium at 200 credits/minute. The unified workflow consumed 17m24s of aggregate executor time, about 3,480 credits / $2.09 at the Performance-plan rate of $15 per 25,000 credits. The latest two-shard workflow consumed 25m57s, about 5,190 credits / $3.11, so reunifying snapshots saves about 1,710 credits / $1.03 (33%) per workflow. Its 11m03s critical path is 1m04s longer than the two-shard CircleCI workflow's 9m59s. Sources: CircleCI resource pricing and Performance plan overview.

Branch protection

Before cutover, classic branch protection required the GitHub-only format, iOS, Bumper, and two snapshot contexts. It now requires exactly the three retained GitHub checks and two CircleCI checks above, pinned to their respective GitHub Apps. The renamed required contexts are exercised by the current PR head. The separate pre-existing Require PR for Main repository ruleset remains disabled and unchanged.

Review focus

  • CircleCI GitHub App/checkout permissions and the explicit no-persisted-credential gate
  • download-only cache paths and failure-only artifact staging
  • the single snapshot check, retired shard tooling, and workflow filter for main
  • the second commit's removal of only GitHub's long iOS and snapshot jobs

Rollback

Before merge, close this PR and disable the CircleCI project/repository access, then restore the old classic required contexts. After merge, restore the deleted GitHub jobs from the migration parent, switch classic branch protection back to their GitHub contexts, and disable the CircleCI project. Snapshot references require no rollback.

@kyleve
kyleve force-pushed the codex/migrate-ci-to-circleci branch 4 times, most recently from 5961801 to 8fa3cde Compare August 10, 2026 19:34
Run the iOS unit suite and two serial snapshot shards concurrently on managed M4 Pro executors while the existing GitHub Actions controls remain in place.

Validate the exact Xcode and simulator runtime, keep checkout access credential-free, hydrate LFS references, export test results, and retain cold-run diagnostics without caching DerivedData.

Validation:
- circleci config validate .circleci/config.yml
- circleci config process .circleci/config.yml
- ./swiftformat --lint
@kyleve
kyleve force-pushed the codex/migrate-ci-to-circleci branch from 8fa3cde to f175a7c Compare August 10, 2026 19:45
Remove the long iOS and snapshot jobs from GitHub Actions after the parallel M4 Pro workflow passed all 1,821 iOS tests and 377 snapshot references.

Keep SwiftFormat, architecture, and native-macOS coverage on GitHub Actions.

Validation:
- circleci config validate .circleci/config.yml
- circleci config process .circleci/config.yml
- ./swiftformat --lint
- CircleCI test-ios, snapshot-1, and snapshot-2
@kyleve

kyleve commented Aug 10, 2026

Copy link
Copy Markdown
Owner Author

Posted by an AI agent on kyleve's behalf.

Here’s the complete configuration inventory, including temporary changes that were subsequently reverted.

Repository configuration

Added .circleci/config.yml:

  • Defined a managed macOS executor:
    • Xcode image 27.0
    • m4pro.medium
    • Bash with pipefail
  • Added three concurrent jobs:
    • test-ios
    • snapshot-1
    • snapshot-2
  • Added shared preparation that:
    • checks out the repository
    • hydrates Git LFS for snapshot jobs
    • fails if snapshot PNGs remain LFS pointers
    • installs the pinned mise toolchain
    • caches only tool/dependency downloads
    • explicitly excludes DerivedData and test output from caching
  • Added environment gates for:
    • exact Xcode build 27A5228h
    • available iOS 27 runtime
    • simulator selection through ./simulator
    • CPU, memory, architecture, macOS and Xcode reporting
  • Added a checkout-security gate that fails if Git leaves a credential helper or HTTP authorization header configured.
  • Added real process-group timeouts:
    • 45 minutes for iOS tests
    • 60 minutes for snapshot shards
  • Added CircleCI test-result publication by converting .xcresult bundles to JUnit.
  • Added successful snapshot timing artifacts.
  • Added failure-only diagnostics:
    • complete TEST_WORKDIR
    • crash reports
    • relevant snapshot candidate/diff images
  • Limited the workflow to:
    • PRs targeting main
    • pushes to main

Added .circleci/xcresult_to_junit.py:

  • Reads Xcode 27 result bundles with xcresulttool.
  • Converts bundles, suites, test cases, durations, failures, and skips into CircleCI-compatible JUnit XML.
  • Continues with a warning if an individual result bundle cannot be decoded.

Updated .github/workflows/ci.yml:

  • Removed Build & Test (iOS).
  • Removed the two-job snapshot matrix.
  • Retained:
    • SwiftFormat Lint
    • Bumper Bowling
    • Build & Test (macOS)
  • Kept the macOS job on GitHub’s pinned xcode-27 runner.
  • Did not change snapshot references, shard assignments, or comparison tolerances.

CircleCI organization/project configuration

I configured the CircleCI project as follows:

  • Connected kyleve/Stuff through the CircleCI GitHub App.
  • Created the build-and-test pipeline definition.
  • Set its config path to .circleci/config.yml.
  • Enabled the GitHub App trigger covering:
    • PRs opened or updated
    • default-branch pushes
    • tag pushes
  • Disabled the original broad “All pushes” trigger.
    • Tags may reach config evaluation, but the workflow’s when condition rejects them.
  • Enabled automatic cancellation of superseded non-default-branch workflows.
  • Confirmed the project contains no:
    • environment variables
    • organization contexts
    • additional SSH keys
    • project API tokens
  • Used the Performance plan after you upgraded it. I did not make the billing-plan change myself.

The installed CircleCI GitHub App is scoped to the Stuff repository. Its vendor-declared GitHub permissions are:

  • Contents: write
  • Commit statuses: write
  • Pull requests: write
  • Issues: write
  • Metadata: read

Those are installation-level app permissions. The CircleCI jobs are not given an API token, context secret, SSH key, or retained Git checkout credential.

GitHub repository configuration

Installed/authorized the CircleCI GitHub App for the Stuff repository so it could:

  • receive PR/push events
  • check out source
  • publish the three CircleCI commit statuses

Updated classic branch protection for main.

Before:

  • SwiftFormat Lint
  • Build & Test (iOS)
  • Bumper Bowling
  • Snapshot Tests (iOS, 1/2)
  • Snapshot Tests (iOS, 2/2)

After:

  • SwiftFormat Lint — pinned to GitHub Actions
  • Bumper Bowling — pinned to GitHub Actions
  • Build & Test (macOS) — pinned to GitHub Actions
  • ci/circleci: test-ios — pinned to CircleCI
  • ci/circleci: snapshot-1 — pinned to CircleCI
  • ci/circleci: snapshot-2 — pinned to CircleCI

I preserved the other classic protection settings, including admin enforcement, PR review configuration, force-push prevention, and deletion prevention.

The separate pre-existing Require PR for Main repository ruleset remains disabled and otherwise unchanged.

Temporary configuration changes

For completeness, two GitHub ruleset changes occurred during the protection audit:

  1. I temporarily activated the pre-existing disabled Require PR for Main ruleset and added status checks there.
  2. When that unexpectedly broadened enforcement, I restored it to its exact original disabled state.
  3. I then created a separate temporary active Require CI for Main ruleset containing the six checks.
  4. I discovered the repository also used classic branch protection, which still required the obsolete GitHub iOS/snapshot contexts.
  5. I migrated the classic protection checks in place and deleted the temporary ruleset.

Final state:

  • No additional active ruleset remains.
  • The old dormant ruleset is disabled.
  • Classic branch protection contains exactly the six current checks.
  • GitHub reports PR ci: migrate iOS tests to CircleCI M4 Pro #237 as MERGEABLE / CLEAN.

Use workflow-level display names matching the retired GitHub Actions iOS and snapshot jobs while preserving CircleCI's reusable job keys.\n\nValidation:\n- circleci config validate .circleci/config.yml\n- circleci config process .circleci/config.yml\n- git diff --check
Comment thread .circleci/config.yml
type: string
steps:
- run:
name: Convert Xcode results to JUnit

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

What does this do?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Posted by an AI agent on kyleve's behalf.

Simplified in e5b3fd94: CircleCI now runs the complete snapshot scheme serially in one M4 Pro job, and the checked-in shard map, balancer, CLI flags, timing-report plumbing, and their tests have been removed. The remaining configuration owns runner setup, exact-renderer/runtime gates, LFS hydration, the canonical ./test invocations, JUnit reporting, real wall-clock timeouts, credential checks, and failure diagnostics. The updated file-level walkthrough is here.

@kyleve kyleve left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Posted by an AI agent on kyleve's behalf.

Configuration walkthrough: one inline note on each changed file.

Comment thread .circleci/config.yml
@@ -0,0 +1,254 @@
version: 2.1

@kyleve kyleve Aug 10, 2026

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Posted by an AI agent on kyleve's behalf.

This file is intentionally more explicit than the retired GitHub workflow. It:

  • defines the managed Xcode 27 M4 Pro executor and two concurrent jobs: the iOS unit suite and complete serial snapshot suite;
  • hydrates and verifies LFS snapshot references;
  • installs and download-caches the pinned mise toolchain without caching DerivedData or test output;
  • gates execution on Xcode build 27A5228h, an iOS 27 runtime, and the repository-selected simulator;
  • verifies checkout did not retain a Git credential;
  • supplies real process-group wall-clock limits because CircleCI's no_output_timeout only detects silence;
  • publishes JUnit test results and per-phase snapshot timings;
  • stages complete test, crash, and snapshot diagnostics only after failure; and
  • filters execution to PRs targeting main and pushes to main.

Some of this length reflects CircleCI differences: no direct equivalents of GitHub's timeout-minutes, checkout LFS option, or jdx/mise-action. The rest is migration hardening and observability that the old workflow did not provide. Orbs could shorten the YAML, but would hide behavior behind additional dependencies. The earlier two-shard duplication has been removed: one snapshot job now pays checkout, tool installation, simulator boot, project generation, and build overhead once.

@@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""Convert Xcode 27 test result bundles into CircleCI-compatible JUnit XML."""

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Posted by an AI agent on kyleve's behalf.

This is a reporting-only adapter. ./test writes Apple's .xcresult bundles, which CircleCI's Tests UI cannot interpret directly. The script:

  1. asks xcresulttool for every result bundle's test tree;
  2. extracts bundle, suite, test name, duration, failure, and skip data;
  3. writes standard JUnit XML; and
  4. hands that XML to CircleCI's store_test_results step.

It enables structured test counts, failures, durations, and timing history in CircleCI. It does not run tests or determine job success—the canonical ./test command still owns pass/fail—and could be removed if the native Tests UI is not useful.

Comment thread .github/workflows/ci.yml
test-macos:
name: Build & Test (macOS)
# Same image as the other jobs so every target builds against one toolchain.
# Keep the native-macOS target on GitHub's pinned Xcode image.

@kyleve kyleve Aug 10, 2026

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Posted by an AI agent on kyleve's behalf.

This file now expresses the cutover boundary: GitHub Actions retains SwiftFormat Lint, Bumper Bowling, and Build & Test (macOS) on the pinned xcode-27 image. The long Build & Test (iOS) job and snapshot job were removed after their CircleCI replacements passed unchanged; CircleCI now runs the complete snapshot scheme serially in one job. Snapshot references, assertions, and tolerances were not modified.

kyleve added 2 commits August 10, 2026 14:17
Run the complete snapshot scheme serially on one CircleCI M4 Pro executor and retire the checked-in shard map, balancer, CLI selection flags, per-suite report plumbing, and their tests.\n\nPreserve the same canonical snapshot command, renderer gates, LFS hydration, assertions, review output, and failure diagnostics while avoiding duplicated setup and build compute.\n\nValidation:\n- circleci config validate .circleci/config.yml\n- circleci config process .circleci/config.yml\n- ./swiftformat --lint\n- bash -n test profile\n- ./test --snapshots --review --timings (41 suites, 377 images passed in 7m04s)\n- git diff --check
Stop the retained GitHub lint job from invoking the deleted snapshot shard tests and configuration checker.\n\nValidation:\n- ruby YAML parse of .github/workflows/ci.yml\n- rg --hidden for shard infrastructure references\n- git diff --check
@kyleve
kyleve merged commit 74a8f36 into main Aug 10, 2026
5 checks passed

kyleve commented Aug 10, 2026

Copy link
Copy Markdown
Owner Author

Posted by an AI agent on kyleve's behalf.

The benchmark completed successfully. Large is faster, but probably not worthwhile for this workload.

Measure M4 Pro Medium M4 Pro Large Benefit
iOS job 6m40s 5m30s 1m10s / 17.5% faster
Snapshot job 10m44s 9m27s 1m17s / 12.0% faster
Workflow 11m03s 10m08s 55s / 8.3% faster
Estimated cost $2.09 $3.59 $1.50 / 72% more

Large consumed approximately 5,980 credits versus Medium’s 3,480. So we pay 72% more to save 55 seconds from the actual workflow critical path.

My recommendation: keep m4pro.medium. This was only one run, but the margin is large enough that normal timing variance is unlikely to reverse the cost conclusion.

PR #239 remains open and unchanged. The monitor is now stopped.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant