Build an iOS Database Migration Regression Gate on a Cloud Mac

Build an iOS Database Migration Regression Gate on a Cloud Mac

A seemingly routine Core Data attribute change may work perfectly on a freshly installed simulator yet leave users upgrading directly from an older release stuck on the launch screen. The question CI must answer is not whether it can create an empty store, but whether an existing store can migrate along every supported path, preserve critical records, and leave enough evidence when something fails. Because a cloud Mac stays continuously available, it is well suited to running these time-consuming but deterministic checks as a separate quality gate.

Define the Supported Migration Scope First

Start by listing every database version that may still exist in the field. Do not assume users upgrade through each release in sequence. If the current model is V5 while V3 and V4 clients remain in use, test at least V3→V5 and V4→V5. Testing an empty V5 store proves only that the current model can load; it does not cover renamed legacy attributes, changes to relationship constraints, or unique-index conflicts.

Maintain a simple matrix:

Fixture Upgrade target Required checks
V3 baseline store V5 Counts of accounts, projects, and historical tasks
V4 boundary-case store V5 Empty relationships, duplicate names, and deletion flags
V4 large fixture V5 Migration completion time and peak file size
V5 empty store V5 Current model initialization

A migration gate should pass only when data semantics remain valid, not merely when the persistent container loads without throwing an error.

For each fixture, also record the application version that generated it, the model identifier, expected record counts, and a validation summary. Do not take ad hoc samples from a developer's everyday database. Those samples change with normal use and make failures difficult to reproduce.

Freeze Reproducible Legacy Database Fixtures

Prepare a dedicated build for each historical version and have it write a fixed data set. Include ordinary records, null values, long text, archived objects, and objects close to constraint boundaries. After writing the data, shut down the persistent container cleanly before copying the database.

When SQLite uses WAL, the data may be spread across three files. The safest approach is to let the application finish saving and perform a checkpoint. If that cannot be guaranteed, archive the entire file set together:

set -euo pipefail

STORE="$HOME/Library/Developer/CoreSimulator/Devices/$SIM_UDID/data/Containers/Data/Application/$APP_ID/Library/Application Support/App.sqlite"
OUT="MigrationFixtures/V4"

mkdir -p "$OUT"
cp "$STORE" "$OUT/App.sqlite"

for suffix in -wal -shm; do
  if [ -f "${STORE}${suffix}" ]; then
    cp "${STORE}${suffix}" "$OUT/App.sqlite${suffix}"
  fi
done

find "$OUT" -type f -print0 | sort -z | xargs -0 shasum -a 256 > "$OUT/SHA256SUMS"

Never open the original repository fixture directly during a test. Each test should first copy it to a temporary directory and delete that copy after migration, ensuring parallel jobs cannot overwrite one another's data. If a fixture contains real user content, tokens, or connection details, regenerate it with anonymous data rather than relying on later redaction.

Wrap Migration in a Testable Entry Point

Application startup code often couples database loading with UI initialization and network requests, making migration failures difficult to isolate. Encapsulate persistent-container creation in a component that accepts an injected URL so XCTest can load a temporary copy directly.

func makeContainer(storeURL: URL) throws -> NSPersistentContainer {
    let container = NSPersistentContainer(name: "AppModel")
    let description = NSPersistentStoreDescription(url: storeURL)
    description.shouldMigrateStoreAutomatically = true
    description.shouldInferMappingModelAutomatically = true
    description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
    container.persistentStoreDescriptions = [description]

    var loadError: Error?
    container.loadPersistentStores { _, error in
        loadError = error
    }
    if let loadError {
        throw loadError
    }
    return container
}

Lightweight migration is appropriate for adding optional attributes, introducing properties with default values, and handling explicitly declared renames. Entity splits, data merges, or value transformations require an explicit mapping or a staged migration. Do not delete old models merely to make the tests pass. Historical models are required to read legacy store metadata and derive valid migration paths.

Verify Business Invariants

After migration, verify at least four layers: the persistent store loads successfully, the model version has been updated, record counts match expectations, and critical relationships and attribute semantics remain correct. For example, the total number of tasks must not change, archived projects must remain archived, and the number of orphaned child objects must be zero.

Avoid relying solely on automatically generated object IDs for comparisons. Give test data stable business keys and use those keys to validate attributes. Normalize ordered or set-based relationships before comparing them so meaningless ordering differences do not cause intermittent failures.

Run a Separate Test Job on a Cloud Mac

Run migration tests separately from ordinary unit tests because they copy fixtures and repeatedly create persistent stores. Pin the simulator runtime and target device name, boot the device first, and then run the designated test plan:

set -euo pipefail

xcrun simctl bootstatus "$SIM_UDID" -b

xcodebuild test \
  -workspace App.xcworkspace \
  -scheme App \
  -testPlan DatabaseMigration \
  -destination "platform=iOS Simulator,id=$SIM_UDID" \
  -resultBundlePath Artifacts/MigrationTests.xcresult \
  CODE_SIGNING_ALLOWED=NO

Clean temporary directories before the job starts. Whether the job succeeds or fails, archive the xcresult, migration logs, fixture summary, and target model identifier afterward. Do not upload the complete migrated database as a default artifact. It may contain test secrets and can quickly increase storage usage. In most cases, retain an anonymized fixture copy only on failure and apply a clearly defined retention period.

When running on ZoomMini, bind the job to a fixed Xcode toolchain and confirm the currently available configurations in the console. After changing Xcode or the simulator runtime, run the migration plan by itself before restoring the full pipeline. This keeps toolchain changes and model changes from entering at the same time and making failures harder to attribute.

Common Failures and Release Checklist

“Passes locally, fails in CI” problems usually come from copying an incomplete file set, allowing a previous test to modify a fixture, omitting model resources from the test bundle, or letting multiple tests operate on the same path. Verify SHA-256 first, then confirm that each temporary directory is unique, and finally check that the build output contains every supported model version.

Before release, confirm each item:

  • Every supported starting version has a fixed fixture and an expected summary;
  • The main SQLite file and WAL state are consistent;
  • Tests migrate only temporary copies and never modify the original fixture;
  • The boundaries between lightweight migration and explicit mappings are documented;
  • Record counts, stable business keys, relationships, and default values all have assertions;
  • Failed jobs retain the xcresult and redacted migration logs;
  • Removing a model version, renaming an attribute, or changing a constraint must trigger the migration plan;
  • The complete test upgrades directly from the oldest supported version to the current version.

Database migration is not a one-off release script. It is a compatibility contract that grows as the model evolves. Once historical fixtures, business invariants, and failure evidence are fixed in place, every model change can answer the same question before merge: can existing data still reach the new version safely?

Frequently asked questions

How many historical database versions should the test suite keep?

Keep at least the current production version and the previous version that can still upgrade directly. Include every supported starting version when users may skip multiple releases.

Why is copying only the main SQLite file unsafe?

In WAL mode, committed records may still reside in the -wal file. Either perform a safe checkpoint first or preserve the sqlite, sqlite-wal, and sqlite-shm files together.

ZoomMini Cloud Mac

Choose dedicated physical nodes for builds, testing, and experiments

Two M4 configurations are available across nodes in Singapore, Tokyo, Seoul, and Hong Kong. Availability is confirmed by the result returned at checkout.

Choose a model and order