Migrating Swift 6 on Dedicated Remote Macs:Strict Concurrency CI Gates and Actor Isolation Runbook

Swift 6 moves concurrency correctness from runtime crashes to compile-time errors: with -strict-concurrency=complete, data races and Actor boundary violations fail the build. This article shows how to add that flag to CI gates on NUKCLOUD dedicated remote Mac nodes and provides a six-step gradual migration checklist so PRs pass concurrency compliance before merge.

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.
Tip: If CI uses a shared per-minute hosted macOS pool, evaluate queue P95 before enabling Swift 6 strict concurrency gates—stuck in queue is harder to debug than stuck compiling. For node provisioning semantics, see the NUKCLOUD dedicated Apple Silicon node runbook.

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:

FlagMeaningRecommended Stage
-strict-concurrency=minimalChecks only basic async/await syntax compliance, not Actor boundariesLegacy warm-up (weeks 1–2)
-strict-concurrency=targetedChecks Actor boundaries for types explicitly marked Sendable, @MainActor, etc.Per-module fix period (weeks 3–6)
-strict-concurrency=completeFull checks equivalent to Swift 6 language mode; unhandled concurrent sharing errorsAdd 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

Swift — typical error example
// ❌ 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

Swift — global variable fix example
// ❌ 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.

GitHub Actions — .github/workflows/swift6-concurrency-check.yml
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/
DerivedData bucket rules: Use -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

  1. 01
    Baseline snapshot: On a dedicated remote Mac, run a full build with -strict-concurrency=minimal and record error count and compile time as a quantified starting point.
  2. 02
    Leaf modules first: From the SwiftPM graph, pick targets with no downstream dependents and enable complete one by one. Submit a separate PR per target for reviewable diffs.
  3. 03
    CI branch strategy: On a long-lived feature/swift6-migration branch, run concurrency Jobs as warn-only (continue-on-error: true) without blocking daily PR merges. Upgrade a module to block-merge only when it reaches zero errors.
  4. 04
    Pin Xcode version: Write the xcode-select version into runbook and README so local Mac developers and CI nodes share the same minor version and avoid local-pass CI-fail noise.
  5. 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.
  6. 06
    Full gate acceptance: After all modules hit zero errors, enable Require swift6-concurrency-check to pass on main Branch Protection and set SWIFT_VERSION = 6 on 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:

DimensionDeveloper Local MacNUKCLOUD Dedicated Remote Mac (CI)
Xcode VersionDeveloper-managed, may driftScript-pinned minor version, team-wide
DerivedDataShared with daily dev; incremental cache may corruptSeparate paths, bucketed by PR or Runner
CPU AvailabilityContended by other apps; compile time variesBare-metal dedicated, predictable P95 latency
Result Trust
Local pass does not imply CI passFinal arbiter at gate stage
Best ForSingle-module quick tries and reading Xcode diagnosticsFull concurrency gate checks and daily regression

06FAQ

Are Swift 6 strict concurrency checks the same as Swift 6 language mode?
Not exactly. -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.
What if third-party deps (CocoaPods / SPM) do not support Swift 6 yet?
Downgrade checks per dependency with .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.
Can NUKCLOUD nodes run concurrency checks for multiple PRs at once?
Yes. Configure --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.
Where can I see pricing and node specs?
See the pricing page, order flow, and help center; this article describes engineering practice only and is not a pricing commitment.
Minute pool, owned Mac, or dedicated remote node during migration?
Minute pools suit short trials but amplify queueing and neighbor CPU during full Swift 6 scans; owned Macs align environments but scale poorly with project cycles; desk machines are weak merge-gate arbiters. When locking -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.