From aa332a3a72e3082fe8df0a2d4b9f7a7482026bbd Mon Sep 17 00:00:00 2001 From: arzafran Date: Fri, 31 Jul 2026 11:34:06 -0300 Subject: [PATCH] fix(ios): stop the companion shipping every build as 1.0 (1) The TestFlight build number never reached the bundle. xcodegen's default for CFBundleVersion is the literal "1", and because `info.path` sets INFOPLIST_FILE Xcode only substitutes $(...) references in that file, so the CURRENT_PROJECT_VERSION that build-ios-testflight.sh passes on the archive command line was silently discarded. Confirmed against the one ipa that has actually shipped (run 30574301994): it reports 1.0 (1), not the run id. That is worse than a wrong label. App Store Connect recorded build 1 under version 1.0, so the next upload would have been rejected as a duplicate bundle version. The lane worked exactly once and was then blocked. Both plists now reference the build settings, so the injected number reaches the app and the widget together, which App Store validation requires them to match on. MARKETING_VERSION moves 0.1 -> 1.0 to match the record that already exists in App Store Connect rather than pushing the version string backwards. The pre-upload verification checked entitlements but never the version, which is why this shipped. It now reads CFBundleVersion back out of the signed app and the signed appex and refuses to upload when either does not match the number the run computed. Also adds a version row to the phone, on both the pairing screen and the workspace list. The app previously displayed nothing about itself, so there was no way from the phone to tell which build was installed. The row is selectable so the build number can be copied and compared against a run. Verified by building with CURRENT_PROJECT_VERSION=424242: app and widget both report 1.0 (424242). Before this change both reported 1. --- ios/ProgramaSpike/.gitignore | 4 ++ .../ProgramaSpike/AppVersion.swift | 47 +++++++++++++++++++ .../ProgramaSpike/PairConnectView.swift | 3 ++ .../Shared/Localizable.xcstrings | 36 +++++++++++++- .../ProgramaSpike/WorkspaceListView.swift | 4 ++ .../ProgramaSpikeWidgets/Info.plist | 29 ------------ ios/ProgramaSpike/project.yml | 29 +++++++++++- scripts/build-ios-testflight.sh | 34 ++++++++++++++ 8 files changed, 154 insertions(+), 32 deletions(-) create mode 100644 ios/ProgramaSpike/ProgramaSpike/AppVersion.swift delete mode 100644 ios/ProgramaSpike/ProgramaSpikeWidgets/Info.plist diff --git a/ios/ProgramaSpike/.gitignore b/ios/ProgramaSpike/.gitignore index d5df7eab..b3086d20 100644 --- a/ios/ProgramaSpike/.gitignore +++ b/ios/ProgramaSpike/.gitignore @@ -6,3 +6,7 @@ ProgramaSpike/ProgramaSpike.entitlements # lives there rather than in INFOPLIST_KEY_* build settings -- see the comment in # project.yml for why mixing the two styles silently drops keys. ProgramaSpike/Info.plist +# The widget's plist is generated the same way and was tracked by accident. A +# committed copy of a generated file drifts from what the build actually +# produces, which is how the hardcoded CFBundleVersion went unnoticed. +ProgramaSpikeWidgets/Info.plist diff --git a/ios/ProgramaSpike/ProgramaSpike/AppVersion.swift b/ios/ProgramaSpike/ProgramaSpike/AppVersion.swift new file mode 100644 index 00000000..87a8a597 --- /dev/null +++ b/ios/ProgramaSpike/ProgramaSpike/AppVersion.swift @@ -0,0 +1,47 @@ +import SwiftUI + +/// The companion's own version and build number, read from the bundle. +/// +/// This exists because there was previously no way, from the phone, to tell +/// which build was installed. The app displayed nothing, every TestFlight build +/// carried the same version string, and the build number is a bare CI run id -- +/// so "is this the current build?" was unanswerable without a Mac and Xcode. +enum AppVersion { + /// `CFBundleShortVersionString`, e.g. "1.0". + static var marketing: String { + bundleString("CFBundleShortVersionString") + } + + /// `CFBundleVersion`. Matches the GitHub Actions run id that produced the + /// build, so it can be compared directly against the run in the repo. + static var build: String { + bundleString("CFBundleVersion") + } + + /// e.g. "1.0 (3057430199401)". + static var displayString: String { + "\(marketing) (\(build))" + } + + private static func bundleString(_ key: String) -> String { + guard let value = Bundle.main.object(forInfoDictionaryKey: key) as? String, + !value.isEmpty else { + return String(localized: "about.version.unknown", defaultValue: "unknown") + } + return value + } +} + +/// A single row showing the installed version. Selectable, because the point of +/// it is copying the build number somewhere else to compare. +struct AppVersionRow: View { + var body: some View { + LabeledContent( + String(localized: "about.version.label", defaultValue: "Version"), + value: AppVersion.displayString + ) + .font(.footnote) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } +} diff --git a/ios/ProgramaSpike/ProgramaSpike/PairConnectView.swift b/ios/ProgramaSpike/ProgramaSpike/PairConnectView.swift index cf67a67a..a48abaed 100644 --- a/ios/ProgramaSpike/ProgramaSpike/PairConnectView.swift +++ b/ios/ProgramaSpike/ProgramaSpike/PairConnectView.swift @@ -105,6 +105,9 @@ struct PairConnectView: View { .foregroundStyle(.red) .font(.footnote) } + // Shown before pairing too: "which build am I on" is most + // often asked when the phone will not connect at all. + AppVersionRow() } Section(String(localized: "notifications.title", defaultValue: "Notifications")) { diff --git a/ios/ProgramaSpike/ProgramaSpike/Shared/Localizable.xcstrings b/ios/ProgramaSpike/ProgramaSpike/Shared/Localizable.xcstrings index 59e7cf4f..da0f8e32 100644 --- a/ios/ProgramaSpike/ProgramaSpike/Shared/Localizable.xcstrings +++ b/ios/ProgramaSpike/ProgramaSpike/Shared/Localizable.xcstrings @@ -18,6 +18,40 @@ } } }, + "about.version.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Version" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バージョン" + } + } + } + }, + "about.version.unknown": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "unknown" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不明" + } + } + } + }, "agentBadge.blocked": { "extractionState": "manual", "localizations": { @@ -1244,4 +1278,4 @@ } }, "version": "1.0" -} \ No newline at end of file +} diff --git a/ios/ProgramaSpike/ProgramaSpike/WorkspaceListView.swift b/ios/ProgramaSpike/ProgramaSpike/WorkspaceListView.swift index 5c0b8b5a..2da59fd4 100644 --- a/ios/ProgramaSpike/ProgramaSpike/WorkspaceListView.swift +++ b/ios/ProgramaSpike/ProgramaSpike/WorkspaceListView.swift @@ -31,6 +31,10 @@ struct WorkspaceListView: View { } } } + + Section { + AppVersionRow() + } } .navigationTitle(String(localized: "workspaceList.title", defaultValue: "Workspaces")) .navigationDestination(for: String.self) { workspaceID in diff --git a/ios/ProgramaSpike/ProgramaSpikeWidgets/Info.plist b/ios/ProgramaSpike/ProgramaSpikeWidgets/Info.plist deleted file mode 100644 index 8b1aab53..00000000 --- a/ios/ProgramaSpike/ProgramaSpikeWidgets/Info.plist +++ /dev/null @@ -1,29 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Programa Widgets - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - XPC! - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 - NSExtension - - NSExtensionPointIdentifier - com.apple.widgetkit-extension - - - diff --git a/ios/ProgramaSpike/project.yml b/ios/ProgramaSpike/project.yml index 41889bd5..3d466ce7 100644 --- a/ios/ProgramaSpike/project.yml +++ b/ios/ProgramaSpike/project.yml @@ -24,7 +24,12 @@ targets: settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.darkroom.programa.spike - MARKETING_VERSION: "0.1" + # App Store Connect already holds a build under version 1.0, because the + # plist used to hardcode that literal and this setting never reached the + # bundle. Now that it does, dropping to 0.1 would push the version string + # backwards against a record that already exists, so this stays at 1.0 + # until the companion's version is deliberately aligned with the Mac app. + MARKETING_VERSION: "1.0" CURRENT_PROJECT_VERSION: "1" SWIFT_VERSION: "6.0" TARGETED_DEVICE_FAMILY: "1,2" @@ -62,6 +67,16 @@ targets: path: ProgramaSpike/Info.plist properties: CFBundleDisplayName: "Programa" + # Declared explicitly because xcodegen's defaults for these two are the + # literals "1.0" and "1", not references to the build settings. Since + # `info.path` sets INFOPLIST_FILE, Xcode only substitutes $(...) in the + # file -- so the CURRENT_PROJECT_VERSION that + # scripts/build-ios-testflight.sh passes on the xcodebuild command line + # never reached the bundle. Every archive shipped as 1.0 (1) regardless + # of the CI build number, which App Store Connect then rejects on the + # second upload as a duplicate bundle version. + CFBundleShortVersionString: "$(MARKETING_VERSION)" + CFBundleVersion: "$(CURRENT_PROJECT_VERSION)" UILaunchScreen: {} # Required, not optional: TARGETED_DEVICE_FAMILY is "1,2" so the app # claims iPad support, and App Store validation HARD FAILS an iPad-capable @@ -147,7 +162,12 @@ targets: settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.darkroom.programa.spike.widgets - MARKETING_VERSION: "0.1" + # App Store Connect already holds a build under version 1.0, because the + # plist used to hardcode that literal and this setting never reached the + # bundle. Now that it does, dropping to 0.1 would push the version string + # backwards against a record that already exists, so this stays at 1.0 + # until the companion's version is deliberately aligned with the Mac app. + MARKETING_VERSION: "1.0" CURRENT_PROJECT_VERSION: "1" SWIFT_VERSION: "6.0" TARGETED_DEVICE_FAMILY: "1,2" @@ -164,5 +184,10 @@ targets: path: ProgramaSpikeWidgets/Info.plist properties: CFBundleDisplayName: Programa Widgets + # Same reason as the app target. These must also track the app exactly: + # App Store validation rejects a bundle whose embedded extension carries + # a different CFBundleVersion than its host app. + CFBundleShortVersionString: "$(MARKETING_VERSION)" + CFBundleVersion: "$(CURRENT_PROJECT_VERSION)" NSExtension: NSExtensionPointIdentifier: com.apple.widgetkit-extension diff --git a/scripts/build-ios-testflight.sh b/scripts/build-ios-testflight.sh index 45cfa649..b099198a 100755 --- a/scripts/build-ios-testflight.sh +++ b/scripts/build-ios-testflight.sh @@ -255,6 +255,40 @@ verify_failed=0 check_entitlement "aps-environment" "production" || verify_failed=1 check_entitlement "get-task-allow" "false" || verify_failed=1 check_entitlement "com.apple.developer.icloud-container-environment" "Production" || verify_failed=1 +# The build number has to actually reach the bundle, and for a long time it did +# not: xcodegen's default for CFBundleVersion is the literal "1", and because +# `info.path` sets INFOPLIST_FILE, Xcode substitutes only $(...) references -- +# so the CURRENT_PROJECT_VERSION passed on the archive command line was silently +# discarded and every build shipped as 1.0 (1). App Store Connect takes that +# once and rejects everything after it as a duplicate bundle version, which +# reads like a broken upload lane rather than a versioning bug. Check the built +# artifact, never the source. +echo "Verifying bundle version:" +check_bundle_version() { + local label="$1" plist="$2" actual + # plutil writes "Could not extract value" to STDOUT, so a bare fallback would + # report the error text as the value -- same trap as the entitlement checks. + if ! actual="$(plutil -extract CFBundleVersion raw -o - "$plist" 2>/dev/null)"; then + actual="" + fi + case "$actual" in *"Could not extract value"*) actual="" ;; esac + if [[ "$actual" != "$BUILD_NUMBER" ]]; then + echo "FAIL: $label CFBundleVersion is '$actual', expected '$BUILD_NUMBER'" >&2 + return 1 + fi + echo " ok $label CFBundleVersion = $actual" +} +check_bundle_version "app" "$SIGNED_APP/Info.plist" || verify_failed=1 +# An embedded extension whose CFBundleVersion differs from its host app fails +# App Store validation, so the widget is checked against the same number. +WIDGET_PLIST="$SIGNED_APP/PlugIns/ProgramaSpikeWidgets.appex/Info.plist" +if [[ -f "$WIDGET_PLIST" ]]; then + check_bundle_version "widget" "$WIDGET_PLIST" || verify_failed=1 +else + echo "FAIL: widget appex missing from the signed bundle" >&2 + verify_failed=1 +fi + if (( verify_failed )); then echo "Refusing to upload a build that would not behave correctly in TestFlight." >&2 exit 1