Build a Swift 6 concurrency migration gate on a cloud Mac

Build a Swift 6 concurrency migration gate on a cloud Mac

When a long-maintained iOS project moves to Swift 6, the greatest risk is not migrating too slowly. It is enabling the new language mode all at once and discovering hundreds of concurrency diagnostics mixed in with unrelated product changes. A safer approach is to create a dedicated check on a cloud Mac first: pin the toolchain, isolate the build directory, record the current warnings, and then tighten the rules module by module.

Split the migration into two stages first

In the first stage, continue using Swift 5 language mode but set SWIFT_STRICT_CONCURRENCY to complete. This exposes non-Sendable types crossing isolation boundaries, calls into main-actor-isolated code, and unsafe global mutable state without immediately introducing every Swift 6 semantic change.

Only in the second stage should cleaned-up modules move to Swift 6. Start with foundational packages that have few dependencies, then proceed to the networking, data, and UI layers. Once leaf modules are stable, the number of diagnostics in higher-level modules will usually decrease as well.

Stage Language mode Check setting Merge requirement
Find issues Swift 5 complete Concurrency warning count must not increase
Clean up module Swift 5 complete No warnings in the target module
Complete migration Swift 6 Default strict rules All builds and tests pass

The goal of a concurrency migration gate is not to eliminate every warning on day one. It is to prevent the main branch from accumulating new issues from day one.

Create a reproducible check job

The job on the cloud Mac must explicitly record the Xcode version, Swift compiler version, and commit revision. Do not reuse DerivedData left behind by developers’ manual builds, because stale module caches can hide the real dependency graph. Use a separate directory for each commit, then clean it up according to the retention policy after the job finishes.

#!/bin/zsh
set -o pipefail
export LC_ALL=C
root="$PWD/.ci"
derived="$root/DerivedData"
result="$root/ConcurrencyCheck.xcresult"
log="$root/concurrency.log"

rm -rf "$derived" "$result"
mkdir -p "$root"

xcodebuild -version
xcrun swiftc --version
git rev-parse HEAD

xcodebuild \
  -project Example.xcodeproj \
  -scheme Example \
  -configuration Debug \
  -destination 'generic/platform=iOS Simulator' \
  -derivedDataPath "$derived" \
  -resultBundlePath "$result" \
  SWIFT_VERSION=5.0 \
  SWIFT_STRICT_CONCURRENCY=complete \
  build 2>&1 | tee "$log"

build_status=${pipestatus[1]}
exit "$build_status"

The project, scheme, and target platform should come from repository configuration rather than being maintained separately in multiple scripts. If the project uses a workspace, replace -project with -workspace and verify that the shared scheme has been committed to version control.

Add a warning budget to the existing main branch

Older projects often cannot reach zero warnings immediately. After the first run, save the number of concurrency diagnostics and use it as the initial budget. Any later commit that exceeds the budget should fail. Each time a group of issues is fixed, lower the budget in the same merge request.

budget=${CONCURRENCY_WARNING_BUDGET:-0}
count=$(grep -Ec \
  'warning:.*(Sendable|actor-isolated|concurrency-safe)' \
  .ci/concurrency.log || true)

printf 'concurrency warnings: %s, budget: %s
' "$count" "$budget"
test "$count" -le "$budget"

Text matching works as a transitional gate, but it requires a fixed LC_ALL=C, and the diagnostic wording must be reviewed whenever Xcode is upgraded. Preserve the build result bundle as a failure artifact so that a truncated terminal log is not the only evidence left behind. Once the budget reaches zero, the job can reject any concurrency warning outright.

Keep generated code from polluting the results

If API clients or models are generated by a tool, pin the generator version in the repository configuration. Externally generated code that cannot be changed immediately may be placed in a separate target module, but do not exclude the entire product module from strict checks. Otherwise, newly written code will bypass the gate as well.

Fix issues by category instead of silencing them in bulk

When a Sendable diagnostic appears, first determine whether the data actually needs to cross task boundaries. Prefer immutable value-type snapshots for read-only configuration. Put reference types with mutable caches behind an actor. For synchronization objects that must be shared, explicitly encapsulate the lock and define the access boundary.

UI state should be isolated with @MainActor at the specific type or method that owns it. Do not mark the entire data layer or every protocol as @MainActor merely to make the checks pass. Doing so implicitly moves background work back to the main thread and creates even more cross-isolation calls.

@unchecked Sendable is appropriate only when safety is already guaranteed by internal locking, immutable design, or a serial queue. Code review should answer at least three questions: which fields are protected, whether every read and write crosses the same boundary, and how future fields will remain protected. If those questions cannot be answered, the declaration should not be used.

When bridging callbacks to async, also verify that the continuation is resumed exactly once. Tests must cover both “no callback” and “duplicate callback” cases; merely silencing the compiler is not enough.

Migrate module by module and retain acceptance evidence

Before switching a module, first reduce its own warning count to zero under Swift 5 complete mode, and only then enable Swift 6. Acceptance checks should include at least Debug builds, Release builds, unit tests, and critical integration tests. Running only a Debug build for the simulator is not enough to uncover problems in optimized configurations or conditional compilation branches.

For each migration, record the module name, owner, switching commit, remaining exemptions, and result bundle path. When upgrading Xcode, establish a new baseline on a separate branch first. Do not combine the toolchain upgrade, concurrency fixes, and product features in a single change.

Keep the strict check job even after the migration is complete. Swift 6 strengthens the default constraints, but dependency updates, Objective-C boundaries, and generated code can still reintroduce isolation gaps. A stable end state is not “we once reached zero”; it is proving on every merge that the codebase has not regressed.

Frequently asked questions

Should the entire project switch to Swift 6 at once?

Usually not. Run complete concurrency checking in Swift 5 mode first, fix leaf modules and shared state, then enable Swift 6 one module at a time.

Is @unchecked Sendable an acceptable quick fix?

Only when the type already enforces safety through immutability, locking, or serial isolation. Record the justification and require a focused code review.

How can a concurrency gate avoid blocking a legacy project?

Capture the current warning count as a budget, reject only increases, and lower that budget after each cleanup until the permitted count reaches zero.

Dedicated physical Mac mini

Choose a cloud Mac rental period based on your task

Rent either M4 configuration by the day, week, month, or quarter. Node availability and real-time details are provided by the console.

Choose a configuration and order