Separate bundle identifiers for debug and release builds in Expo
Separate bundle identifiers for debug and release builds in Expo
By default, debug and release Expo builds share the same app ID. Install a
debug build and it replaces your production app. This is fine when you're
heads-down in development, but annoying when you want to quickly test something
without losing the production app — or when you want each build connected to a
different Firebase project.
The fix is to give debug builds a different bundle identifier — typically by
appending .dev. On Android you can do this with applicationIdSuffix in
Gradle, and on iOS you set PRODUCT_BUNDLE_IDENTIFIER per build configuration
in Xcode. The problem is that expo prebuild regenerates those native files
every time you run it, so any manual edits get wiped.
That's exactly what config plugins are for.
What's a config plugin?
A config plugin is a JavaScript function that runs during expo prebuild and
modifies the generated native project before compilation. It receives the Expo
config, modifies native files through a set of typed modifiers, and returns the
config. Expo ships modifiers for the most common targets — withAppBuildGradle,
withXcodeProject, withInfoPlist, and so on.
function withMyPlugin(config, options) {
config = withXcodeProject(config, (mod) => {
// mod.modResults is a parsed representation of the .pbxproj file
return mod;
});
return config;
}
withBuildTypeAppIds
I wrote a plugin called withBuildTypeAppIds that takes a production bundle ID
and a suffix, then wires things up across all four of the relevant native files.
On Android, withAppBuildGradle patches the debug block to add
applicationIdSuffix:
buildTypes {
debug {
applicationIdSuffix ".dev"
}
}
Gradle appends this automatically, so you never have to hardcode
com.yourapp.dev anywhere.
I also had to fix the deep link manifest with withAndroidManifest. Android
resolves URL schemes at build time, and with the suffix in play the scheme would
end up as com.yourapp.dev in release builds too if you're not careful. The
plugin sets android:scheme in the intent filter explicitly to the production
scheme.
On iOS, withXcodeProject writes directly into the .pbxproj build
configuration sections. Xcode has a "Debug" and a "Release" configuration and
both have a PRODUCT_BUNDLE_IDENTIFIER setting — the plugin just sets each one:
if (cfg.name === 'Debug') cfg.buildSettings.PRODUCT_BUNDLE_IDENTIFIER = devId;
if (cfg.name === 'Release') cfg.buildSettings.PRODUCT_BUNDLE_IDENTIFIER = prodId;
Then withInfoPlist sets the URL scheme to $(PRODUCT_BUNDLE_IDENTIFIER)
rather than a hardcoded string, so it automatically follows whichever bundle ID
is active for that configuration.
Wiring it up in app.config.ts looks like this:
import withBuildTypeAppIds from '../../packages/expo-config-plugins/src/withBuildTypeAppIds';
const PROD_BUNDLE_ID = 'com.yourapp';
export default ({ config }: ConfigContext): ExpoConfig => ({
...config,
plugins: [
[withBuildTypeAppIds, { prodId: PROD_BUNDLE_ID, devSuffix: '.dev' }],
],
ios: { bundleIdentifier: PROD_BUNDLE_ID },
android: { package: PROD_BUNDLE_ID },
});
After expo prebuild, debug builds install as com.yourapp.dev and release
builds as com.yourapp. Both live on the same device without conflict, and you
can point each at a different Firebase project too.
withAndroidReleaseSigning
While I had the config plugin pattern in mind I wrote a second one to handle
Android release signing. Signing credentials shouldn't be committed to the repo,
but they need to end up in build.gradle somehow. The convention is to store
them in ~/.gradle/gradle.properties, which Gradle reads automatically and
which is never committed.
The plugin uses withAppBuildGradle to inject three things. First, variables
that read from Gradle properties:
def uploadStoreFile = findProperty('YOURAPP_UPLOAD_STORE_FILE')
def uploadStorePassword = findProperty('YOURAPP_UPLOAD_STORE_PASSWORD')
def uploadKeyAlias = findProperty('YOURAPP_UPLOAD_KEY_ALIAS')
def uploadKeyPassword = findProperty('YOURAPP_UPLOAD_KEY_PASSWORD')
def hasUploadSigning = uploadStoreFile && uploadStorePassword && uploadKeyAlias && uploadKeyPassword
Then a signingConfigs.release block that uses them, and finally logic in the
release buildType that applies the config when the properties are present — or
throws a helpful Gradle error if you're attempting a release build without them.
Each developer adds their keystore details to ~/.gradle/gradle.properties
(never committed). CI injects the same values from a secrets manager. The plugin
handles the plumbing, so there's nothing to remember or document beyond "put
your keys in gradle.properties."
One thing worth noting: expo prebuild can run multiple times, so any plugin
that injects text into native files needs to be idempotent. I used marker
comments (// @generated begin yourapp-release-signing) to track what was
already injected, and checked for existing content before inserting anything.
Sharing across apps
Both plugins live in a shared packages/expo-config-plugins workspace package
in our monorepo. Any app can import and use them with no duplication. Since
they're just functions, testing them is straightforward too — pass in a mock
config, check the output.
The thing I like most about this approach is that there are no manually edited
native files to maintain. Run expo prebuild, everything is set up correctly,
and the configuration lives in version-controlled JavaScript rather than
scattered across platform-specific files that you have to remember to keep in sync.
Comments (0)
No comments yet. Be the first!