diff --git a/.changeset/react-native-release-coordinates.md b/.changeset/react-native-release-coordinates.md new file mode 100644 index 0000000000..b50c87d58e --- /dev/null +++ b/.changeset/react-native-release-coordinates.md @@ -0,0 +1,5 @@ +--- +'posthog-react-native': patch +--- + +Key the release an iOS build uploads on the app's Info.plist rather than on Xcode's `MARKETING_VERSION` and `CURRENT_PROJECT_VERSION`. The SDK reports `$app_version` and `$app_build` from Info.plist, and Expo writes literal versions there while leaving the build settings at the Xcode template default of `1.0`. A build whose app reported `1.0.0` therefore created its release as `1.0`, so the release did not describe the app that shipped. Releases created by an Expo build now carry the app's real version, which means a project on the default release mode will start creating release rows under that version instead. A project whose Info.plist references the build settings, as a bare React Native app does, is unaffected. diff --git a/packages/react-native/test/posthog-xcode-parse.spec.ts b/packages/react-native/test/posthog-xcode-parse.spec.ts index 55c9678b6f..f90c79b31f 100644 --- a/packages/react-native/test/posthog-xcode-parse.spec.ts +++ b/packages/react-native/test/posthog-xcode-parse.spec.ts @@ -1,4 +1,4 @@ -import { execFileSync, execSync } from 'child_process' +import { execFileSync, execSync, spawnSync } from 'child_process' import * as fs from 'fs' import * as os from 'os' import * as path from 'path' @@ -217,3 +217,118 @@ print_command_error "posthog-cli hermes upload" "42" "$CLI_OUTPUT"` expect(output.every((line) => line.startsWith('error: '))).toBe(true) }) }) + +describe('posthog-xcode.sh posthog-cli invocation', () => { + // Runs the wrapper against a posthog-cli stub that records its arguments, so the assertions + // are on what the CLI was actually asked to do rather than on the shell source. + const runWrapper = ( + args: string[], + extraEnv: Record, + infoPlist?: Record + ): { status: number; invocations: string[]; output: string } => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'posthog-xcode-release-mode-')) + try { + const derivedDir = path.join(tempDir, 'derived') + const configurationDir = path.join(tempDir, 'configuration') + const homeDir = path.join(tempDir, 'home') + const iosDir = path.join(tempDir, 'ios') + const cliTracePath = path.join(tempDir, 'cli.log') + const cliPath = path.join(homeDir, '.posthog', 'posthog-cli') + const reactNativePath = path.join(tempDir, 'react-native-xcode.sh') + + for (const directory of [derivedDir, configurationDir, iosDir, path.dirname(cliPath)]) { + fs.mkdirSync(directory, { recursive: true }) + } + fs.writeFileSync(cliPath, '#!/bin/sh\necho "$@" >> "$CLI_TRACE_PATH"\n', { mode: 0o755 }) + fs.writeFileSync(reactNativePath, '#!/bin/sh\nexit 0\n', { mode: 0o755 }) + + const plistEnv: Record = {} + if (infoPlist) { + const entries = Object.entries(infoPlist) + .map(([key, value]) => ` ${key}\n ${value}`) + .join('\n') + fs.mkdirSync(path.join(iosDir, 'App'), { recursive: true }) + fs.writeFileSync( + path.join(iosDir, 'App', 'Info.plist'), + `\n\n\n${entries}\n\n\n` + ) + plistEnv.SRCROOT = iosDir + plistEnv.INFOPLIST_FILE = 'App/Info.plist' + } + + const result = spawnSync(SCRIPT_PATH, [...args, '/bin/sh', reactNativePath], { + cwd: iosDir, + env: { + ...process.env, + CLI_TRACE_PATH: cliTracePath, + CONFIGURATION_BUILD_DIR: configurationDir, + DERIVED_FILE_DIR: derivedDir, + // Stands in for a CI runner so the wrapper skips deriving git metadata from the + // (repo-less) temp directory. + GITHUB_SHA: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + HOME: homeDir, + NODE_BINARY: process.execPath, + ...plistEnv, + ...extraEnv, + }, + encoding: 'utf8', + }) + + const invocations = fs.existsSync(cliTracePath) + ? fs.readFileSync(cliTracePath, 'utf8').trim().split('\n').filter(Boolean) + : [] + return { status: result.status ?? -1, invocations, output: `${result.stdout}${result.stderr}` } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }) + } + } + + // The SDK reports $app_version and $app_build from Info.plist, and event release mode resolves + // an exception's release from exactly those. Expo writes literal versions there and leaves + // MARKETING_VERSION at the Xcode template default of 1.0, so a release keyed on the build + // setting never matches an event and the exception silently reports no release. + it('keys the release on Info.plist rather than the build settings', () => { + const { status, invocations } = runWrapper( + [], + { + PRODUCT_BUNDLE_IDENTIFIER: 'com.example.app', + MARKETING_VERSION: '1.0', + CURRENT_PROJECT_VERSION: '1', + }, + { CFBundleShortVersionString: '1.0.0', CFBundleVersion: '42' } + ) + + expect(status).toBe(0) + expect(invocations[1]).toContain('--release-name com.example.app') + expect(invocations[1]).toContain('--release-version 1.0.0') + expect(invocations[1]).toContain('--build 42') + }) + + it('falls back to the build settings when Info.plist only references them', () => { + const { status, invocations } = runWrapper( + [], + { + PRODUCT_BUNDLE_IDENTIFIER: 'com.example.app', + MARKETING_VERSION: '2.5.0', + CURRENT_PROJECT_VERSION: '7', + }, + { CFBundleShortVersionString: '$(MARKETING_VERSION)', CFBundleVersion: '$(CURRENT_PROJECT_VERSION)' } + ) + + expect(status).toBe(0) + expect(invocations[1]).toContain('--release-version 2.5.0') + expect(invocations[1]).toContain('--build 7') + }) + + it('falls back to the build settings when there is no Info.plist at all', () => { + const { status, invocations } = runWrapper([], { + PRODUCT_BUNDLE_IDENTIFIER: 'com.example.app', + MARKETING_VERSION: '3.1.4', + CURRENT_PROJECT_VERSION: '9', + }) + + expect(status).toBe(0) + expect(invocations[1]).toContain('--release-version 3.1.4') + expect(invocations[1]).toContain('--build 9') + }) +}) diff --git a/packages/react-native/tooling/posthog-xcode.sh b/packages/react-native/tooling/posthog-xcode.sh index d567541f2a..f1cfc5756c 100755 --- a/packages/react-native/tooling/posthog-xcode.sh +++ b/packages/react-native/tooling/posthog-xcode.sh @@ -127,16 +127,46 @@ fi # mimics how the file is defined in node_modules/react-native/scripts/react-native-xcode.sh (PACKAGER_SOURCEMAP_FILE) SOURCEMAP_PACKAGER_FILE="$CONFIGURATION_BUILD_DIR/$SOURCEMAP_NAME" -# Pass release info from Xcode build settings when available +# Read a literal value out of the target's Info.plist. Returns nothing when the key is absent or +# still holds an unexpanded build-setting reference such as $(MARKETING_VERSION), which tells the +# caller to use the build setting instead. +posthog_plist_value() { + posthog_plist_file="${SRCROOT:-}/${INFOPLIST_FILE:-}" + if [ -z "${INFOPLIST_FILE:-}" ] || [ ! -f "$posthog_plist_file" ]; then + return 0 + fi + posthog_plist_result=$(/usr/libexec/PlistBuddy -c "Print :$1" "$posthog_plist_file" 2>/dev/null) || return 0 + case "$posthog_plist_result" in + *'$('*) return 0 ;; + esac + printf '%s' "$posthog_plist_result" +} + +# The SDK reports $app_version and $app_build from the app's Info.plist. Xcode's MARKETING_VERSION +# and CURRENT_PROJECT_VERSION are only the usual source for those keys. Expo writes literal +# versions into Info.plist and leaves the build settings at their template defaults, so the two +# disagree, and a release keyed on the build setting does not describe the app that ships. Read the +# plist that ships, and fall back to the build setting when it holds a reference to one. +POSTHOG_APP_VERSION=$(posthog_plist_value CFBundleShortVersionString) +if [ -z "$POSTHOG_APP_VERSION" ]; then + POSTHOG_APP_VERSION="${MARKETING_VERSION:-}" +fi +POSTHOG_APP_BUILD=$(posthog_plist_value CFBundleVersion) +if [ -z "$POSTHOG_APP_BUILD" ]; then + POSTHOG_APP_BUILD="${CURRENT_PROJECT_VERSION:-}" +fi + +# The bundle identifier is read from the build setting alone. Info.plist normally references it +# rather than repeating it, and the SDK reports the resolved value. CLI_RELEASE_ARGS="" if [ -n "${PRODUCT_BUNDLE_IDENTIFIER}" ]; then CLI_RELEASE_ARGS="$CLI_RELEASE_ARGS --release-name $PRODUCT_BUNDLE_IDENTIFIER" fi -if [ -n "${MARKETING_VERSION}" ]; then - CLI_RELEASE_ARGS="$CLI_RELEASE_ARGS --release-version $MARKETING_VERSION" +if [ -n "$POSTHOG_APP_VERSION" ]; then + CLI_RELEASE_ARGS="$CLI_RELEASE_ARGS --release-version $POSTHOG_APP_VERSION" fi -if [ -n "${CURRENT_PROJECT_VERSION}" ]; then - CLI_RELEASE_ARGS="$CLI_RELEASE_ARGS --build $CURRENT_PROJECT_VERSION" +if [ -n "$POSTHOG_APP_BUILD" ]; then + CLI_RELEASE_ARGS="$CLI_RELEASE_ARGS --build $POSTHOG_APP_BUILD" fi # RN deletes the PACKAGER_SOURCEMAP_FILE file after execution but we need it