Migrating from appStoreReceiptURL to AppTransaction

How to detect TestFlight builds now that appStoreReceiptURL is deprecated.

For years, the standard way to detect TestFlight was checking the receipt file name:

if Bundle.main.appStoreReceiptURL?.lastPathComponent == "sandboxReceipt" {
    // TestFlight
}

You probably have a check like this somewhere to change app behavior somewhere — pointing TestFlight builds at a staging server, or tagging analytics by environment. That’s what Paku was doing.

appStoreReceiptURL is now deprecated, and when I went looking for what to replace this specific check with, I couldn’t find much. The answer is StoreKit’s AppTransaction, which reports the environment that signed your app as an AppStore.Environment: .production for the App Store, .sandbox for TestFlight, and .xcode for builds run from Xcode.

The catch is that AppTransaction.shared is async, so you don’t want to fetch it on every check. Storing it in a Task handles that — the closure runs once, and every await .value after the first returns the cached result immediately:

private static let environmentTask = Task<AppStore.Environment, Never> {
    do {
        return switch try await AppTransaction.shared {
        case .verified(let transaction), .unverified(let transaction, _):
            transaction.environment
        }
    } catch {
        return .production // If StoreKit can't answer, assume App Store
    }
}

static var environment: AppStore.Environment {
    get async { await environmentTask.value }
}

A few things doing quiet work here: a static let is initialized lazily and exactly once, and creating a Task starts it running — so the lookup kicks off on first access and never repeats. Concurrent callers awaiting .value all suspend on the same task and get the same answer. And the Never failure type means call sites don’t need try.

The environment being async turned out to matter less than I expected. In Paku, the main thing it feeds is the API base URL — and anything consuming a URL is about to make a network call, so it’s already in an async context. One more await there is free.