Swift 6 language mode makes the compiler a concurrency auditor: instead of catching @MainActor misuse or cross-Actor sharing in review, xcodebuild fails early. You need a stable compute, version-pinned build node—full compiles without neighbor CPU jitter on tail latency. This article shows phased Swift 6 strict concurrency adoption on NUKCLOUD dedicated remote Mac nodes and locking it into PR merge gates.
00Why Migrate Now—and Why Dedicated Nodes
Swift 6 strict concurrency checks (-strict-concurrency=complete) scale with codebase size: 200–800 compile errors on first enable is common for mid-size iOS projects. Fixes span weeks or months; during that window CI must guarantee:
- Pinned Xcode minor versions: Actor isolation diagnostics change wording across Xcode versions; pinning keeps CI output comparable.
- No neighbor CPU contention: Full strict-concurrency compiles run 20–40% longer than typical debug builds; dedicated nodes keep P95 tail latency stable.
- Isolated DerivedData namespaces: Multiple PRs running concurrency checks need separate incremental caches or results become unreliable.
01Three Concurrency Check Levels and a Migration Path
Swift offers three compiler flag levels in OTHER_SWIFT_FLAGS (Xcode Build Settings) or swiftSettings in Package.swift:
| Flag | Meaning | Recommended Stage |
|---|---|---|
-strict-concurrency=minimal | Checks only basic async/await syntax compliance, not Actor boundaries | Legacy warm-up (weeks 1–2) |
-strict-concurrency=targeted | Checks Actor boundaries for types explicitly marked Sendable, @MainActor, etc. | Per-module fix period (weeks 3–6) |
-strict-concurrency=complete | Full checks equivalent to Swift 6 language mode; unhandled concurrent sharing errors | Add to gates after reaching zero errors |
In practice, migrate module by module: enable complete on leaf modules first, then core modules. SwiftPM .enableUpcomingFeature("StrictConcurrency") controls per-target levels precisely.
02Actor Isolation Boundaries: Three Frequent Error Types
About 90% of migration compile errors fall into these three; fixing them clears most red builds:
① Cross-Actor access to non-Sendable types
// ❌ Error: accessing @MainActor-isolated state from outside @MainActor
actor DataFetcher {
func load() async {
let result = await networkClient.fetch()
// ❌ passing argument of non-sendable type 'UserModel' across actor boundary
await MainActor.run { viewModel.update(result) }
}
}
Fix: make UserModel conform to Sendable (value types often qualify automatically), or perform cross-boundary conversion inside the Actor boundary.
② Missing @MainActor
UIKit and SwiftUI ViewController and ObservableObject subclasses implicitly ran on the main thread before migration; Swift 6 requires an explicit @MainActor annotation. Xcode Fix can batch edits, but review each site so non-UI logic is not pulled onto the main actor.
③ Concurrent access to global variables
// ❌ In Swift 6, globals must be Sendable or nonisolated
var sharedCache: NSCache<NSString, AnyObject> = .init()
// ✅ Mark nonisolated(unsafe) during migration
nonisolated(unsafe) var sharedCache: NSCache<NSString, AnyObject> = .init()
// ✅ Better: wrap in an Actor with async APIs
actor CacheStore {
private let cache = NSCache<NSString, AnyObject>()
func object(forKey key: String) -> AnyObject? { cache.object(forKey: key as NSString) }
}
03Configuring CI Gates on Remote Macs
Core idea for PR gates: run concurrency checks in a separate Job from regular tests. Failures do not pollute test reports and you can use different strategies during fixes (warn-only vs. block-merge). On NUKCLOUD, pick disk and region on the pricing page, then provision via order so migration metrics stay comparable—do not rely on temporary minute-pool bursts.
Fix windows often last weeks: log concurrency error counts, full-build wall time, and DerivedData size, and tie changes to spec upgrades (disk, region) so regressions are not mistaken for code drift.
name: Swift 6 Concurrency Gate
on:
pull_request:
branches: [main, release/*]
jobs:
concurrency-check:
runs-on: self-hosted # NUKCLOUD dedicated remote Mac runner
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: Select Xcode (pin minor version)
run: sudo xcode-select -s /Applications/Xcode_16.3.app
- name: Restore DerivedData cache
uses: actions/cache@v4
with:
path: ~/DerivedData/swift6-check
key: swift6-${{ runner.name }}-${{ hashFiles('**/*.xcodeproj/project.pbxproj', '**/Package.resolved') }}
restore-keys: swift6-${{ runner.name }}-
- name: Build with strict concurrency (complete)
run: |
xcodebuild build \
-project MyApp.xcodeproj \
-scheme MyApp \
-destination 'generic/platform=iOS Simulator' \
-derivedDataPath ~/DerivedData/swift6-check \
OTHER_SWIFT_FLAGS="-strict-concurrency=complete" \
| xcbeautify --renderer github-actions
- name: Upload build log on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: swift6-build-log-${{ github.sha }}
path: ~/DerivedData/swift6-check/Logs/Build/
-derivedDataPath ~/DerivedData/swift6-check separate from normal builds so concurrency artifacts do not poison debug caches. When multiple PRs run, add runner.name or PR number for further isolation.04Six-Step Gradual Migration Runbook
-
01
Baseline snapshot: On a dedicated remote Mac, run a full build with
-strict-concurrency=minimaland record error count and compile time as a quantified starting point. -
02
Leaf modules first: From the SwiftPM graph, pick targets with no downstream dependents and enable
completeone by one. Submit a separate PR per target for reviewable diffs. -
03
CI branch strategy: On a long-lived
feature/swift6-migrationbranch, run concurrency Jobs aswarn-only(continue-on-error: true) without blocking daily PR merges. Upgrade a module to block-merge only when it reaches zero errors. -
04
Pin Xcode version: Write the
xcode-selectversion into runbook and README so local Mac developers and CI nodes share the same minor version and avoid local-pass CI-fail noise. -
05
Error trend dashboard: Log Swift 6 error counts per CI run to structured logs or build metrics; track migration with line charts and weekly team goals to reduce X errors.
-
06
Full gate acceptance: After all modules hit zero errors, enable Require swift6-concurrency-check to pass on
mainBranch Protection and setSWIFT_VERSION = 6on all targets to switch Swift 6 language mode.
05Local Mac vs. Dedicated Remote Node
Swift 6 migration needs both fast local iteration and trustworthy full CI validation; node requirements differ:
| Dimension | Developer Local Mac | NUKCLOUD Dedicated Remote Mac (CI) |
|---|---|---|
| Xcode Version | Developer-managed, may drift | Script-pinned minor version, team-wide |
| DerivedData | Shared with daily dev; incremental cache may corrupt | Separate paths, bucketed by PR or Runner |
| CPU Availability | Contended by other apps; compile time varies | Bare-metal dedicated, predictable P95 latency |
| Result Trust | Local pass does not imply CI pass | Final arbiter at gate stage |
| Best For | Single-module quick tries and reading Xcode diagnostics | Full concurrency gate checks and daily regression |
06FAQ
-strict-concurrency=complete is a compiler flag you can enable in Swift 5 language mode to see all errors before switching. Formal Swift 6 language mode (SWIFT_VERSION = 6) also removes legacy syntax. Fix with the complete flag first, then switch language mode..unsafeFlags(["-strict-concurrency=minimal"]) in Package.swift, or set SWIFT_STRICT_CONCURRENCY = minimal in a Podfile post_install hook. Most major libraries completed migration by 2026—check Release Notes first.--concurrent-jobs N when registering Runners and bucket DerivedData by PR (-derivedDataPath ~/DerivedData/pr-${{ github.event.number }}) so Jobs compile independently. Bare-metal dedicated nodes shine here: no neighbor CPU contention and low variance across concurrent Jobs.-strict-concurrency=complete into merge gates for weeks, NUKCLOUD multi-region bare-metal Mac / cloud Mac nodes stabilize pinned Xcode, DerivedData buckets, and observable queues—especially with the console provisioning runbook six-step acceptance checklist.