diff --git a/.claude/docs/technology-stack.md b/.claude/docs/technology-stack.md
index b5e4b9e94..d93bc3a86 100644
--- a/.claude/docs/technology-stack.md
+++ b/.claude/docs/technology-stack.md
@@ -4,6 +4,22 @@
Open `Code.xcodeproj` in Xcode 16.x. Swift packages resolve automatically on first open. Build and run the `Flipcash` scheme.
+## Commit stamp
+
+`Scripts/write_commit_xcconfig` writes `GIT_COMMIT_SHA` into the gitignored
+`Configurations/commit.local.xcconfig`; Info.plist exposes it as `FCGitCommit`, read by
+`AppMeta.commit` and parsed by `BuildCommit`. It runs from the Flipcash scheme's build
+pre-action (pre-actions run before build settings are read, so the value lands in the same
+build; a Run Script phase would be one build stale and can't reach `.git` under
+`ENABLE_USER_SCRIPT_SANDBOXING`) and from `ci_scripts/ci_pre_xcodebuild.sh`, which prefers
+`CI_COMMIT` and never marks the build dirty. Locally, `*` marks uncommitted changes to tracked
+files (`git diff --quiet HEAD`, excluding the generated `GoogleService-Info.plist`), the same rule
+Android uses. `base.xcconfig` defaults it to `unknown`. The footer format is shared with Android:
+keep `AppMeta.versionFooter` and Android's `VersionInfo.commitLabel` in step. The track after
+the hash comes from `ReleaseTrack`: `development` under `#if DEBUG`, `beta` when StoreKit's
+`AppTransaction` environment is `.sandbox` (TestFlight), none on the App Store. Don't
+change `CFBundleVersion` for this: `SessionAuthenticator.requiresUpgrade` parses it.
+
## Protos: consumed, not generated here
This repo no longer vendors `.proto` files or runs protoc. The generated Swift ships from two
diff --git a/.gitignore b/.gitignore
index 0df4412c2..cd6ffae28 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
## Private assets
Configurations/secrets.local.xcconfig
+Configurations/commit.local.xcconfig
ci_scripts/ci_post_xcodebuild.sh
.env
.env.local
diff --git a/Code.xcodeproj/xcshareddata/xcschemes/Flipcash.xcscheme b/Code.xcodeproj/xcshareddata/xcschemes/Flipcash.xcscheme
index 695064917..9fce46577 100644
--- a/Code.xcodeproj/xcshareddata/xcschemes/Flipcash.xcscheme
+++ b/Code.xcodeproj/xcshareddata/xcschemes/Flipcash.xcscheme
@@ -23,6 +23,22 @@
+
+
+
+
+
+
+
+ UIColorName
background
+ FCGitCommit
+ $(GIT_COMMIT_SHA)bugsnagapiKey
diff --git a/Flipcash/Utilities/ErrorReporting.swift b/Flipcash/Utilities/ErrorReporting.swift
index 40ff1e3eb..2774005ea 100644
--- a/Flipcash/Utilities/ErrorReporting.swift
+++ b/Flipcash/Utilities/ErrorReporting.swift
@@ -38,6 +38,9 @@ enum ErrorReporting {
static func initialize() {
let config = BugsnagConfiguration.loadConfig()
config.maxStringValueLength = 50_000
+ // The raw stamp, `*` dirty marker included: CFBundleVersion is Xcode Cloud's run
+ // counter and maps to no commit on its own.
+ config.addMetadata(AppMeta.commit, key: "commit", section: "app")
Bugsnag.start(with: config)
isEnabled = true
}
diff --git a/Flipcash/Utilities/ReleaseTrack.swift b/Flipcash/Utilities/ReleaseTrack.swift
new file mode 100644
index 000000000..14adf3612
--- /dev/null
+++ b/Flipcash/Utilities/ReleaseTrack.swift
@@ -0,0 +1,36 @@
+//
+// ReleaseTrack.swift
+// Flipcash
+//
+
+import StoreKit
+
+/// The distribution channel shown after the commit in the version footer, named as Android names it.
+nonisolated enum ReleaseTrack {
+
+ /// The track for this build: `development` for a DEBUG build, `beta` on TestFlight, and
+ /// `nil` on the App Store or when StoreKit can't say.
+ ///
+ /// TestFlight and the App Store run the same Release binary, so only StoreKit's
+ /// `AppTransaction` environment tells them apart.
+ static func current() async -> String? {
+ #if DEBUG
+ return name(isDebug: true, environment: nil)
+ #else
+ let environment = try? await AppTransaction.shared.payloadValue.environment
+ return name(isDebug: false, environment: environment)
+ #endif
+ }
+
+ /// Maps a build to its track name; `nil` means no track is shown.
+ static func name(isDebug: Bool, environment: AppStore.Environment?) -> String? {
+ if isDebug {
+ return "development"
+ }
+ // AppStore.Environment is a struct, so this can't be an exhaustive switch.
+ if environment == .sandbox {
+ return "beta"
+ }
+ return nil
+ }
+}
diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/AppMeta.swift b/FlipcashCore/Sources/FlipcashCore/Clients/AppMeta.swift
index baea44565..93cd6a54b 100644
--- a/FlipcashCore/Sources/FlipcashCore/Clients/AppMeta.swift
+++ b/FlipcashCore/Sources/FlipcashCore/Clients/AppMeta.swift
@@ -23,4 +23,29 @@ public enum AppMeta {
public static var build: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? unknown
}
+
+ /// The git commit stamp the app was built from, or ``unknown`` when the host bundle doesn't
+ /// declare one. Parse it with ``BuildCommit`` for display.
+ public static var commit: String {
+ Bundle.main.object(forInfoDictionaryKey: "FCGitCommit") as? String ?? unknown
+ }
+
+ /// The You screen's version footer, in the format Android's footer uses.
+ ///
+ /// Two lines, `Version • Build ` over the commit label followed by
+ /// ` •