diff --git a/.changeset/react-native-event-release-mode.md b/.changeset/react-native-event-release-mode.md new file mode 100644 index 0000000000..4952746d0c --- /dev/null +++ b/.changeset/react-native-event-release-mode.md @@ -0,0 +1,7 @@ +--- +'posthog-react-native': minor +--- + +Add experimental event release mode to React Native builds. Set `releaseMode: 'event'` on the `posthog-react-native/expo` config plugin (or export `POSTHOG_RELEASE_MODE=event`, or set `posthog.releaseMode=event` in `android/gradle.properties`) and the build uploads its Hermes source maps, iOS dSYMs and Android R8 mappings without binding them to a release. Each exception then resolves its own release from the `$app_namespace` / `$app_version` / `$app_build` the SDK already sends, instead of inheriting the release of the symbols its frames resolved against. Use it when two releases can ship identical JavaScript or identical native code: symbol ids are derived from content, so the default `symbol-set` mode makes both releases report whichever one uploaded first. An unrecognized mode fails the build rather than falling back. The Hermes upload needs posthog-cli 0.16.0 or newer, which carries `--release-mode` on its `hermes` commands; an older one fails the build and names the upgrade. + +The Android mapping upload needs the `com.posthog.android` gradle plugin 1.5.0 or newer, which reads `posthog.releaseMode`. A fresh prebuild now injects 1.5.1. A project whose `android/build.gradle` already has the classpath line keeps its version, so bump that line to 1.5.0 or newer by hand, or prebuild with `--clean`. On 1.4.0 the mapping stays bound to a release while the Hermes maps do not. diff --git a/packages/react-native/src/tooling/expoconfig.ts b/packages/react-native/src/tooling/expoconfig.ts index a445e41012..5a23133598 100644 --- a/packages/react-native/src/tooling/expoconfig.ts +++ b/packages/react-native/src/tooling/expoconfig.ts @@ -6,14 +6,45 @@ const { withAppBuildGradle, withBaseMod, withGradleProperties, withProjectBuildG require('@expo/config-plugins') // com.posthog.android uploads R8 mapping files and injects a matching map-id so native -// crash stack traces can be deobfuscated. -const POSTHOG_ANDROID_GRADLE_PLUGIN_VERSION = '1.4.0' +// crash stack traces can be deobfuscated. The injected version has to read every gradle +// property the plugin writes, or that half of the build ignores the option: 1.4.0 is the first +// version that reads posthog.dotenvFile, and 1.5.0 the first that reads posthog.releaseMode. +const POSTHOG_ANDROID_GRADLE_PLUGIN_VERSION = '1.5.1' const resolvePostHogReactNativePackageJsonPath = "[\"node\", \"--print\", \"require('path').join(require('path').dirname(require.resolve('posthog-react-native')), '..', 'tooling', 'posthog.gradle')\"].execute().text.trim()" const POSTHOG_ANDROID_SKIP_ON_CONFLICT_PROPERTY = 'posthogReactNativeSkipOnConflict' +/** + * How the release a build belongs to gets associated with the exceptions it reports. + * `symbol-set` stamps the release onto the uploaded source maps, dSYMs and mappings, and an + * exception inherits the release of the symbols its frames resolved against. `event` uploads them + * release-independent and lets each event resolve its own release from the app version and + * namespace the SDK already sends. + */ +export type PostHogReleaseMode = 'symbol-set' | 'event' + +// Exported so a test can hold the copies in posthog-xcode.sh, posthog.gradle and the generated +// dSYM phase to this list, because nothing else fails when they drift. +export const POSTHOG_RELEASE_MODES: PostHogReleaseMode[] = ['symbol-set', 'event'] + +// Empty/whitespace-only values (easy to produce from templated app.config values) count as unset. +// An unrecognized value stops the prebuild rather than falling back, so a typo cannot silently +// leave a build binding its symbols to a release it meant to keep independent. +export function resolveReleaseModeProp(releaseMode?: string): PostHogReleaseMode | undefined { + const trimmed = releaseMode?.trim() + if (!trimmed) { + return undefined + } + if (!POSTHOG_RELEASE_MODES.includes(trimmed as PostHogReleaseMode)) { + throw new Error( + `[posthog-react-native] releaseMode must be one of ${POSTHOG_RELEASE_MODES.join(', ')}, was '${trimmed}'` + ) + } + return trimmed as PostHogReleaseMode +} + export function buildAndroidSkipOnConflictGradleLine(skipOnConflict: boolean): string | null { if (!skipOnConflict) { return null @@ -168,7 +199,11 @@ const withAndroidNativeSymbolsPlugin = (config: any) => { type BuildPhase = { shellScript: string } -export function modifyExistingXcodeBuildScript(script: BuildPhase | undefined, skipOnConflict = false): void { +export function modifyExistingXcodeBuildScript( + script: BuildPhase | undefined, + skipOnConflict = false, + releaseMode?: PostHogReleaseMode +): void { if (!script?.shellScript) { console.warn( "[posthog-react-native] Could not find the 'Bundle React Native code and images' build phase; " + @@ -183,7 +218,7 @@ export function modifyExistingXcodeBuildScript(script: BuildPhase | undefined, s if (script.shellScript.includes('posthog-xcode.sh')) { const code = migrateLegacyPostHogWrapperInvocation(JSON.parse(script.shellScript)) - script.shellScript = JSON.stringify(updatePostHogSkipOnConflict(code, skipOnConflict)) + script.shellScript = JSON.stringify(updatePostHogBundlePhaseExports(code, skipOnConflict, releaseMode)) return } @@ -192,7 +227,9 @@ export function modifyExistingXcodeBuildScript(script: BuildPhase | undefined, s } const code = JSON.parse(script.shellScript) - script.shellScript = JSON.stringify(addPostHogWithBundledScriptsToBundleShellScript(code, skipOnConflict)) + script.shellScript = JSON.stringify( + addPostHogWithBundledScriptsToBundleShellScript(code, skipOnConflict, releaseMode) + ) } // Invoked directly so another wrapper receives this script—not /bin/sh—as $1. @@ -201,6 +238,21 @@ const POSTHOG_REACT_NATIVE_XCODE_PATH = "`\"$NODE_BINARY\" --print \"require('path').join(require('path').dirname(require.resolve('posthog-react-native')), '..', 'tooling', 'posthog-xcode.sh')\"`" const POSTHOG_SKIP_ON_CONFLICT_EXPORT = 'export POSTHOG_SKIP_ON_CONFLICT=1' +const POSTHOG_RELEASE_MODE_EXPORT_PREFIX = 'export POSTHOG_RELEASE_MODE=' + +// Exported before the wrapped command so posthog-xcode.sh — and any outer wrapper that re-invokes +// it — sees them. posthog-cli reads POSTHOG_RELEASE_MODE itself, so one export covers the hermes +// clone and upload alike. +function buildBundlePhaseExports(skipOnConflict: boolean, releaseMode?: PostHogReleaseMode): string[] { + const exports: string[] = [] + if (skipOnConflict) { + exports.push(POSTHOG_SKIP_ON_CONFLICT_EXPORT) + } + if (releaseMode) { + exports.push(`${POSTHOG_RELEASE_MODE_EXPORT_PREFIX}${releaseMode}`) + } + return exports +} const REACT_NATIVE_XCODE_LINE = /^([ \t]*)(?![A-Za-z_][A-Za-z0-9_]*=)([^\n]*(?:packager|scripts)\/react-native-xcode\.sh\b[^\n]*)$/m @@ -209,31 +261,45 @@ function migrateLegacyPostHogWrapperInvocation(script: string): string { return script.replace(`/bin/sh ${POSTHOG_REACT_NATIVE_XCODE_PATH}`, POSTHOG_REACT_NATIVE_XCODE_PATH) } -function updatePostHogSkipOnConflict(script: string, skipOnConflict: boolean): string { +// Rewrites the managed exports on an already-wrapped bundle phase, so changing an option in +// app.json takes effect without a clean prebuild. +function updatePostHogBundlePhaseExports( + script: string, + skipOnConflict: boolean, + releaseMode?: PostHogReleaseMode +): string { const skipArg = '--posthog-skip-on-conflict --' const lines = script .replace(new RegExp(`\\s*${skipArg}\\s*`, 'g'), ' ') .split('\n') .filter((line) => line.trim() !== POSTHOG_SKIP_ON_CONFLICT_EXPORT) + .filter((line) => !line.trim().startsWith(POSTHOG_RELEASE_MODE_EXPORT_PREFIX)) - if (skipOnConflict) { + const exports = buildBundlePhaseExports(skipOnConflict, releaseMode) + if (exports.length > 0) { const commandIndex = lines.findIndex((line) => line.includes(POSTHOG_REACT_NATIVE_XCODE_PATH)) if (commandIndex !== -1) { const indent = lines[commandIndex].match(/^[ \t]*/)?.[0] ?? '' - lines.splice(commandIndex, 0, `${indent}${POSTHOG_SKIP_ON_CONFLICT_EXPORT}`) + lines.splice(commandIndex, 0, ...exports.map((line) => `${indent}${line}`)) } } return lines.join('\n') } -export function addPostHogWithBundledScriptsToBundleShellScript(script: string, skipOnConflict = false): string { +export function addPostHogWithBundledScriptsToBundleShellScript( + script: string, + skipOnConflict = false, + releaseMode?: PostHogReleaseMode +): string { // Capture the full RN script invocation. Expo uses a backtick-wrapped // node --print command, so matching only up to react-native-xcode.sh cuts the // command substitution in half and leaves the generated shell invalid. return script.replace(REACT_NATIVE_XCODE_LINE, (_match: string, indent: string, rnCommand: string) => { - const skipOnConflictExport = skipOnConflict ? `${indent}${POSTHOG_SKIP_ON_CONFLICT_EXPORT}\n` : '' - return `${skipOnConflictExport}${indent}${POSTHOG_REACT_NATIVE_XCODE_PATH} ${rnCommand}` + const exports = buildBundlePhaseExports(skipOnConflict, releaseMode) + .map((line) => `${indent}${line}\n`) + .join('') + return `${exports}${indent}${POSTHOG_REACT_NATIVE_XCODE_PATH} ${rnCommand}` }) } @@ -244,7 +310,29 @@ const POSTHOG_DSYM_INPUT_PATH = // Shell script for the dSYM upload build phase. It locates and runs posthog-ios's // upload-symbols.sh (CocoaPods or SwiftPM) rather than re-implementing dSYM upload. // `includeSource` (iOS only) opts into POSTHOG_INCLUDE_SOURCE to also upload native source. -export function buildDsymUploadShellScript(includeSource = false, skipOnConflict = false): string { +export function buildDsymUploadShellScript( + includeSource = false, + skipOnConflict = false, + releaseMode?: PostHogReleaseMode +): string { + return composeDsymUploadShellScript(includeSource, skipOnConflict, buildDsymReleaseModeLines(releaseMode)) +} + +// The phase as SDKs without release-mode support wrote it: the same script with no release-mode +// block. isPluginGeneratedDsymUploadBuildPhase recognizes only text the plugin wrote, so this text +// has to stay among its variants. Without it a project prebuilt by such an SDK keeps its old phase, +// and event mode unbinds its Hermes maps but not its dSYMs. That makes the shared lines in +// composeDsymUploadShellScript a compatibility contract: a change to them needs the old text kept +// as a variant too. +function buildLegacyDsymUploadShellScript(includeSource: boolean, skipOnConflict: boolean): string { + return composeDsymUploadShellScript(includeSource, skipOnConflict, []) +} + +function composeDsymUploadShellScript( + includeSource: boolean, + skipOnConflict: boolean, + releaseModeLines: string[] +): string { const lines = [ '# Upload iOS dSYMs to PostHog so native crashes can be symbolicated.', '# upload-symbols.sh ships inside the posthog-ios dependency.', @@ -264,6 +352,8 @@ export function buildDsymUploadShellScript(includeSource = false, skipOnConflict ) } + lines.push(...releaseModeLines) + lines.push( 'PODS_SCRIPT="${PODS_ROOT}/PostHog/build-tools/upload-symbols.sh"', 'SPM_SCRIPT="${BUILD_DIR%/Build/*}/SourcePackages/checkouts/posthog-ios/build-tools/upload-symbols.sh"', @@ -279,6 +369,32 @@ export function buildDsymUploadShellScript(includeSource = false, skipOnConflict return lines.join('\n') } +// Resolved when the phase runs, not when it is generated. The bundle phase reads +// POSTHOG_RELEASE_MODE out of the environment, so a build configured that way rather than +// through the plugin prop would otherwise upload its maps release-independent and keep binding +// its dSYMs. posthog-ios reads only POSTHOG_NO_RELEASE_BIND, and posthog-cli's `dsym upload` +// binds no environment variable of its own, so the translation has to happen here. +function buildDsymReleaseModeLines(releaseMode?: PostHogReleaseMode): string[] { + return [ + releaseMode + ? `POSTHOG_RESOLVED_RELEASE_MODE="${releaseMode}"` + : 'POSTHOG_RESOLVED_RELEASE_MODE="${POSTHOG_RELEASE_MODE:-}"', + 'case "$POSTHOG_RESOLVED_RELEASE_MODE" in', + ' ""|symbol-set) ;;', + ' event)', + ' # Upload dSYMs without binding them to a release, so each crash resolves its own from the', + ' # app version and namespace the SDK sends. posthog-ios versions whose upload-symbols.sh', + ' # does not read this variable ignore it and keep binding the dSYMs.', + ' export POSTHOG_NO_RELEASE_BIND=1', + ' ;;', + ' *)', + " echo \"error: posthog release mode must be 'symbol-set' or 'event', was '$POSTHOG_RESOLVED_RELEASE_MODE'\"", + ' exit 1', + ' ;;', + 'esac', + ] +} + // xcode's addBuildPhase stores shellScript quote-escaped with literal newlines; in-place // refreshes must match or the stored pbxproj representation churns. function encodePbxShellScript(script: string): string { @@ -299,8 +415,13 @@ function isPluginGeneratedDsymUploadBuildPhase(phase: any): boolean { if (typeof phase?.shellScript !== 'string') { return false } + const stored = decodePbxShellScript(phase.shellScript) return [false, true].some((source) => - [false, true].some((skip) => decodePbxShellScript(phase.shellScript) === buildDsymUploadShellScript(source, skip)) + [false, true].some( + (skip) => + stored === buildLegacyDsymUploadShellScript(source, skip) || + [undefined, ...POSTHOG_RELEASE_MODES].some((mode) => stored === buildDsymUploadShellScript(source, skip, mode)) + ) ) } @@ -322,12 +443,20 @@ export function moveDsymUploadBuildPhaseToEnd(xcodeProject: any): void { // Keeps the upload phase last and declares the main DWARF as an input, matching the native iOS // setup guide. Both are required: the input makes Xcode wait for dSYM generation, while placing // the phase after extension embedding avoids dependency cycles in apps with app extensions. -// Re-runs refresh only a still-plugin-generated phase so user customizations remain untouched. -export function addDsymUploadBuildPhase(xcodeProject: any, includeSource = false, skipOnConflict = false): void { +// Re-runs refresh only a still-plugin-generated phase, also one an older SDK wrote, so user +// customizations remain untouched. +export function addDsymUploadBuildPhase( + xcodeProject: any, + includeSource = false, + skipOnConflict = false, + releaseMode?: PostHogReleaseMode +): void { const existing = xcodeProject.pbxItemByComment(POSTHOG_DSYM_BUILD_PHASE_NAME, 'PBXShellScriptBuildPhase') if (existing) { if (isPluginGeneratedDsymUploadBuildPhase(existing)) { - existing.shellScript = encodePbxShellScript(buildDsymUploadShellScript(includeSource, skipOnConflict)) + existing.shellScript = encodePbxShellScript( + buildDsymUploadShellScript(includeSource, skipOnConflict, releaseMode) + ) existing.inputPaths = Array.from( new Set([...(Array.isArray(existing.inputPaths) ? existing.inputPaths : []), POSTHOG_DSYM_INPUT_PATH]) ) @@ -336,7 +465,7 @@ export function addDsymUploadBuildPhase(xcodeProject: any, includeSource = false xcodeProject.addBuildPhase([], 'PBXShellScriptBuildPhase', POSTHOG_DSYM_BUILD_PHASE_NAME, null, { inputPaths: [POSTHOG_DSYM_INPUT_PATH], shellPath: '/bin/sh', - shellScript: buildDsymUploadShellScript(includeSource, skipOnConflict), + shellScript: buildDsymUploadShellScript(includeSource, skipOnConflict, releaseMode), }) } @@ -358,6 +487,7 @@ export function disableUserScriptSandboxing(xcodeProject: any): void { const POSTHOG_DOTENV_BUILD_SETTING = 'POSTHOG_CLI_DOTENV_FILE' const POSTHOG_DOTENV_GRADLE_PROPERTY = 'posthog.dotenvFile' +const POSTHOG_RELEASE_MODE_GRADLE_PROPERTY = 'posthog.releaseMode' // Strips a leading ./ so relative props join cleanly onto their per-platform prefix. function normalizeDotenvFileProp(dotenvFile: string): string { @@ -431,9 +561,28 @@ export function updateDotenvFileGradleProperties( return rest } -const withPostHogGradleProperties = (config: any, dotenvFile?: string) => { +// Managed posthog.releaseMode entry in android/gradle.properties. Both Android upload hooks read +// it — the SDK's posthog.gradle hermes upload and the com.posthog.android R8 mapping upload — so +// one entry keeps the JavaScript and native halves of a build on the same mode. Added when the +// prop is set, removed when it isn't. +export function updateReleaseModeGradleProperties( + properties: GradlePropertiesItem[], + releaseMode?: PostHogReleaseMode +): GradlePropertiesItem[] { + const rest = properties.filter( + (item) => !(item.type === 'property' && item.key === POSTHOG_RELEASE_MODE_GRADLE_PROPERTY) + ) + if (!releaseMode) { + return rest + } + rest.push({ type: 'property', key: POSTHOG_RELEASE_MODE_GRADLE_PROPERTY, value: releaseMode }) + return rest +} + +const withPostHogGradleProperties = (config: any, dotenvFile?: string, releaseMode?: PostHogReleaseMode) => { return withGradleProperties(config, (config: any) => { config.modResults = updateDotenvFileGradleProperties(config.modResults, dotenvFile) + config.modResults = updateReleaseModeGradleProperties(config.modResults, releaseMode) return config }) } @@ -496,10 +645,9 @@ type PostHogPluginProps = { * The path reaches every upload hook as POSTHOG_CLI_DOTENV_FILE: on iOS as a * build setting (Xcode exports it to the bundle and dSYM script phases), on * Android as a `posthog.dotenvFile` entry in android/gradle.properties read - * by the SDK's `posthog.gradle` hermes upload and, on gradle plugin >= 1.4.0 - * (the version this plugin injects), by the `com.posthog.android` mapping - * upload. Process env always wins inside the CLI; a missing file is a - * warning, not a build failure. + * by the SDK's `posthog.gradle` hermes upload and, on gradle plugin >= 1.4.0, + * by the `com.posthog.android` mapping upload. Process env always wins inside + * the CLI; a missing file is a warning, not a build failure. * * Requires posthog-cli >= 0.8.4 — older CLIs ignore the variable and fall * back to their other credential sources. With `disableSandboxing: false`, @@ -507,6 +655,35 @@ type PostHogPluginProps = { * error (an unreadable-but-present file does not fall through). */ dotenvFile?: string + + /** + * How the release a build belongs to gets associated with the exceptions it reports. + * + * `symbol-set` (the default) stamps the release onto everything the build uploads — Hermes + * source maps, iOS dSYMs, Android R8 mappings — and an exception inherits the release of the + * symbols its frames resolved against. + * + * EXPERIMENTAL `event` uploads them release-independent, and each event resolves its own + * release from the `$app_namespace` / `$app_version` / `$app_build` the SDK already sends. + * Nothing is injected into the app in exchange. Use it when two releases can ship identical + * JavaScript or identical native code: symbol ids are derived from content, so in `symbol-set` + * mode both releases report whichever one uploaded first. + * + * Reaches every upload hook: iOS as `POSTHOG_RELEASE_MODE` in the bundle build phase (plus + * `POSTHOG_NO_RELEASE_BIND` in the dSYM phase when `uploadNativeSymbols` is on), Android as a + * `posthog.releaseMode` entry in android/gradle.properties. + * + * Requires posthog-cli >= 0.13.0 and the `com.posthog.android` gradle plugin >= 1.5.0 for the + * Android mapping upload. Plugin 1.4.0 ignores `posthog.releaseMode` and keeps binding the + * mapping. A fresh prebuild injects a version that reads it, but a project whose + * android/build.gradle already carries the classpath line keeps its version: bump that line by + * hand or prebuild with `--clean`. The dSYM half needs posthog-cli >= 0.10.0 and + * posthog-ios >= 3.69.1; older ones ignore `POSTHOG_NO_RELEASE_BIND` and keep binding. + * The Hermes source map upload needs posthog-cli >= 0.16.0, which carries `--release-mode` on + * the `hermes` commands; the build fails on an older one and names the upgrade. That floor + * lives in posthog-xcode.sh and posthog.gradle: update them and this line together. + */ + releaseMode?: PostHogReleaseMode } // Normalizes the uploadNativeSymbols prop (boolean | { includeSource }) into a @@ -550,10 +727,15 @@ const withIosPlugin = (config: any, props: PostHogPluginProps = {}) => { 'PBXShellScriptBuildPhase' ) - modifyExistingXcodeBuildScript(bundleReactNativePhase, props.skipOnConflict === true) + modifyExistingXcodeBuildScript(bundleReactNativePhase, props.skipOnConflict === true, props.releaseMode) if (nativeSymbols.enabled) { - addDsymUploadBuildPhase(xcodeProject, nativeSymbols.includeSource, props.skipOnConflict === true) + addDsymUploadBuildPhase( + xcodeProject, + nativeSymbols.includeSource, + props.skipOnConflict === true, + props.releaseMode + ) } applyDotenvFileBuildSetting(xcodeProject, props.dotenvFile) @@ -583,7 +765,11 @@ const withIosPlugin = (config: any, props: PostHogPluginProps = {}) => { } const withPostHogPlugin = (config: any, rawProps: PostHogPluginProps = {}) => { - const props = { ...rawProps, dotenvFile: resolveDotenvFileProp(rawProps.dotenvFile) } + const props = { + ...rawProps, + dotenvFile: resolveDotenvFileProp(rawProps.dotenvFile), + releaseMode: resolveReleaseModeProp(rawProps.releaseMode), + } // Must register first: it inserts the projectBuildGradle mod key ahead of appBuildGradle, // and expo evaluates mods in key-insertion order. Registering withAndroidPlugin first would // make appBuildGradle run before projectBuildGradle, so `classpathPresent` would still be @@ -594,7 +780,7 @@ const withPostHogPlugin = (config: any, rawProps: PostHogPluginProps = {}) => { } config = withAndroidPlugin(config, props.skipOnConflict === true) // Runs unconditionally so removing the prop also removes the managed entry. - config = withPostHogGradleProperties(config, props.dotenvFile) + config = withPostHogGradleProperties(config, props.dotenvFile, props.releaseMode) return withIosPlugin(config, props) } @@ -620,3 +806,6 @@ module.exports.applyDotenvFileBuildSetting = applyDotenvFileBuildSetting module.exports.resolveDotenvFileProp = resolveDotenvFileProp module.exports.buildAndroidDotenvFileGradleValue = buildAndroidDotenvFileGradleValue module.exports.updateDotenvFileGradleProperties = updateDotenvFileGradleProperties +module.exports.POSTHOG_RELEASE_MODES = POSTHOG_RELEASE_MODES +module.exports.resolveReleaseModeProp = resolveReleaseModeProp +module.exports.updateReleaseModeGradleProperties = updateReleaseModeGradleProperties diff --git a/packages/react-native/test/expoconfig.spec.ts b/packages/react-native/test/expoconfig.spec.ts index 52e1e43c5f..27bf5d0619 100644 --- a/packages/react-native/test/expoconfig.spec.ts +++ b/packages/react-native/test/expoconfig.spec.ts @@ -1,5 +1,8 @@ import { withXcodeProject } from '@expo/config-plugins' import { spawnSync } from 'child_process' +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' import * as postHogExpoPluginModule from '../src/tooling/expoconfig' import { @@ -17,7 +20,9 @@ import { moveDsymUploadBuildPhaseToEnd, resolveDotenvFileProp, resolveNativeSymbolUpload, + resolveReleaseModeProp, updateDotenvFileGradleProperties, + updateReleaseModeGradleProperties, } from '../src/tooling/expoconfig' const postHogExpoPlugin = (postHogExpoPluginModule as any).default @@ -183,6 +188,21 @@ describe('modifyExistingXcodeBuildScript', () => { expect(parsed).not.toContain('POSTHOG_SKIP_ON_CONFLICT') }) + it('adds and removes the release mode export as the prop changes', () => { + // Reverting `releaseMode` in app.json has to remove the export, or the build keeps uploading + // release-independent source maps after the user asked for the default back. + const script = { shellScript: JSON.stringify('"../node_modules/react-native/scripts/react-native-xcode.sh"') } + + modifyExistingXcodeBuildScript(script, false, 'event') + expect(JSON.parse(script.shellScript)).toContain('export POSTHOG_RELEASE_MODE=event') + + modifyExistingXcodeBuildScript(script, false, 'symbol-set') + expect(JSON.parse(script.shellScript)).toContain('export POSTHOG_RELEASE_MODE=symbol-set') + + modifyExistingXcodeBuildScript(script) + expect(JSON.parse(script.shellScript)).not.toContain('POSTHOG_RELEASE_MODE') + }) + it('migrates an existing shell-prefixed PostHog wrapper to a composable invocation', () => { const reactNativeCommand = '../node_modules/react-native/scripts/react-native-xcode.sh' const oldWrapped = `/bin/sh ${addPostHogWithBundledScriptsToBundleShellScript(reactNativeCommand)}` @@ -347,6 +367,122 @@ describe('addDsymUploadBuildPhase', () => { // xcode's addBuildPhase stores shellScript quote-escaped with literal newlines. const encodePbx = (script: string): string => '"' + script.replace(/"/g, '\\"') + '"' + it('unbinds dSYM uploads from a release in event mode, and refreshes back out of it', () => { + // The refresh only fires when the stored script matches a variant the plugin can generate, so + // a release-mode variant missing from that list would silently freeze the phase as-is. + const existing = { isa: 'PBXShellScriptBuildPhase', shellScript: encodePbx(buildDsymUploadShellScript()) } + const xp = mockXcodeProjectForBuildPhase(existing) + + addDsymUploadBuildPhase(xp, false, false, 'event') + expect(existing.shellScript).toBe(encodePbx(buildDsymUploadShellScript(false, false, 'event'))) + + addDsymUploadBuildPhase(xp, false, false, 'symbol-set') + expect(existing.shellScript).toBe(encodePbx(buildDsymUploadShellScript(false, false, 'symbol-set'))) + }) + + // Verbatim text of the phase as posthog-react-native 4.63 wrote it. The plugin refreshes a phase + // only when its text matches something the plugin generated, so a project prebuilt by that SDK + // depends on this exact text staying in the list. Kept as literals on purpose: deriving it from + // the current generator would hide a change to the shared lines. + const LEGACY_DSYM_SCRIPT_TAIL = [ + 'PODS_SCRIPT="${PODS_ROOT}/PostHog/build-tools/upload-symbols.sh"', + 'SPM_SCRIPT="${BUILD_DIR%/Build/*}/SourcePackages/checkouts/posthog-ios/build-tools/upload-symbols.sh"', + 'if [ -f "$PODS_SCRIPT" ]; then', + ' /bin/sh "$PODS_SCRIPT"', + 'elif [ -f "$SPM_SCRIPT" ]; then', + ' /bin/sh "$SPM_SCRIPT"', + 'else', + ' echo "warning: PostHog upload-symbols.sh not found in Pods or SwiftPM checkouts; skipping dSYM upload."', + 'fi', + ] + const legacyDsymPhases: Array<[string, boolean, boolean, string[]]> = [ + [ + 'no options', + false, + false, + [ + '# Upload iOS dSYMs to PostHog so native crashes can be symbolicated.', + '# upload-symbols.sh ships inside the posthog-ios dependency.', + ...LEGACY_DSYM_SCRIPT_TAIL, + ], + ], + [ + 'includeSource and skipOnConflict', + true, + true, + [ + '# Upload iOS dSYMs to PostHog so native crashes can be symbolicated.', + '# upload-symbols.sh ships inside the posthog-ios dependency.', + '# Also upload native source files for source-code context around crashes.', + 'export POSTHOG_INCLUDE_SOURCE=1', + '# Skip dSYMs that already exist in PostHog with different content instead of failing the build.', + 'export POSTHOG_SKIP_ON_CONFLICT=1', + ...LEGACY_DSYM_SCRIPT_TAIL, + ], + ], + ] + + it.each(legacyDsymPhases)( + 'refreshes a phase written by an SDK without release-mode support (%s)', + (_case, includeSource, skipOnConflict, legacyLines) => { + const existing = { isa: 'PBXShellScriptBuildPhase', shellScript: encodePbx(legacyLines.join('\n')) } + const xp = mockXcodeProjectForBuildPhase(existing) + + addDsymUploadBuildPhase(xp, includeSource, skipOnConflict, 'event') + + expect(xp.addBuildPhase).not.toHaveBeenCalled() + expect(existing.shellScript).toBe(encodePbx(buildDsymUploadShellScript(includeSource, skipOnConflict, 'event'))) + } + ) + + // Runs the generated phase against a stub upload-symbols.sh, so the assertions are on what + // posthog-ios actually receives rather than on the shell source. + const runDsymPhase = (script: string, env: Record): { status: number; output: string } => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'posthog-dsym-phase-')) + try { + const stub = path.join(tempDir, 'PostHog', 'build-tools', 'upload-symbols.sh') + fs.mkdirSync(path.dirname(stub), { recursive: true }) + fs.writeFileSync(stub, '#!/bin/sh\necho "NO_RELEASE_BIND=${POSTHOG_NO_RELEASE_BIND:-unset}"\n', { + mode: 0o755, + }) + const scriptPath = path.join(tempDir, 'phase.sh') + fs.writeFileSync(scriptPath, script, { mode: 0o755 }) + const result = spawnSync('/bin/sh', [scriptPath], { + env: { ...process.env, PODS_ROOT: tempDir, BUILD_DIR: tempDir, ...env }, + encoding: 'utf8', + }) + return { status: result.status ?? -1, output: `${result.stdout}${result.stderr}` } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }) + } + } + + it('unbinds dSYM uploads when only the environment selects event mode', () => { + // The bundle phase reads POSTHOG_RELEASE_MODE from the environment, so a build configured that + // way and not through the plugin prop used to upload maps unbound and dSYMs bound. + const script = buildDsymUploadShellScript() + + expect(runDsymPhase(script, { POSTHOG_RELEASE_MODE: 'event' }).output).toContain('NO_RELEASE_BIND=1') + expect(runDsymPhase(script, { POSTHOG_RELEASE_MODE: 'symbol-set' }).output).toContain('NO_RELEASE_BIND=unset') + expect(runDsymPhase(script, {}).output).toContain('NO_RELEASE_BIND=unset') + }) + + it('keeps the plugin prop in charge when the environment disagrees', () => { + // The bundle phase exports the prop's mode, overriding what it inherited. The dSYM phase has + // to settle the same disagreement the same way, or one build binds half its symbols. + const script = buildDsymUploadShellScript(false, false, 'event') + + expect(runDsymPhase(script, { POSTHOG_RELEASE_MODE: 'symbol-set' }).output).toContain('NO_RELEASE_BIND=1') + }) + + it('fails the build on a release mode it does not recognize', () => { + // Falling back would upload dSYMs bound to a release the user asked to keep independent. + const result = runDsymPhase(buildDsymUploadShellScript(), { POSTHOG_RELEASE_MODE: 'evnet' }) + + expect(result.status).toBe(1) + expect(result.output).toContain("was 'evnet'") + }) + it('refreshes an existing plugin-generated phase script so option changes take effect', () => { const existing: any = { isa: 'PBXShellScriptBuildPhase', @@ -681,6 +817,40 @@ describe('updateDotenvFileGradleProperties', () => { }) }) +describe('updateReleaseModeGradleProperties', () => { + const unrelated = [ + { type: 'comment', value: 'Project-wide Gradle settings.' }, + { type: 'property', key: 'android.useAndroidX', value: 'true' }, + ] + + it('adds the entry when set and removes it when the prop is dropped', () => { + const withEntry = updateReleaseModeGradleProperties([...unrelated], 'event') + expect(withEntry).toEqual([...unrelated, { type: 'property', key: 'posthog.releaseMode', value: 'event' }]) + + expect(updateReleaseModeGradleProperties(withEntry)).toEqual(unrelated) + }) + + it('replaces an existing entry instead of duplicating it', () => { + const withEntry = updateReleaseModeGradleProperties([...unrelated], 'event') + const result = updateReleaseModeGradleProperties(withEntry, 'symbol-set') + expect(result.filter((item) => item.key === 'posthog.releaseMode')).toEqual([ + { type: 'property', key: 'posthog.releaseMode', value: 'symbol-set' }, + ]) + }) +}) + +describe('resolveReleaseModeProp', () => { + it('treats an unset or blank prop as the posthog-cli default', () => { + expect(resolveReleaseModeProp()).toBeUndefined() + expect(resolveReleaseModeProp(' ')).toBeUndefined() + }) + + it('stops the prebuild on a typo rather than falling back to the default', () => { + expect(resolveReleaseModeProp(' event ')).toBe('event') + expect(() => resolveReleaseModeProp('evnet')).toThrow("was 'evnet'") + }) +}) + describe('applyPostHogAndroidGradlePlugin', () => { const appBuildGradle = [ 'apply plugin: "com.android.application"', diff --git a/packages/react-native/test/posthog-xcode-parse.spec.ts b/packages/react-native/test/posthog-xcode-parse.spec.ts index f76d5d91f0..656a3784e4 100644 --- a/packages/react-native/test/posthog-xcode-parse.spec.ts +++ b/packages/react-native/test/posthog-xcode-parse.spec.ts @@ -1,8 +1,10 @@ -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' +import { POSTHOG_RELEASE_MODES, buildDsymUploadShellScript } from '../src/tooling/expoconfig' + /** * These tests validate the sed expressions used in tooling/posthog-xcode.sh * to parse a git remote URL into {host, owner/repo}. Rather than re-declare @@ -370,7 +372,7 @@ describe('posthog-xcode.sh skipOnConflict upload flag', () => { expect(contents).toContain('POSTHOG_UPLOAD_ARGS+=(--skip-on-conflict)') expect(contents).toContain( - 'CLI_UPLOAD_OUTPUT=$("$PH_CLI_PATH" hermes upload --directory "$DERIVED_FILE_DIR" "${CLI_RELEASE_ARGS[@]}" "${POSTHOG_UPLOAD_ARGS[@]}" 2>&1)' + 'CLI_UPLOAD_OUTPUT=$("$PH_CLI_PATH" hermes upload --directory "$DERIVED_FILE_DIR" "${CLI_RELEASE_ARGS[@]}" "${POSTHOG_UPLOAD_ARGS[@]}" "${POSTHOG_RELEASE_MODE_ARGS[@]}" 2>&1)' ) expect(contents).not.toContain('hermes clone --skip-on-conflict') }) @@ -406,3 +408,284 @@ 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', () => { + // The wrapper reads `posthog-cli --version` once, to choose the release arguments and to check + // the --release-mode floor. These stubs answer that without recording it, so the trace holds only + // the clone and upload calls. The versions sit far either side of the real floor, so the tests + // survive it being set. + const cliStub = (version: string): string => + [ + '#!/bin/sh', + `case " $* " in *" --version "*) echo "posthog-cli ${version}"; exit 0;; esac`, + 'echo "$@" >> "$CLI_TRACE_PATH"', + '', + ].join('\n') + const CLI_NEW_ENOUGH = cliStub('9.9.9') + const CLI_TOO_OLD = cliStub('0.0.1') + // Reports no version, and records every call, the version probe included. + const CLI_WITHOUT_VERSION = ['#!/bin/sh', 'echo "$@" >> "$CLI_TRACE_PATH"', ''].join('\n') + // Below the version at which the wrapper hands Info.plist to posthog-cli as --info-plist, so the + // wrapper resolves the release from the plist itself. The hand-over has its own tests above. + const CLI_WITHOUT_INFO_PLIST = cliStub('0.15.0') + + // 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, + cli: string = CLI_NEW_ENOUGH + ): { 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, cli, { 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` + ) + // Stands in for /usr/libexec/PlistBuddy, which Linux CI does not have. Answers with the + // values written above, so the assertions see the wrapper's own plist resolution. + const plistBuddyPath = path.join(tempDir, 'plist-buddy') + fs.writeFileSync( + plistBuddyPath, + [ + '#!/bin/sh', + 'case "$2" in', + ...Object.entries(infoPlist).map(([key, value]) => ` *${key}*) printf %s '${value}' ;;`), + 'esac', + '', + ].join('\n'), + { mode: 0o755 } + ) + plistEnv.POSTHOG_PLIST_BUDDY = plistBuddyPath + 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 }) + } + } + + it.each([ + ['the POSTHOG_RELEASE_MODE env var', [] as string[], { POSTHOG_RELEASE_MODE: 'event' }], + ['the --posthog-release-mode argument', ['--posthog-release-mode', 'event', '--'], {}], + ])('passes --release-mode event to clone and upload from %s', (_source, args, env) => { + const { status, invocations } = runWrapper(args, env) + + expect(status).toBe(0) + expect(invocations).toHaveLength(2) + expect(invocations[0]).toContain('hermes clone') + expect(invocations[0]).toContain('--release-mode event') + expect(invocations[1]).toContain('hermes upload') + expect(invocations[1]).toContain('--release-mode event') + }) + + it('pins the same posthog-cli floor as posthog.gradle', () => { + const shellFloor = fs.readFileSync(SCRIPT_PATH, 'utf8').match(/^MIN_RELEASE_MODE_CLI_VERSION="([^"]+)"$/m)?.[1] + const gradleFloor = fs + .readFileSync(path.join(path.dirname(SCRIPT_PATH), 'posthog.gradle'), 'utf8') + .match(/MIN_RELEASE_MODE_VERSION = "([^"]+)"/)?.[1] + + expect(shellFloor).toMatch(/^\d+\.\d+\.\d+$/) + expect(gradleFloor).toBe(shellFloor) + }) + + it.each([ + ['is below the minimum', CLI_TOO_OLD, 'needs posthog-cli >='], + ['reports no version at all', CLI_WITHOUT_VERSION, 'could not determine the posthog-cli version'], + ])('names the upgrade when the posthog-cli on the box %s', (_case, cli, message) => { + const { status, invocations, output } = runWrapper([], { POSTHOG_RELEASE_MODE: 'event' }, undefined, cli) + + expect(status).not.toBe(0) + expect(output).toContain(message) + expect(output).toContain('npm install -g @posthog/cli@latest') + // It fails before uploading anything, rather than part way through. + expect(invocations.join('\n')).not.toContain('hermes') + }) + + it('reports the version it found so the message is actionable', () => { + const { output } = runWrapper([], { POSTHOG_RELEASE_MODE: 'event' }, undefined, CLI_TOO_OLD) + + expect(output).toContain('needs posthog-cli >= 0.16.0 (found 0.0.1)') + }) + + it('skips the version check for a posthog-cli built from source', () => { + const { status, invocations } = runWrapper( + [], + { POSTHOG_RELEASE_MODE: 'event', POSTHOG_SKIP_CLI_VERSION_CHECK: '1' }, + undefined, + CLI_TOO_OLD + ) + + expect(status).toBe(0) + expect(invocations).toHaveLength(2) + expect(invocations[0]).toContain('--release-mode event') + }) + + it('omits the flag by default so an older posthog-cli keeps working', () => { + const { status, invocations } = runWrapper([], { POSTHOG_RELEASE_MODE: '' }, undefined, CLI_WITHOUT_VERSION) + + expect(status).toBe(0) + const uploads = invocations.filter((line) => line.includes('hermes')) + expect(uploads).toHaveLength(2) + expect(uploads.join('\n')).not.toContain('--release-mode') + }) + + it('fails the build on an unrecognized mode instead of binding the maps anyway', () => { + const { status, invocations, output } = runWrapper([], { POSTHOG_RELEASE_MODE: 'evnet' }) + + expect(status).not.toBe(0) + expect(invocations).toHaveLength(0) + expect(output).toContain("must be 'symbol-set' or 'event'") + }) + + // 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. posthog-cli + // 0.15.1 and newer read the plist themselves; older ones get the wrapper's resolution below. + 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' }, + CLI_WITHOUT_INFO_PLIST + ) + + 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)' }, + CLI_WITHOUT_INFO_PLIST + ) + + 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') + }) +}) + +/** + * The accepted release modes are written out four times: POSTHOG_RELEASE_MODES, the case in + * posthog-xcode.sh, the list in posthog.gradle, and the case in the generated dSYM phase. A third + * mode would be accepted at prebuild and then rejected at build time by whichever copy was missed. + */ +describe('release mode lists stay in sync', () => { + const GRADLE_PATH = path.resolve(__dirname, '..', 'tooling', 'posthog.gradle') + + // Reads ` symbol-set|event) ;;` out of the case on $POSTHOG_RELEASE_MODE_VALUE. + const shellModes = (): string[] => { + const contents = fs.readFileSync(SCRIPT_PATH, 'utf8') + const match = contents.match(/case "\$POSTHOG_RELEASE_MODE_VALUE" in\s*\n\s*([^)]+)\)/) + if (!match) throw new Error('Could not locate the release mode case in posthog-xcode.sh') + return match[1].split('|').map((mode) => mode.trim()) + } + + // Reads `["symbol-set", "event"]` out of resolvePostHogReleaseMode. + const gradleModes = (): string[] => { + const contents = fs.readFileSync(GRADLE_PATH, 'utf8') + const match = contents.match(/value in \[([^\]]+)\]/) + if (!match) throw new Error('Could not locate the release mode list in posthog.gradle') + return match[1].split(',').map((mode) => mode.trim().replace(/"/g, '')) + } + + // Reads the case labels out of the generated dSYM phase, dropping the `""` unset arm. + const dsymModes = (): string[] => { + const script = buildDsymUploadShellScript(false, false, undefined) + const start = script.indexOf('case "$POSTHOG_RESOLVED_RELEASE_MODE" in') + const end = script.indexOf('\n *)', start) + if (start === -1 || end === -1) throw new Error('Could not locate the release mode case in the dSYM phase') + return [...script.slice(start, end).matchAll(/^ {2}([^)]+)\)/gm)] + .flatMap((match) => match[1].split('|')) + .map((mode) => mode.replace(/"/g, '').trim()) + .filter(Boolean) + } + + it.each([ + ['posthog-xcode.sh', shellModes], + ['posthog.gradle', gradleModes], + ['the generated dSYM phase', dsymModes], + ])('%s accepts exactly the modes the plugin does', (_name, extract) => { + expect((extract as () => string[])().sort()).toEqual([...POSTHOG_RELEASE_MODES].sort()) + }) + + // Both platforms gate event mode on the same posthog-cli, so raising one floor and forgetting + // the other would leave one platform accepting a CLI the other rejects. + it('gates both platforms on the same posthog-cli version', () => { + const shell = fs.readFileSync(SCRIPT_PATH, 'utf8').match(/MIN_RELEASE_MODE_CLI_VERSION="([^"]+)"/) + const gradle = fs.readFileSync(GRADLE_PATH, 'utf8').match(/MIN_RELEASE_MODE_VERSION = "([^"]+)"/) + if (!shell || !gradle) throw new Error('Could not locate the release mode version floors') + + expect(shell[1]).toBe(gradle[1]) + }) +}) diff --git a/packages/react-native/tooling/posthog-xcode.sh b/packages/react-native/tooling/posthog-xcode.sh index 262dc4103b..85b389370d 100755 --- a/packages/react-native/tooling/posthog-xcode.sh +++ b/packages/react-native/tooling/posthog-xcode.sh @@ -33,12 +33,26 @@ print_command_error() { # WITH_ENVIRONMENT is executed by React Native POSTHOG_SKIP_ON_CONFLICT_ENABLED="${POSTHOG_SKIP_ON_CONFLICT:-}" +POSTHOG_RELEASE_MODE_VALUE="${POSTHOG_RELEASE_MODE:-}" while [ "$#" -gt 0 ]; do case "$1" in --posthog-skip-on-conflict) POSTHOG_SKIP_ON_CONFLICT_ENABLED=1 shift ;; + --posthog-release-mode) + # `shift 2` past the end of the argument list makes `set -e` abort the wrapper silently. + if [ "$#" -lt 2 ]; then + echo "error: --posthog-release-mode needs a value ('symbol-set' or 'event')" + exit 1 + fi + POSTHOG_RELEASE_MODE_VALUE="$2" + shift 2 + ;; + --posthog-release-mode=*) + POSTHOG_RELEASE_MODE_VALUE="${1#*=}" + shift + ;; --) shift break @@ -54,6 +68,32 @@ if [ "$POSTHOG_SKIP_ON_CONFLICT_ENABLED" = "1" ] || [ "$POSTHOG_SKIP_ON_CONFLICT POSTHOG_UPLOAD_ARGS+=(--skip-on-conflict) fi +# How the release a build belongs to gets associated with the exceptions it reports. +# symbol-set (the default) stamps the release onto the uploaded source maps, and an exception +# inherits the release of the maps its frames resolved against. +# event uploads the maps release-independent, and each event resolves its own release from the +# $app_namespace / $app_version / $app_build the SDK already sends. Xcode's build settings +# supply matching coordinates below, so nothing has to be injected into the app. +POSTHOG_RELEASE_MODE_ARGS=() +if [ -n "$POSTHOG_RELEASE_MODE_VALUE" ]; then + case "$POSTHOG_RELEASE_MODE_VALUE" in + symbol-set|event) ;; + *) + echo "error: posthog release mode must be 'symbol-set' or 'event', was '$POSTHOG_RELEASE_MODE_VALUE'" + exit 1 + ;; + esac + + # posthog-cli reads POSTHOG_RELEASE_MODE itself when --release-mode is absent, which it + # deliberately is in symbol-set mode so the flag stays optional against a CLI predating it. Pin + # the resolved mode so a variable inherited from the build environment cannot quietly override + # an explicit --posthog-release-mode. + export POSTHOG_RELEASE_MODE="$POSTHOG_RELEASE_MODE_VALUE" + if [ "$POSTHOG_RELEASE_MODE_VALUE" != "symbol-set" ]; then + POSTHOG_RELEASE_MODE_ARGS=(--release-mode "$POSTHOG_RELEASE_MODE_VALUE") + fi +fi + REACT_NATIVE_XCODE_DEFAULT="../node_modules/react-native/scripts/react-native-xcode.sh" REACT_NATIVE_XCODE="$REACT_NATIVE_XCODE_DEFAULT" # A config plugin may already wrap the React Native script. Keep the complete @@ -134,6 +174,26 @@ if [ -n "$PH_CLI_VERSION" ]; then fi fi +# The CLI is whatever the machine has, not a pinned version, so an older one rejects --release-mode +# with a bare argument-parser error. Checks the version read above, and mirrors the floor check in +# posthog-ios build-tools/upload-symbols.sh. POSTHOG_SKIP_CLI_VERSION_CHECK=1 allows a posthog-cli +# built from source, which reports its Cargo manifest version rather than the version it ships as. +# posthog-cli 0.16.0 added --release-mode to the hermes commands (PostHog/posthog#87660). Keep this +# in step with PostHogCli.MIN_RELEASE_MODE_VERSION in posthog.gradle. +MIN_RELEASE_MODE_CLI_VERSION="0.16.0" +if [ ${#POSTHOG_RELEASE_MODE_ARGS[@]} -gt 0 ] && [ "${POSTHOG_SKIP_CLI_VERSION_CHECK:-}" != "1" ]; then + if [ -z "$PH_CLI_VERSION" ]; then + echo "error: could not determine the posthog-cli version, which release mode '$POSTHOG_RELEASE_MODE_VALUE' needs. Upgrade: npm install -g @posthog/cli@latest" + exit 1 + fi + # If the minimum sorts first, the installed version is at or above it. + PH_CLI_LOWEST=$(printf '%s\n%s\n' "$MIN_RELEASE_MODE_CLI_VERSION" "$PH_CLI_VERSION" | sort -t. -k1,1n -k2,2n -k3,3n | head -n1) + if [ "$PH_CLI_LOWEST" != "$MIN_RELEASE_MODE_CLI_VERSION" ]; then + echo "error: release mode '$POSTHOG_RELEASE_MODE_VALUE' needs posthog-cli >= ${MIN_RELEASE_MODE_CLI_VERSION} (found ${PH_CLI_VERSION}). Upgrade: npm install -g @posthog/cli@latest" + exit 1 + fi +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" @@ -304,7 +364,7 @@ fi # Execute posthog cli clone set +x +e -CLI_CLONE_OUTPUT=$("$PH_CLI_PATH" hermes clone --minified-map-path "$SOURCEMAP_PACKAGER_FILE" --composed-map-path "$SOURCEMAP_FILE" "${CLI_RELEASE_ARGS[@]}" 2>&1) +CLI_CLONE_OUTPUT=$("$PH_CLI_PATH" hermes clone --minified-map-path "$SOURCEMAP_PACKAGER_FILE" --composed-map-path "$SOURCEMAP_FILE" "${CLI_RELEASE_ARGS[@]}" "${POSTHOG_RELEASE_MODE_ARGS[@]}" 2>&1) CLONE_EXIT_CODE=$? if [ $CLONE_EXIT_CODE -eq 0 ]; then echo "$CLI_CLONE_OUTPUT" | awk '{print "output: posthog-cli - " $0}' @@ -316,7 +376,7 @@ set -x -e # Execute posthog cli upload set +x +e -CLI_UPLOAD_OUTPUT=$("$PH_CLI_PATH" hermes upload --directory "$DERIVED_FILE_DIR" "${CLI_RELEASE_ARGS[@]}" "${POSTHOG_UPLOAD_ARGS[@]}" 2>&1) +CLI_UPLOAD_OUTPUT=$("$PH_CLI_PATH" hermes upload --directory "$DERIVED_FILE_DIR" "${CLI_RELEASE_ARGS[@]}" "${POSTHOG_UPLOAD_ARGS[@]}" "${POSTHOG_RELEASE_MODE_ARGS[@]}" 2>&1) UPLOAD_EXIT_CODE=$? if [ $UPLOAD_EXIT_CODE -eq 0 ]; then echo "$CLI_UPLOAD_OUTPUT" | awk '{print "output: posthog-cli - " $0}' diff --git a/packages/react-native/tooling/posthog.gradle b/packages/react-native/tooling/posthog.gradle index c47c18640a..9f808f68d1 100644 --- a/packages/react-native/tooling/posthog.gradle +++ b/packages/react-native/tooling/posthog.gradle @@ -97,6 +97,20 @@ plugins.withId('com.android.application') { posthogUploadArgs.add("--skip-on-conflict") } + // How the release a build belongs to gets associated with the exceptions + // it reports. `symbol-set` (the default) stamps the release onto the + // uploaded source maps; `event` uploads them release-independent and lets + // each event resolve its own release from the $app_namespace / + // $app_version / $app_build the SDK already sends, which the release + // coordinates below are built from. Passed only outside the default mode, + // so a symbol-set build keeps working against a posthog-cli predating the + // flag. + def posthogReleaseMode = resolvePostHogReleaseMode(project) + def posthogReleaseModeArgs = [] + if (posthogReleaseMode != null && posthogReleaseMode != "symbol-set") { + posthogReleaseModeArgs.addAll(["--release-mode", posthogReleaseMode]) + } + // posthog.dotenvFile (gradle.properties): dotenv file with POSTHOG_CLI_* // credentials, relative paths resolved against the android root project. // Exported to the CLI as POSTHOG_CLI_DOTENV_FILE (posthog-cli >= 0.8.4; @@ -128,12 +142,22 @@ plugins.withId('com.android.application') { doFirst { def cliPackage = PostHogCli.resolveCliPackagePath(rootDirFile, reactRoot) + if (!posthogReleaseModeArgs.isEmpty() && System.getenv("POSTHOG_SKIP_CLI_VERSION_CHECK") != "1") { + def cliVersion = PostHogCli.resolveVersion([*osCompatibility, cliPackage]) + if (cliVersion == null) { + throw new GradleException("Could not determine the posthog-cli version, which posthog.releaseMode '${posthogReleaseMode}' needs. Upgrade: npm install -g @posthog/cli@latest") + } + if (!PostHogCli.meetsMinimumVersion(cliVersion, PostHogCli.MIN_RELEASE_MODE_VERSION)) { + throw new GradleException("posthog.releaseMode '${posthogReleaseMode}' needs posthog-cli >= ${PostHogCli.MIN_RELEASE_MODE_VERSION} (found ${cliVersion}). Upgrade: npm install -g @posthog/cli@latest") + } + } def args = [cliPackage] args.addAll(["hermes", "clone", "--minified-map-path", packagerSourcemapOutput, // The path to a sourcemap "--composed-map-path", sourcemapOutput // The path of the composed source map ]) args.addAll(extraArgs) + args.addAll(posthogReleaseModeArgs) logger.info("posthog-cli clone arguments: ${args}") injected.execOps.exec { @@ -141,6 +165,13 @@ plugins.withId('com.android.application') { if (posthogDotenvFilePath != null) { environment "POSTHOG_CLI_DOTENV_FILE", posthogDotenvFilePath } + // posthog-cli reads POSTHOG_RELEASE_MODE itself when + // --release-mode is absent, which it deliberately is in symbol-set + // mode. Pin the resolved mode so a daemon that inherited the + // variable cannot override an explicit posthog.releaseMode. + if (posthogReleaseMode != null) { + environment "POSTHOG_RELEASE_MODE", posthogReleaseMode + } commandLine(*osCompatibility, *args) } } @@ -153,6 +184,7 @@ plugins.withId('com.android.application') { ]) args.addAll(extraArgs) args.addAll(posthogUploadArgs) + args.addAll(posthogReleaseModeArgs) logger.info("posthog-cli upload arguments: ${args}") injected.execOps.exec { @@ -160,6 +192,9 @@ plugins.withId('com.android.application') { if (posthogDotenvFilePath != null) { environment "POSTHOG_CLI_DOTENV_FILE", posthogDotenvFilePath } + if (posthogReleaseMode != null) { + environment "POSTHOG_RELEASE_MODE", posthogReleaseMode + } commandLine(*osCompatibility, *args) } } @@ -269,6 +304,48 @@ abstract class PostHogCli { logger.info("falling back to global posthog-cli") return "posthog-cli" } + + /** + * The CLI is whatever the machine has, not a pinned version, so an older one rejects + * --release-mode with a bare argument-parser error. Mirrors the floor check in posthog-ios + * build-tools/upload-symbols.sh. POSTHOG_SKIP_CLI_VERSION_CHECK=1 allows a posthog-cli built + * from source, which reports its Cargo manifest version rather than the version it ships as. + * posthog-cli 0.16.0 added --release-mode to the hermes commands (PostHog/posthog#87660). Keep + * this in step with MIN_RELEASE_MODE_CLI_VERSION in posthog-xcode.sh. + */ + static final String MIN_RELEASE_MODE_VERSION = "0.16.0" + + /** Null when the version cannot be read. */ + static String resolveVersion(List command) { + try { + def process = (command + ["--version"]).execute() + def stdout = new StringBuffer() + def stderr = new StringBuffer() + process.waitForProcessOutput(stdout, stderr) + if (process.exitValue() != 0) { + return null + } + return (stdout.toString() + stderr.toString()).find(/\d+(\.\d+)+/) + } catch (Throwable ignored) { + return null + } + } + + /** Compares numeric fields left to right, so 0.9.0 stays below 0.16.0. */ + static boolean meetsMinimumVersion(String found, String minimum) { + def fields = { String version -> version.tokenize(".").collect { (it.find(/^\d+/) ?: "0") as int } } + def a = fields(found) + def b = fields(minimum) + // Pad the shorter one so 0.16 and 0.16.0 compare equal. + while (a.size() < b.size()) a << 0 + while (b.size() < a.size()) b << 0 + for (int i = 0; i < a.size(); i++) { + if (a[i] != b[i]) { + return a[i] > b[i] + } + } + return true + } } def resolvePostHogReactNativeSDKPath(reactRoot) { @@ -280,6 +357,29 @@ def resolvePostHogReactNativeSDKPath(reactRoot) { return packagePath } +/** + * Release mode for this build: the `posthog.releaseMode` gradle property, then the + * `POSTHOG_RELEASE_MODE` environment variable posthog-cli and the bundler plugins already read, + * then null for the posthog-cli default. `com.posthog.android` reads the same property for the R8 + * mapping upload, so one entry covers both halves of an Android build. + * + * An unrecognized value fails the build rather than falling back, so a typo cannot silently leave + * a build binding its source maps to a release it meant to keep independent. + */ +static String resolvePostHogReleaseMode(Project project) { + def value = project.findProperty("posthog.releaseMode")?.toString()?.trim() + if (!value) { + value = System.getenv("POSTHOG_RELEASE_MODE")?.trim() + } + if (!value) { + return null + } + if (!(value in ["symbol-set", "event"])) { + throw new GradleException("posthog.releaseMode must be one of symbol-set, event, was '${value}'") + } + return value +} + /** Extract from arguments collection bundle and sourcemap files output names. */ static extractBundleTaskArgumentsLegacy(cmdArgs, Project project) { def bundleOutput = null