From 302ec43f6845b3c1e83eb459f422d37724c3d006 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 12:24:31 -0400 Subject: [PATCH 1/3] feat(you): show the commit SHA in the version footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CFBundleVersion is Xcode Cloud's run counter, so "Build 272" maps to no commit without an App Store Connect lookup. The footer now reads "Version 2026.9.3 • Build 272 • 2acfecffeb" in every build, matching the Android change. Scripts/write_commit_xcconfig writes GIT_COMMIT_SHA to a gitignored Configurations/commit.local.xcconfig, which Info.plist exposes as FCGitCommit. It runs from the Flipcash scheme's build pre-action and from the new ci_scripts/ci_pre_xcodebuild.sh. On Xcode Cloud it takes CI_COMMIT and never adds -dirty; locally it uses git rev-parse HEAD plus -dirty when git status reports changes. base.xcconfig defaults the setting to "unknown", so a clone that never ran the script still builds. BuildCommit parses the stamp and owns the 10-character display. Tapping the footer copies the full SHA and shows "Copied" through the existing toast; the beta-unlock countdown still wins on its last taps. Bugsnag events carry the raw stamp as app.commit. --- .claude/docs/technology-stack.md | 11 ++++ .gitignore | 1 + .../xcshareddata/xcschemes/Flipcash.xcscheme | 16 ++++++ Configurations/base.xcconfig | 6 +++ .../Screens/Main/You/VersionTapUnlock.swift | 10 ++-- .../Core/Screens/Main/You/YouScreen.swift | 15 ++++-- Flipcash/Supporting Files/Info.plist | 2 + Flipcash/Utilities/ErrorReporting.swift | 3 ++ .../FlipcashCore/Clients/AppMeta.swift | 6 +++ .../FlipcashCore/Clients/BuildCommit.swift | 45 ++++++++++++++++ .../FlipcashCoreTests/AppMetaTests.swift | 5 ++ .../FlipcashCoreTests/BuildCommitTests.swift | 54 +++++++++++++++++++ FlipcashTests/VersionTapUnlockTests.swift | 21 +++++++- Scripts/write_commit_xcconfig | 52 ++++++++++++++++++ ci_scripts/ci_pre_xcodebuild.sh | 6 +++ 15 files changed, 245 insertions(+), 8 deletions(-) create mode 100644 FlipcashCore/Sources/FlipcashCore/Clients/BuildCommit.swift create mode 100644 FlipcashCore/Tests/FlipcashCoreTests/BuildCommitTests.swift create mode 100755 Scripts/write_commit_xcconfig create mode 100755 ci_scripts/ci_pre_xcodebuild.sh diff --git a/.claude/docs/technology-stack.md b/.claude/docs/technology-stack.md index b5e4b9e94..a39989460 100644 --- a/.claude/docs/technology-stack.md +++ b/.claude/docs/technology-stack.md @@ -4,6 +4,17 @@ 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. `base.xcconfig` defaults it to `unknown`. 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 @@ + + + + + + + + Void) { + func registerTap(isUnlocked: Bool, otherwise: String? = nil, toggle: () -> Void) { tapCount += 1 let remaining = Self.tapsToToggle - tapCount @@ -57,6 +59,8 @@ final class VersionTapUnlock { ? "You are now \(remaining) \(steps) away from hiding beta features" : "You are now \(remaining) \(steps) away from being a developer" ) + } else if let otherwise { + show(otherwise) } } diff --git a/Flipcash/Core/Screens/Main/You/YouScreen.swift b/Flipcash/Core/Screens/Main/You/YouScreen.swift index 807e8763b..2b9d564e9 100644 --- a/Flipcash/Core/Screens/Main/You/YouScreen.swift +++ b/Flipcash/Core/Screens/Main/You/YouScreen.swift @@ -459,12 +459,21 @@ struct YouScreen: View { } private var versionFooter: some View { - Button { - versionUnlock.registerTap(isUnlocked: betaFlags.accessGranted) { + let commit = BuildCommit(AppMeta.commit) + return Button { + // Copied on every tap, so the count toward the beta toggle still + // starts from the first one. + if let sha = commit.sha { + UIPasteboard.general.string = sha + } + versionUnlock.registerTap( + isUnlocked: betaFlags.accessGranted, + otherwise: commit.sha == nil ? nil : "Copied" + ) { betaFlags.setAccessGranted(!betaFlags.accessGranted) } } label: { - Text("Version \(AppMeta.version) • Build \(AppMeta.build)") + Text("Version \(AppMeta.version) • Build \(AppMeta.build) • \(commit.display)") .lineLimit(1) .font(.appTextHeading) .foregroundStyle(Color.textSecondary) diff --git a/Flipcash/Supporting Files/Info.plist b/Flipcash/Supporting Files/Info.plist index 1bcdc4734..1ba3c37b8 100644 --- a/Flipcash/Supporting Files/Info.plist +++ b/Flipcash/Supporting Files/Info.plist @@ -49,6 +49,8 @@ UIColorName background + FCGitCommit + $(GIT_COMMIT_SHA) bugsnag apiKey diff --git a/Flipcash/Utilities/ErrorReporting.swift b/Flipcash/Utilities/ErrorReporting.swift index 40ff1e3eb..f53b00949 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` suffix 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/FlipcashCore/Sources/FlipcashCore/Clients/AppMeta.swift b/FlipcashCore/Sources/FlipcashCore/Clients/AppMeta.swift index baea44565..f2ebca0c9 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/AppMeta.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/AppMeta.swift @@ -23,4 +23,10 @@ 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 + } } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/BuildCommit.swift b/FlipcashCore/Sources/FlipcashCore/Clients/BuildCommit.swift new file mode 100644 index 000000000..899b32c54 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Clients/BuildCommit.swift @@ -0,0 +1,45 @@ +// +// BuildCommit.swift +// FlipcashCore +// + +import Foundation + +/// The git commit a build was made from, parsed from the stamp in ``AppMeta/commit``. +/// +/// The stamp is a 40-character SHA, suffixed `-dirty` when a local build had uncommitted +/// changes. Anything else, including ``AppMeta/unknown``, parses as an unknown commit. +public struct BuildCommit: Equatable, Sendable { + + /// Characters of the SHA shown in ``display``, matched by Android's version footer. + public static let displayLength = 10 + + private static let dirtySuffix = "-dirty" + + /// The full 40-character SHA, or `nil` when the build carried no usable stamp. + public let sha: String? + + /// Whether the build was made from a working tree with uncommitted changes. + public let isDirty: Bool + + /// Parses a stamp as written by `Scripts/write_commit_xcconfig`. + public init(_ stamp: String) { + let dirty = stamp.hasSuffix(Self.dirtySuffix) + let candidate = dirty ? String(stamp.dropLast(Self.dirtySuffix.count)) : stamp + + if candidate.count == 40, candidate.allSatisfy(\.isHexDigit) { + sha = candidate.lowercased() + isDirty = dirty + } else { + sha = nil + isDirty = false + } + } + + /// The commit as the version footer shows it: the first ``displayLength`` characters of + /// the SHA, `-dirty` when it applies, or ``AppMeta/unknown``. + public var display: String { + guard let sha else { return AppMeta.unknown } + return String(sha.prefix(Self.displayLength)) + (isDirty ? Self.dirtySuffix : "") + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/AppMetaTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/AppMetaTests.swift index 2f0cd434f..029ef9461 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/AppMetaTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/AppMetaTests.swift @@ -22,6 +22,11 @@ struct AppMetaTests { #expect(!AppMeta.build.isEmpty) } + @Test("Commit reads without trapping and is never empty") + func commitDegrades() { + #expect(!AppMeta.commit.isEmpty) + } + @Test("The build stand-in stays unparseable, so requiresUpgrade allows access") func buildStandInIsNotNumeric() { #expect(UInt32(AppMeta.unknown) == nil) diff --git a/FlipcashCore/Tests/FlipcashCoreTests/BuildCommitTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/BuildCommitTests.swift new file mode 100644 index 000000000..0fb424473 --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/BuildCommitTests.swift @@ -0,0 +1,54 @@ +// +// BuildCommitTests.swift +// FlipcashCoreTests +// + +import Testing +@testable import FlipcashCore + +@Suite("BuildCommit — parsing and formatting the build's commit stamp") +struct BuildCommitTests { + + private static let sha = "2acfecffeb4d1c3b8e0f9a7d6c5b4a3928170615" + + @Test("A clean stamp keeps the full SHA and displays its first 10 characters") + func cleanStamp() { + let commit = BuildCommit(Self.sha) + #expect(commit.sha == Self.sha) + #expect(commit.isDirty == false) + #expect(commit.display == "2acfecffeb") + } + + @Test("A dirty stamp keeps the full SHA and suffixes the display") + func dirtyStamp() { + let commit = BuildCommit(Self.sha + "-dirty") + #expect(commit.sha == Self.sha) + #expect(commit.isDirty) + #expect(commit.display == "2acfecffeb-dirty") + } + + @Test("An uppercase SHA is normalized to git's lowercase") + func uppercaseStamp() { + #expect(BuildCommit(Self.sha.uppercased()).sha == Self.sha) + } + + @Test( + "Anything that isn't a 40-character SHA displays as unknown", + arguments: [ + AppMeta.unknown, + "", + "$(GIT_COMMIT_SHA)", + "-dirty", + "2acfecffeb", + "2acfecffeb-dirty", + "2acfecffeb4d1c3b8e0f9a7d6c5b4a39281706150", + "zacfecffeb4d1c3b8e0f9a7d6c5b4a3928170615", + ] + ) + func unusableStamp(stamp: String) { + let commit = BuildCommit(stamp) + #expect(commit.sha == nil) + #expect(commit.isDirty == false) + #expect(commit.display == AppMeta.unknown) + } +} diff --git a/FlipcashTests/VersionTapUnlockTests.swift b/FlipcashTests/VersionTapUnlockTests.swift index 4e8c2bfdf..0676cc6b6 100644 --- a/FlipcashTests/VersionTapUnlockTests.swift +++ b/FlipcashTests/VersionTapUnlockTests.swift @@ -13,10 +13,10 @@ struct VersionTapUnlockTests { /// Taps `count` times against a locked state, reporting how many taps /// toggled and the message left standing. - private func tap(_ unlock: VersionTapUnlock, times count: Int, isUnlocked: Bool = false) -> Int { + private func tap(_ unlock: VersionTapUnlock, times count: Int, isUnlocked: Bool = false, otherwise: String? = nil) -> Int { var toggles = 0 for _ in 0.. + +SOURCE_ROOT="$1" + +if [ ! -d "$SOURCE_ROOT" ]; then + echo "warning: No source directory provided: $SOURCE_ROOT. Skipping commit stamp." + exit 0 +fi + +OUTPUT="$SOURCE_ROOT/Configurations/commit.local.xcconfig" + +# Xcode Cloud names the commit it checked out. Its clone is never dirty, so the +# dirty check is skipped there rather than trusted to a working tree CI may +# have touched (package resolution, generated plists). +if [ -n "$CI_COMMIT" ]; then + COMMIT="$CI_COMMIT" + DIRTY="" +else + COMMIT=$(git -C "$SOURCE_ROOT" rev-parse HEAD 2>/dev/null) + if [ -n "$(git -C "$SOURCE_ROOT" status --porcelain 2>/dev/null)" ]; then + DIRTY="-dirty" + else + DIRTY="" + fi +fi + +if ! [[ "$COMMIT" =~ ^[0-9a-f]{40}$ ]]; then + echo "warning: Could not determine the git commit. The build will report 'unknown'." + rm -f "$OUTPUT" + exit 0 +fi + +CONTENT="GIT_COMMIT_SHA = $COMMIT$DIRTY" + +# Rewriting an unchanged file would still bump its timestamp and invalidate +# the Info.plist step on every build. +if [ -f "$OUTPUT" ] && [ "$(cat "$OUTPUT")" = "$CONTENT" ]; then + exit 0 +fi + +echo "$CONTENT" > "$OUTPUT" +echo "Stamped $COMMIT$DIRTY" diff --git a/ci_scripts/ci_pre_xcodebuild.sh b/ci_scripts/ci_pre_xcodebuild.sh new file mode 100755 index 000000000..4690d00d7 --- /dev/null +++ b/ci_scripts/ci_pre_xcodebuild.sh @@ -0,0 +1,6 @@ +#!/bin/sh + +# Stamp CI_COMMIT into the build. The scheme pre-action does the same, but it +# runs only for scheme-driven actions; this covers every xcodebuild Xcode Cloud +# runs, and it never fails the build. +"$CI_PRIMARY_REPOSITORY_PATH/Scripts/write_commit_xcconfig" "$CI_PRIMARY_REPOSITORY_PATH" From a79b88b02d159a9f9f2b4f8ba6a93a2ad3cdfde2 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 12:37:26 -0400 Subject: [PATCH 2/3] feat(you): match Android's two-line version footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android's footer changed in code-payments/code-android-app#1571, and the two apps share one format: Version 2026.9.3 • Build 272 2acfecffeb - The commit moves to a second line, followed by " • " when there is one. iOS has no release track yet, so its second line is the hash. Without a commit the footer is the old single line. - A dirty build is marked "*" instead of "-dirty", and only changes to tracked files count, as on Android (git diff --quiet HEAD). The generated GoogleService-Info.plist is excluded because the Generate Google phase rewrites it on every build with secrets. - Tap-to-copy is gone; the footer tap drives only the beta unlock count. --- .claude/docs/technology-stack.md | 5 +- .../Screens/Main/You/VersionTapUnlock.swift | 10 ++-- .../Core/Screens/Main/You/YouScreen.swift | 18 ++----- Flipcash/Utilities/ErrorReporting.swift | 2 +- .../FlipcashCore/Clients/AppMeta.swift | 19 ++++++++ .../FlipcashCore/Clients/BuildCommit.swift | 26 +++++----- .../FlipcashCoreTests/BuildCommitTests.swift | 47 +++++++++++++++---- FlipcashTests/VersionTapUnlockTests.swift | 21 +-------- Scripts/write_commit_xcconfig | 17 ++++--- 9 files changed, 95 insertions(+), 70 deletions(-) diff --git a/.claude/docs/technology-stack.md b/.claude/docs/technology-stack.md index a39989460..579eef5b0 100644 --- a/.claude/docs/technology-stack.md +++ b/.claude/docs/technology-stack.md @@ -12,7 +12,10 @@ Open `Code.xcodeproj` in Xcode 16.x. Swift packages resolve automatically on fir 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. `base.xcconfig` defaults it to `unknown`. Don't +`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. Don't change `CFBundleVersion` for this: `SessionAuthenticator.requiresUpgrade` parses it. ## Protos: consumed, not generated here diff --git a/Flipcash/Core/Screens/Main/You/VersionTapUnlock.swift b/Flipcash/Core/Screens/Main/You/VersionTapUnlock.swift index 9ddc737d1..8e5e210e1 100644 --- a/Flipcash/Core/Screens/Main/You/VersionTapUnlock.swift +++ b/Flipcash/Core/Screens/Main/You/VersionTapUnlock.swift @@ -9,9 +9,8 @@ import FlipcashCore /// The version footer's easter egg: ten taps toggle beta access, and the last /// few taps say so out loud. /// -/// The countdown is silent until the unlock is within reach, so a stray tap on -/// the version string never announces it; until then a tap shows only the -/// caller's `otherwise` message, if any. Owns the message it shows and +/// The counter is silent until the unlock is within reach, so a stray tap on +/// the version string never announces anything. Owns the message it shows and /// the delay that takes it away again, so the footer only has to draw it. @MainActor @Observable @@ -42,9 +41,8 @@ final class VersionTapUnlock { /// - Parameters: /// - isUnlocked: Beta access as it stands, which decides whether the taps /// are counting toward showing the beta rows or hiding them again. - /// - otherwise: Shown on a tap the countdown has nothing to say about. /// - toggle: Flips beta access. Called on the tap that completes the count. - func registerTap(isUnlocked: Bool, otherwise: String? = nil, toggle: () -> Void) { + func registerTap(isUnlocked: Bool, toggle: () -> Void) { tapCount += 1 let remaining = Self.tapsToToggle - tapCount @@ -59,8 +57,6 @@ final class VersionTapUnlock { ? "You are now \(remaining) \(steps) away from hiding beta features" : "You are now \(remaining) \(steps) away from being a developer" ) - } else if let otherwise { - show(otherwise) } } diff --git a/Flipcash/Core/Screens/Main/You/YouScreen.swift b/Flipcash/Core/Screens/Main/You/YouScreen.swift index 2b9d564e9..58bfbcb61 100644 --- a/Flipcash/Core/Screens/Main/You/YouScreen.swift +++ b/Flipcash/Core/Screens/Main/You/YouScreen.swift @@ -459,22 +459,14 @@ struct YouScreen: View { } private var versionFooter: some View { - let commit = BuildCommit(AppMeta.commit) - return Button { - // Copied on every tap, so the count toward the beta toggle still - // starts from the first one. - if let sha = commit.sha { - UIPasteboard.general.string = sha - } - versionUnlock.registerTap( - isUnlocked: betaFlags.accessGranted, - otherwise: commit.sha == nil ? nil : "Copied" - ) { + Button { + versionUnlock.registerTap(isUnlocked: betaFlags.accessGranted) { betaFlags.setAccessGranted(!betaFlags.accessGranted) } } label: { - Text("Version \(AppMeta.version) • Build \(AppMeta.build) • \(commit.display)") - .lineLimit(1) + Text(AppMeta.versionFooter(commit: BuildCommit(AppMeta.commit))) + .lineLimit(2) + .multilineTextAlignment(.center) .font(.appTextHeading) .foregroundStyle(Color.textSecondary) .padding(.vertical, 12) diff --git a/Flipcash/Utilities/ErrorReporting.swift b/Flipcash/Utilities/ErrorReporting.swift index f53b00949..2774005ea 100644 --- a/Flipcash/Utilities/ErrorReporting.swift +++ b/Flipcash/Utilities/ErrorReporting.swift @@ -38,7 +38,7 @@ enum ErrorReporting { static func initialize() { let config = BugsnagConfiguration.loadConfig() config.maxStringValueLength = 50_000 - // The raw stamp, `-dirty` suffix included: CFBundleVersion is Xcode Cloud's run + // 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) diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/AppMeta.swift b/FlipcashCore/Sources/FlipcashCore/Clients/AppMeta.swift index f2ebca0c9..93cd6a54b 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/AppMeta.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/AppMeta.swift @@ -29,4 +29,23 @@ public enum AppMeta { 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 + /// ` • ` when there is one. Without a commit it falls back to the single line + /// `Version • Build `, with the track appended the same way. + public static func versionFooter( + version: String = version, + build: String = build, + commit: BuildCommit, + track: String? = nil + ) -> String { + let trackSuffix = track.map { " • \($0)" } ?? "" + let versionLine = "Version \(version) • Build \(build)" + guard let label = commit.label else { + return versionLine + trackSuffix + } + return versionLine + "\n" + label + trackSuffix + } } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/BuildCommit.swift b/FlipcashCore/Sources/FlipcashCore/Clients/BuildCommit.swift index 899b32c54..416b3f5d8 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/BuildCommit.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/BuildCommit.swift @@ -7,25 +7,25 @@ import Foundation /// The git commit a build was made from, parsed from the stamp in ``AppMeta/commit``. /// -/// The stamp is a 40-character SHA, suffixed `-dirty` when a local build had uncommitted -/// changes. Anything else, including ``AppMeta/unknown``, parses as an unknown commit. +/// The stamp is a 40-character SHA, suffixed `*` when a local build had uncommitted changes to +/// tracked files. Anything else, including ``AppMeta/unknown``, parses as no commit. public struct BuildCommit: Equatable, Sendable { - /// Characters of the SHA shown in ``display``, matched by Android's version footer. - public static let displayLength = 10 + /// Characters of the SHA shown in ``label``, matched by Android's version footer. + public static let labelLength = 10 - private static let dirtySuffix = "-dirty" + private static let dirtyMarker = "*" /// The full 40-character SHA, or `nil` when the build carried no usable stamp. public let sha: String? - /// Whether the build was made from a working tree with uncommitted changes. + /// Whether the build was made with uncommitted changes to tracked files. public let isDirty: Bool /// Parses a stamp as written by `Scripts/write_commit_xcconfig`. public init(_ stamp: String) { - let dirty = stamp.hasSuffix(Self.dirtySuffix) - let candidate = dirty ? String(stamp.dropLast(Self.dirtySuffix.count)) : stamp + let dirty = stamp.hasSuffix(Self.dirtyMarker) + let candidate = dirty ? String(stamp.dropLast(Self.dirtyMarker.count)) : stamp if candidate.count == 40, candidate.allSatisfy(\.isHexDigit) { sha = candidate.lowercased() @@ -36,10 +36,10 @@ public struct BuildCommit: Equatable, Sendable { } } - /// The commit as the version footer shows it: the first ``displayLength`` characters of - /// the SHA, `-dirty` when it applies, or ``AppMeta/unknown``. - public var display: String { - guard let sha else { return AppMeta.unknown } - return String(sha.prefix(Self.displayLength)) + (isDirty ? Self.dirtySuffix : "") + /// The commit as the version footer shows it: the first ``labelLength`` characters of the + /// SHA with `*` appended for a dirty build, or `nil` when there is no commit. + public var label: String? { + guard let sha else { return nil } + return String(sha.prefix(Self.labelLength)) + (isDirty ? Self.dirtyMarker : "") } } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/BuildCommitTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/BuildCommitTests.swift index 0fb424473..6e69fb04b 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/BuildCommitTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/BuildCommitTests.swift @@ -6,25 +6,25 @@ import Testing @testable import FlipcashCore -@Suite("BuildCommit — parsing and formatting the build's commit stamp") +@Suite("BuildCommit — parsing the build's commit stamp and formatting the version footer") struct BuildCommitTests { private static let sha = "2acfecffeb4d1c3b8e0f9a7d6c5b4a3928170615" - @Test("A clean stamp keeps the full SHA and displays its first 10 characters") + @Test("A clean stamp keeps the full SHA and labels it with the first 10 characters") func cleanStamp() { let commit = BuildCommit(Self.sha) #expect(commit.sha == Self.sha) #expect(commit.isDirty == false) - #expect(commit.display == "2acfecffeb") + #expect(commit.label == "2acfecffeb") } - @Test("A dirty stamp keeps the full SHA and suffixes the display") + @Test("A dirty stamp keeps the full SHA and appends an asterisk to the label") func dirtyStamp() { - let commit = BuildCommit(Self.sha + "-dirty") + let commit = BuildCommit(Self.sha + "*") #expect(commit.sha == Self.sha) #expect(commit.isDirty) - #expect(commit.display == "2acfecffeb-dirty") + #expect(commit.label == "2acfecffeb*") } @Test("An uppercase SHA is normalized to git's lowercase") @@ -33,14 +33,15 @@ struct BuildCommitTests { } @Test( - "Anything that isn't a 40-character SHA displays as unknown", + "Anything that isn't a 40-character SHA has no label", arguments: [ AppMeta.unknown, "", "$(GIT_COMMIT_SHA)", - "-dirty", + "*", "2acfecffeb", - "2acfecffeb-dirty", + "2acfecffeb*", + "2acfecffeb4d1c3b8e0f9a7d6c5b4a3928170615-dirty", "2acfecffeb4d1c3b8e0f9a7d6c5b4a39281706150", "zacfecffeb4d1c3b8e0f9a7d6c5b4a3928170615", ] @@ -49,6 +50,32 @@ struct BuildCommitTests { let commit = BuildCommit(stamp) #expect(commit.sha == nil) #expect(commit.isDirty == false) - #expect(commit.display == AppMeta.unknown) + #expect(commit.label == nil) + } + + // MARK: - Footer - + + @Test("The footer puts the commit on its own line") + func footerWithCommit() { + let footer = AppMeta.versionFooter(version: "2026.9.3", build: "272", commit: BuildCommit(Self.sha)) + #expect(footer == "Version 2026.9.3 • Build 272\n2acfecffeb") + } + + @Test("A track follows the commit on the second line") + func footerWithCommitAndTrack() { + let footer = AppMeta.versionFooter(version: "2026.9.3", build: "272", commit: BuildCommit(Self.sha + "*"), track: "beta") + #expect(footer == "Version 2026.9.3 • Build 272\n2acfecffeb* • beta") + } + + @Test("Without a commit the footer is the old single line") + func footerWithoutCommit() { + let footer = AppMeta.versionFooter(version: "2026.9.3", build: "272", commit: BuildCommit(AppMeta.unknown)) + #expect(footer == "Version 2026.9.3 • Build 272") + } + + @Test("Without a commit a track stays on the single line") + func footerWithoutCommitWithTrack() { + let footer = AppMeta.versionFooter(version: "2026.9.3", build: "272", commit: BuildCommit(AppMeta.unknown), track: "beta") + #expect(footer == "Version 2026.9.3 • Build 272 • beta") } } diff --git a/FlipcashTests/VersionTapUnlockTests.swift b/FlipcashTests/VersionTapUnlockTests.swift index 0676cc6b6..4e8c2bfdf 100644 --- a/FlipcashTests/VersionTapUnlockTests.swift +++ b/FlipcashTests/VersionTapUnlockTests.swift @@ -13,10 +13,10 @@ struct VersionTapUnlockTests { /// Taps `count` times against a locked state, reporting how many taps /// toggled and the message left standing. - private func tap(_ unlock: VersionTapUnlock, times count: Int, isUnlocked: Bool = false, otherwise: String? = nil) -> Int { + private func tap(_ unlock: VersionTapUnlock, times count: Int, isUnlocked: Bool = false) -> Int { var toggles = 0 for _ in 0.. @@ -27,10 +28,14 @@ if [ -n "$CI_COMMIT" ]; then DIRTY="" else COMMIT=$(git -C "$SOURCE_ROOT" rev-parse HEAD 2>/dev/null) - if [ -n "$(git -C "$SOURCE_ROOT" status --porcelain 2>/dev/null)" ]; then - DIRTY="-dirty" - else + # Tracked changes only, matching Android: untracked files never mark a build + # dirty. GoogleService-Info.plist is tracked but rewritten from secrets by the + # "Generate Google" phase on every build, so it doesn't count either. + if git -C "$SOURCE_ROOT" diff --quiet HEAD -- . \ + ':(exclude)Flipcash/Supporting Files/GoogleService-Info.plist' 2>/dev/null; then DIRTY="" + else + DIRTY="*" fi fi From cf8a857ec2518d4f8ac4c91a4d29aef5589ea2a8 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 25 Sep 2026 12:43:22 -0400 Subject: [PATCH 3/3] feat(you): show the release track after the commit in the version footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Line 2 of the footer now ends with ` • development` in a DEBUG build and ` • beta` on TestFlight, matching Android. App Store builds show no track. TestFlight and the App Store run the same Release binary, so the track comes from StoreKit's AppTransaction environment (`.sandbox` on TestFlight), read once when the You screen appears. If StoreKit can't answer, no track is shown. --- .claude/docs/technology-stack.md | 4 ++- .../Core/Screens/Main/You/YouScreen.swift | 4 ++- Flipcash/Utilities/ReleaseTrack.swift | 36 +++++++++++++++++++ FlipcashTests/ReleaseTrackTests.swift | 27 ++++++++++++++ 4 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 Flipcash/Utilities/ReleaseTrack.swift create mode 100644 FlipcashTests/ReleaseTrackTests.swift diff --git a/.claude/docs/technology-stack.md b/.claude/docs/technology-stack.md index 579eef5b0..d93bc3a86 100644 --- a/.claude/docs/technology-stack.md +++ b/.claude/docs/technology-stack.md @@ -15,7 +15,9 @@ build; a Run Script phase would be one build stale and can't reach `.git` under `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. Don't +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 diff --git a/Flipcash/Core/Screens/Main/You/YouScreen.swift b/Flipcash/Core/Screens/Main/You/YouScreen.swift index 58bfbcb61..2bc3ed74d 100644 --- a/Flipcash/Core/Screens/Main/You/YouScreen.swift +++ b/Flipcash/Core/Screens/Main/You/YouScreen.swift @@ -42,6 +42,7 @@ struct YouScreen: View { /// The version footer's beta-access easter egg — the tap count and the line /// it shows for the last few taps. @State private var versionUnlock = VersionTapUnlock() + @State private var releaseTrack: String? @State private var isShowingDownloadOptions = false /// The format tapped in the download sheet, held until the sheet is gone so @@ -464,7 +465,7 @@ struct YouScreen: View { betaFlags.setAccessGranted(!betaFlags.accessGranted) } } label: { - Text(AppMeta.versionFooter(commit: BuildCommit(AppMeta.commit))) + Text(AppMeta.versionFooter(commit: BuildCommit(AppMeta.commit), track: releaseTrack)) .lineLimit(2) .multilineTextAlignment(.center) .font(.appTextHeading) @@ -493,6 +494,7 @@ struct YouScreen: View { } } .animation(.springFaster, value: versionUnlock.message) + .task { releaseTrack = await ReleaseTrack.current() } } // MARK: - Content - 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/FlipcashTests/ReleaseTrackTests.swift b/FlipcashTests/ReleaseTrackTests.swift new file mode 100644 index 000000000..e61ec12b2 --- /dev/null +++ b/FlipcashTests/ReleaseTrackTests.swift @@ -0,0 +1,27 @@ +// +// ReleaseTrackTests.swift +// FlipcashTests +// + +import StoreKit +import Testing +@testable import Flipcash + +@Suite("Release track shown in the version footer") +struct ReleaseTrackTests { + + @Test("A debug build is development, whatever StoreKit says", arguments: [nil, .sandbox, .production, .xcode] as [AppStore.Environment?]) + func debugIsDevelopment(environment: AppStore.Environment?) { + #expect(ReleaseTrack.name(isDebug: true, environment: environment) == "development") + } + + @Test("TestFlight is beta") + func testFlightIsBeta() { + #expect(ReleaseTrack.name(isDebug: false, environment: .sandbox) == "beta") + } + + @Test("The App Store, an Xcode install, and an unknown environment show no track", arguments: [nil, .production, .xcode] as [AppStore.Environment?]) + func otherwiseNoTrack(environment: AppStore.Environment?) { + #expect(ReleaseTrack.name(isDebug: false, environment: environment) == nil) + } +}