Automate Flutter app rebranding across Android and iOS β driven by JSON configuration and customizable YAML operations.
Flutter App Transmuter updates package names, bundle identifiers, app display names, Google API keys, pubspec versions, and more β all from a single transmute.json file. It also provides a complete brand management workflow for maintaining multiple branded variants of the same app.
Because all operations are defined as regex-based transformations in YAML, the built-in defaults are just a starting point β you can add, override, or disable operations to perform virtually any text-based transformation on any file in your Flutter project. If a value lives in a config file, manifest, plist, or source file and can be matched with a regex, Flutter App Transmuter can update it. The post-switch operations pipeline can also run arbitrary shell commands and Dart packages as part of the workflow β for example, the defaults include running flutter_launcher_icons for app icons, flutter_native_splash for splash screens, flutterfire configure for Firebase setup, and platform builds β all orchestrated as a single brand-switch operation.
- π Brand Switching β Switch between branded app variants with full file management
- π Whole-File Swapping β Replace entire source files, configs, and assets per brand via
master_transmute.yaml - π¦ Package Name & Bundle ID β Change Android package name and iOS bundle identifiers (where-ever that may reside within different flutter version's platform files (AndroidManifest.xml/build.gradle.kts/etc.))
- π·οΈ App Display Name β Change the app name on both platforms simultaneously
- π MainActivity Relocation β Automatically moves/renames
MainActivity.java/.ktto the correct package directories - π API Key Management β Swap API keys per brand across platform config files, such as Google Maps SDK keys in
AndroidManifest.xmlandAppDelegate.swift - π Version Management β Set
pubspec.yamlversion from your brand configuration - π§ Customizable Operations β Override, extend, or disable any operation via YAML
- π Dry Run Mode β Preview all changes/differences without modifying any files
- β Verification β When switching between brands check that project files match your transmute configuration and/or changes made to brand files within the 'live' source project are copied back to the brand directory
Install the command-line tool globally so it's available on your PATH:
dart pub global activate flutter_app_transmuterThis registers the transmute executable (plus the aliases transmuter,
app_transmuter, and flutter_app_transmuter). You can then run it from any
Flutter project root:
transmute --transmuteOther variants:
# A specific version from pub.dev
dart pub global activate flutter_app_transmuter <version>
# Activate from a LOCAL checkout instead of pub.dev (for testing before publishing)
dart pub global activate --source path /path/to/flutter_app_transmuter
# Deactivate later
dart pub global deactivate flutter_app_transmuterUpdating: there is no separate update command β re-running
dart pub global activate flutter_app_transmuter always fetches and installs the
latest published version, overwriting whatever is currently activated. Run
transmute --check_pubdev to see whether a newer version is available (regular
commands also check automatically once a day and print a notice). Note that
pub.dev can take a minute or two after a publish before the new version is
available to activate.
If
dart pub global activatewarns that the pub-cachebindirectory isn't on yourPATH, add the directory it names (typically~/.pub-cache/binon macOS/Linux, or%LOCALAPPDATA%\Pub\Cache\binon Windows) to yourPATH.
The rest of this README assumes you have activated the tool globally and uses
transmuteas the command in all examples. If you instead added it as a dev dependency (below), replacetransmutewithdart run flutter_app_transmuter:mainin every example.
Add it to your project's pubspec.yaml under dev_dependencies and invoke it
via dart run:
dev_dependencies:
flutter_app_transmuter:
path: ../path/to/flutter_app_transmuterdart pub get
dart run flutter_app_transmuter:main --transmuteBeyond rebranding files, the tool can create and audit each brand's cloud resources - Google Cloud project, Firebase apps, restricted API keys, Play checks, and the Apple/App Store Connect side - via a bundled provisioning engine (written in Python, shipped inside this package).
# one-time per project: write a commented starter config, then fill it in
transmute provision init # -> transmute_provisioning.yaml
# audit one brand / all brands (loops interactively until clean)
transmute provision audit brands/my_brand
transmute provision audit
# other verbs
transmute provision create <brand_dir> # project + apps + keys end to end
transmute provision add-asc-key <brand_dir> # record an App Store Connect Team Key
transmute provision check-agreements # which Apple teams need agreements signed
transmute provision check-personal-ios-dev-certs # certificate-expiry sweepRequirements (for the provision verbs only - everything else works without):
- Python 3.10+ on PATH
pip install google-api-python-client google-auth requests PyJWT cryptography pyyaml- Google auth via Application Default Credentials:
gcloud auth application-default login
transmute_provisioning.yaml declares what YOUR project needs: the Google APIs
to enable, each API key's purpose (restriction type, allowed services, display
name - with {customerIds} substitution from brand dir names), signing-cert
fingerprints, Apple App ID capabilities, and optional "a server keeps a copy of
this key" notices with per-customer admin URLs. Delete the sections you don't
use; the audit only checks what you declare.
The audit also runs IAM security-hygiene checks on every brand project (on
by default, tuned via the security: section): the FCM messaging service
account must hold only roles/firebasecloudmessaging.admin (--fix revokes
excess roles - a messaging SA holding Editor means a leaked key can enable APIs
and create service accounts), no project service account may hold Owner/Editor,
unknown service accounts and forbidden enabled APIs (default:
compute.googleapis.com) are flagged, and user-managed keys are checked for
strays, duplicates, and (optionally) age.
- Create a
transmute.jsonin your Flutter project root:
{
"packageName": "com.example.myapp",
"appName": "My App"
}- Run the transmuter:
transmute --transmuteThat's it! All Android and iOS configuration files will be updated to match.
Want to see brand switching in action? Check out the
example/directory, which includes 3 complete brand variants (Acme Corp, Globex Industries, Initech Solutions) β each with its own app icon, splash screen, logo, and configuration. The example README walks you through switching between brands. You can switch between brands and run each brand's app version side by side from your IDE.
Place this file in your Flutter project's root directory. It defines the values that the transmuter will apply to your project files.
| Key | Required | Description |
|---|---|---|
packageName |
Yes | Android package name and default iOS bundle identifier |
appName |
Yes | Android label and default iOS display name |
iosBundleIdentifier |
No | Override iOS bundle identifier (defaults to packageName) |
iosBundleDisplayName |
No | Override iOS display name (defaults to appName) |
androidGoogleMapsSDKApiKey |
No | Google Maps API key for Android (AndroidManifest.xml) |
iosGoogleMapsSDKApiKey |
No | Google Maps API key for iOS (AppDelegate.swift) |
pubspec_version |
No | Version string for pubspec.yaml (e.g., 1.2.3+4) |
brand_name |
No | Display name shown in the rainbow banner during operations |
brand_source_directory |
No | Path to the brand files directory (set automatically by --copy) |
{
"brand_name": "Acme Corp",
"brand_source_directory": "../brands/acme",
"packageName": "com.acmecorp.superapp",
"appName": "Acme Super App",
"iosBundleIdentifier": "com.acmecorp.superapp.ios",
"iosBundleDisplayName": "Acme App",
"androidGoogleMapsSDKApiKey": "AIza...",
"iosGoogleMapsSDKApiKey": "AIza...",
"pubspec_version": "2.1.0+5"
}π When
brand_nameis set, a colorful rainbow banner is displayed at the start of every operation showing which brand is active.
All commands are run from your Flutter project root:
transmute <options>Options that take a value are written space-separated throughout this README (e.g.
--platform ios). The=form (--platform=ios) is also accepted β the two are equivalent.
Operations are mutually exclusive β only one can be specified per invocation. (Operations: --status, --check, --verify, --transmute, --copy, --diff, --update, --switch, --executepostprocess)
Show the current brand status: diffs brand files against the project and checks transmute values.
transmute --statusThis is a read-only operation. It displays:
- The current
brand_source_directoryfromtransmute.json - File differences between the brand directory and the project
- Whether transmute values in the JSON match the actual project files
Check that all project files match the values defined in transmute.json. No files are modified.
transmute --checkEach operation is reported as MATCH, MISMATCH, or SKIP:
MATCH: [build_gradle_kts_namespace] namespace in build.gradle.kts
MISMATCH: [android_label] android:label in AndroidManifest.xml
file has: Old App Name
transmute.json specifies: My New App
SKIP: [ios_google_maps_api_key] no value for json_key "iosGoogleMapsSDKApiKey"
Interactive verification β like --check, but offers to fix mismatches.
transmute --verifyFor each mismatch, you're prompted:
MISMATCH: [android_label] android:label in AndroidManifest.xml
file has: Old App Name
transmute.json specifies: My New App
(T) transmute.json -> file, (F) file -> transmute.json, or (N) no change (default N):
- T β Apply the
transmute.jsonvalue to the project file - F β Update
transmute.jsonto match the current file value - N β Skip (no change)
For missing keys, you're offered to add them to transmute.json from the current file values.
Run all transmute operations β applies values from transmute.json to project files.
transmute --transmuteThis is the main operation. It reads transmute.json, loads the operations from the built-in defaults (and merges any user transmute_operations.yaml), then executes each operation in order.
Execution is two-pass:
- Pass 1 runs all
git_restoreoperations first (unconditionally) - Pass 2 runs all value-driven operations (
regex_replace,extract_and_replace,move_activity)
# Dry run to preview changes
transmute --transmute --dryrun
# With debug output
transmute --transmute --debugCopy brand files from a directory into the project using the mappings defined in master_transmute.yaml.
transmute --copy ../brands/acmeThis:
- Reads
master_transmute.yamlfor file mapping definitions - Copies each mapped file from
<brand_dir>into the project - Records the brand directory in
transmute.jsonasbrand_source_directory
The brand directory is a flat folder containing all brand-specific files (icons, config files, transmute.json, etc.).
Compare brand files against current project files.
# Use the brand_source_directory from transmute.json
transmute --diff
# Or specify a directory explicitly
transmute --diff ../brands/acmeFiles are reported as identical, different, or missing. No files are modified.
When specifying an explicit directory, it is checked against the brand_source_directory in transmute.json and a warning is shown if they don't match.
Interactively update brand files from changed project files.
# Use the brand_source_directory from transmute.json
transmute --update
# Specify a directory explicitly
transmute --update ../brands/acme
# Auto-confirm all prompts (copy project->brand for all diffs)
transmute --update --yes
# Non-interactive: use project files, apply transmute values
transmute --update --projectfile --transmutevalueFor each changed file, you're prompted whether to update the brand copy. If the brand file is newer than the project file, a timestamp warning is shown with options:
WARNING: Brand file is NEWER than project file!
Brand: 2025-01-15 14:30:00
Project: 2025-01-10 09:15:00
(B) use brand file -> project, (P) use project file -> brand, (N) skip, or (Q) quit (default N):
Q quits the tool immediately: files already answered are kept, everything else is left untouched.
After file updates, a transmute check runs to verify values and optionally update transmute.json.
Switch from the current brand to a new one. This is the most comprehensive operation.
# Basic switch (interactive prompts)
transmute --switch ../brands/newbrand
# Non-interactive: use project files, apply transmute values
transmute --switch ../brands/newbrand --projectfile --transmutevalue
# With post-switch flags
transmute --switch ../brands/newbrand --projectfile --transmutevalue +flutterfire +build
# Exclude specific post-switch steps
transmute --switch ../brands/newbrand --projectfile --transmutevalue -clean -pub_get
# Auto-confirm everything (yes to all prompts, project->brand for file diffs)
transmute --switch ../brands/newbrand --yesThe switch performs these steps in order:
- Step 1: Update current brand β Saves any project changes back to the current brand directory (same as
--update, with one difference: the file prompt offers (B/P/Q) with no N/skip option. A "skipped" project file would be silently overwritten by step 2's brand copy anyway, so the honest choices are B β take the brand version, P β keep your project version, or Q β quit and abort the switch before anything else runs. Q is the default, so a stray Enter aborts safely.) - Step 2: Copy new brand β Copies files from the new brand directory into the project (same as
--copy) - Step 3: Post-switch operations β Runs the post-switch pipeline (transmute, rebuild icons, clean, etc.)
β οΈ Requiresbrand_source_directoryto be set intransmute.json(automatically set by--copy).
Use + prefix to enable optional post-switch steps:
| Flag | Description |
|---|---|
+flutterfire |
Run flutterfire configure --yes --overwrite-firebase-options |
+build |
Run platform build (flutter build apk on Windows/Linux, flutter build ipa on macOS) |
Use - prefix to skip specific post-switch steps:
| Example | Effect |
|---|---|
-clean |
Skip flutter clean |
-pub_get |
Skip flutter pub get |
-native_splash |
Skip flutter_native_splash:create |
-remove_derived_data |
Skip ios_remove_derived_data (macOS only step) |
-ios_remove_derived_data |
Same as above (full name also works) |
-transmute_command |
Skip the internal transmute step |
Run only the post-switch operations pipeline without performing a full brand switch. This skips the "update current brand" and "copy new brand" steps, and goes straight to executing the post-switch operations (transmute, rebuild icons, clean, etc.).
# Run post-switch pipeline (uses brand_source_directory from transmute.json for $brand_dir)
transmute --executepostprocess
# Specify a brand directory explicitly (for $brand_dir substitution in commands)
transmute --executepostprocess ../brands/acme
# With flags and step exclusions (same syntax as --switch)
transmute --executepostprocess +flutterfire -clean
# Skip the transmute step, only run launcher icons and clean
transmute --executepostprocess -transmute_command -native_splash -pub_getThis is useful when:
- You need to re-run the post-switch pipeline after a failed or interrupted
--switch - You want to run specific post-switch steps (e.g., regenerate launcher icons) without switching brands
- You're debugging or testing the post-switch pipeline
The same +flag and -stepname options used with --switch work here. See Post-Switch Flags and Excluding Steps above.
Print the built-in default transmute operations YAML to stdout.
transmute --showdefaultyamlUseful for reviewing the default operations, piping to a file, or copying specific sections.
Write the default operations YAML to a file as a starting point for customization.
# Write to transmute_operations.yaml (default)
transmute --writedefaultyaml
# Write to a custom filename
transmute --writedefaultyaml my_operations.yamlIf the file already exists, you'll be prompted before overwriting. This is the recommended way to create a starting point for your own customized operations file.
Check pub.dev for a newer published version of the tool.
transmute --check_pubdevPrints the current version, the latest version on pub.dev, and the update command if a newer version is available:
flutter_app_transmuter 2.1.6
Checking pub.dev for the latest published version...
A newer version of flutter_app_transmuter is available: 2.2.0 (current: 2.1.6)
Update with: dart pub global activate flutter_app_transmuter
Regular commands also perform this check automatically in the background (at most once every 24 hours, silently skipped when offline) and print the same notice at the end of the run when an update is available.
These options modify the behavior of the primary operations:
| Option | Description |
|---|---|
--yes |
Auto-confirm all prompts (answer Y to yes/no, copy project->brand for file diffs) |
--skip |
Auto-answer N (skip/no change) to any prompt |
--brandfile |
Auto-answer B (use brand file) for brand/project file conflict prompts |
--projectfile |
Auto-answer P (use project file) for brand/project file conflict prompts |
--transmutevalue |
Auto-answer T (use transmute.json value) for transmute/file mismatch prompts |
--filevalue |
Auto-answer F (use file value) for transmute/file mismatch prompts |
--fatal-prompts |
Exit with error if any interactive prompt is encountered |
--dryrun |
Preview mode β no files are written to disk |
--debug |
Enable debug output (equivalent to --verbose 1) |
--verbose <N> |
Set verbose debug level (0=off, 1+=debug detail) |
--version |
Print the tool version and exit |
--check_pubdev |
Check pub.dev for a newer version of the tool and exit |
--help / --usage |
Show command line help |
The auto-answer options give you fine-grained control over how interactive prompts are handled:
File conflict prompts (B/P/N/Q; B/P/Q during --switch) β shown when brand and project files differ:
--brandfileβ Always use the brand file (answer B)--projectfileβ Always use the project file (answer P)--skipβ Always skip (answer N; during--switcha note warns that step 2 will overwrite the skipped project file)
Transmute mismatch prompts (T/F/N) β shown when file values don't match transmute.json during --verify:
--transmutevalueβ Always use the transmute.json value (answer T)--filevalueβ Always use the current file value (answer F)--skipβ Always skip (answer N)
General behavior:
--yesβ Auto-confirm yes/no prompts and copy project->brand for all file diffs--skipβ Answer N/skip to any prompt that has a skip option--fatal-promptsβ Exit with error code 1 if any prompt would be shown that isn't already auto-answered by another option
Mutually exclusive pairs:
--yesand--skipcannot be used together--brandfileand--projectfilecannot be used together--transmutevalueand--filevaluecannot be used together
Precedence: More specific options take priority. For example, --yes --transmutevalue will auto-confirm yes/no prompts (via --yes) but answer T to mismatch prompts (via --transmutevalue, overriding --yes's default of N for mismatches). --fatal-prompts only triggers for prompts not already handled by another auto-answer option.
The transmuter's operations are defined in YAML and are fully customizable. There are two layers:
- Built-in defaults β Always loaded from
default_transmute_operations.dart(14 operations covering all standard Android and iOS configuration files) - User overrides β Optional
transmute_operations.yamlin the project root can override, extend, or disable default operations
# Print to terminal
transmute --showdefaultyaml
# Write to a file for customization
transmute --writedefaultyamlFinds regex matches in a file and replaces them with a template string.
- id: android_label
description: "android:label in AndroidManifest.xml"
type: regex_replace
platform: android
file: "android/app/src/main/AndroidManifest.xml"
json_key: appName
regex: 'android:label\s*=\s*"([^"]*(\\"[^"]*)*)"'
replacement: 'android:label="$value"'Extracts the current value via regex group(1), then replaces all occurrences throughout the file.
- id: ios_bundle_identifier
description: "Bundle identifier in project.pbxproj"
type: extract_and_replace
platform: ios
file: "ios/Runner.xcodeproj/project.pbxproj"
json_key: iosBundleIdentifier
fallback_key: packageName
regex: 'PRODUCT_BUNDLE_IDENTIFIER\s*=?\s*(.*);'
replacement: '$value'Moves MainActivity.java/.kt to the correct package directory structure. This is a specialized operation with hardcoded procedural logic.
- id: move_main_activity
description: "Move MainActivity to new package directory"
type: move_activity
platform: android
json_key: packageNameRestores a file to the git HEAD baseline before other operations run. This operation is designed for deterministic transforms on files that are modified by many brands (for example, ios/Runner/Info.plist).
git_restore runs unconditionally and does not require a json_key value.
- id: restore_info_plist_baseline
description: "Restore Info.plist before transmute"
type: git_restore
platform: ios
file: "ios/Runner/Info.plist"Under the hood, transmuter tries git restore <file> first, and falls back to git checkout -- <file> for older git versions.
| Field | Required | Description |
|---|---|---|
id |
Yes | Unique identifier for merge/override matching |
description |
Yes | Human-readable label printed during execution |
type |
Yes | regex_replace, extract_and_replace, move_activity, or git_restore |
platform |
Yes | android, ios, or both (affects logging color) |
file |
For regex/extract/git_restore | Path to the file to modify (relative to project root) |
optional |
No | If true, skip silently when file doesn't exist (default: false) |
json_key |
No for git_restore, Yes otherwise |
The transmute.json key that provides the replacement value |
fallback_key |
No | Fallback transmute.json key if json_key is missing |
regex |
For regex/extract | Regular expression pattern (use single quotes in YAML). To match across lines, use \s* or \r?\n β never a bare \n: on Windows, files written by git restore (e.g. a git_restore baseline) have CRLF endings, and a hardcoded \n will silently never match |
multiline |
No | Enable multiline regex matching (default: false) |
replacement |
For regex/extract | Template string β $value is replaced with the JSON value |
always_run |
No | If true, runs without requiring json_key (implicitly true for git_restore) |
value_is_flag |
No | Marks json_key as an enable-flag rather than a value (see below). Auto-detected when replacement contains no $value |
Some operations don't substitute a value into the file at all β their replacement is fixed text (often empty) and the json_key merely turns the operation on or off. A typical example is removing a block from Info.plist for some brands:
- id: ios_remove_location_always_usage_description
description: "Remove NSLocationAlwaysUsageDescription from Info.plist"
type: regex_replace
platform: ios
file: "ios/Runner/Info.plist"
json_key: iosInfoPlistAlwaysLocationRemoval
multiline: true
regex: '(\n\t\t<key>NSLocationAlwaysUsageDescription</key>\n\t\t<string>[\s\S]*?</string>)'
replacement: ""An operation whose replacement contains no $value is automatically treated as flag-gated (set value_is_flag: false to opt out, or value_is_flag: true to force it). Flag-gated operations behave differently:
- The operation runs when the key is any non-empty value except
false,no,0, oroff(case-insensitive); by convention use"true". - A missing key means "disabled" β
--verifyreports it as an informational skip and never offers to copy the regex match from the file intotransmute.json(the match is content the operation removes, not a configuration value). --check/--verifytreat "pattern no longer matches" as MATCH (the removal has been applied) and "pattern still matches" as MISMATCH (not yet applied). On a mismatch,--verifyoffers only (T) apply the operation or (N) no change β the file value is never written totransmute.json, including with--filevalue.
Create a transmute_operations.yaml in your project root to customize operations. Use --writedefaultyaml to generate a starting point:
transmute --writedefaultyamlThen edit the file. The merge rules are:
- Override β A user operation with the same
idas a default replaces it in-place - Disable β Set
disabled: trueon an operationidto remove it - Extend β Operations with new
idvalues are appended at the end
Note on ordering: all git_restore operations execute in a dedicated first pass before any other operation types, regardless of where they appear in the merged list.
operations:
# Override: change the regex for namespace
- id: build_gradle_kts_namespace
description: "custom namespace in build.gradle.kts"
type: regex_replace
platform: android
file: "android/app/build.gradle.kts"
optional: true
json_key: packageName
regex: 'namespace\s*=\s*"(.*)"'
replacement: 'namespace = "$value"'
# Disable: skip the profile manifest update
- id: profile_manifest_package
disabled: true
# Extend: add a custom operation
- id: custom_splash_config
description: "Update splash screen app title"
type: regex_replace
platform: both
file: "lib/config/splash.dart"
json_key: appName
regex: 'appTitle\s*=\s*"(.*)"'
replacement: 'appTitle = "$value"'
post_switch_operations:
# Override: use a different clean command
clean: "flutter clean && flutter pub cache clean"
# Disable a step
native_splash: disabled
# Add a custom step
build_runner: "dart run build_runner build --delete-conflicting-outputs"When using --switch, a pipeline of shell commands runs automatically after the brand copy. These are defined in the post_switch_operations section of the YAML.
| Step | Command | Platform | Condition |
|---|---|---|---|
transmute_command |
--transmute (internal) |
All | Always |
launcher_icons |
dart run flutter_launcher_icons |
All | Always |
native_splash |
dart run flutter_native_splash:create |
All | Always |
clean |
flutter clean |
All | Always |
ios_remove_derived_data |
rm -rf ~/Library/.../DerivedData/Runner-* |
macOS only | Always |
ios_xcode_reminder |
Prints Xcode reminder message | macOS only | Always |
pub_get |
flutter pub get |
All | Always |
requireflag_flutterfire |
flutterfire configure --yes --overwrite-firebase-options |
All | +flutterfire |
android_requireflag_build |
flutter build apk --target-platform android-arm64 |
Windows/Linux | +build |
ios_requireflag_build |
flutter build ipa |
macOS | +build |
The step name (YAML key) uses prefixes to control behavior:
| Prefix | Effect |
|---|---|
ios_ |
Step only runs on macOS |
android_ |
Step only runs on Windows/Linux |
requireflag_ |
Step only runs when +flagname is on the command line |
| (none) | Step runs on all platforms unconditionally |
Prefixes can combine: ios_requireflag_build runs only on macOS and only when +build is specified.
The transmute_command step name invokes the transmuter internally (no new process spawned). The value specifies which options to use:
post_switch_operations:
transmute_command: "--transmute"Only these options are allowed in the value: --transmute, --yes, --debug, --verbose[=N]. Invalid options cause an error at startup before any work begins.
Shell commands can use $brand_dir which is replaced with the new brand directory path:
post_switch_operations:
copy_apk: "cp build/app/outputs/flutter-apk/app-release.apk $brand_dir/release_builds/"Record a branded release as an annotated git tag pointing at the current
HEAD commit, capturing the brand's version and metadata in the tag message.
Because brand directories are committed and --switch is deterministic, the
pair (commit, brand dir) reproduces the exact built source β so you tag the
clean canonical commit, never a mutated working tree.
This is a cross-platform (Windows/macOS/Linux) replacement for a shell tagging
script: no bash, jq, sed, or date required.
# Tag a release (brand dir is required; platform is prompted if not resolved)
transmute --tagrelease branded/fine_335_1535 --platform ios
# Add one or more note paragraphs to the annotation
transmute --tagrelease branded/fine_335_1535 --platform ios \
--note "Hotfix for iOS 26 crash"
# Create and push in one step
transmute --tagrelease branded/fine_335_1535 --platform ios --push
# Overwrite an existing tag of the same name
transmute --tagrelease branded/fine_335_1535 --platform ios --force
# Preview without creating anything
transmute --tagrelease branded/fine_335_1535 --platform ios --dryrunOptions:
| Option | Description |
|---|---|
--tagrelease <brand_dir> |
Brand directory to tag (required). |
--platform <p> |
Release platform: ios, android, windows, macosx, linux. |
--note "..." |
Note paragraph added to the annotation (repeatable). |
--push |
Push the tag to origin after creating it. |
--force |
Overwrite an existing tag of the same name. |
--dryrun |
Preview the tag name and annotation; create/push nothing. |
Behavior:
- Refuses to run if the working tree is dirty (commit your canonical state
first) or if the tag already exists (use
--force). Under--dryrunthese become warnings and the preview is shown anyway. - The release platform is resolved in this order:
--platformβdefault_platform_by_os(matching the host OS) βdefault_platformβ interactive prompt. With--fatal-prompts, an unresolved platform is a hard error instead of a prompt (useful in CI).
The command ships with general defaults that produce
release/{slug}/{platform}/{version} with zero config. Override or extend them
with a tag_release: block:
tag_release:
tag_template: "release/{slug}/{platform}/{version}" # tag name template
slug_strip_pattern: '_[0-9]+$' # repeatedly stripped from the brand-dir basename
version_field: pubspec_version # transmute.json key supplying {version}
required_files: [transmute.json] # refuse if any are missing in the brand dir
title: "Release: {appName}" # first line of the annotation
default_platform: ios # optional single default platform
default_platform_by_os: "macosx=ios, windows=android" # optional host-OS-keyed defaults
metadata: # ordered annotation lines
- { label: "Brand dir", value: "{brand_dir}" }
- { label: "Version", json_key: pubspec_version }
- { label: "Bundle ID", json_key: iosBundleIdentifier }
- { label: "Dev team", json_key: DEVELOPMENT_TEAM }
- { label: "Shorebird app", file: shorebird.yaml, yaml_key: app_id }
- { label: "Base commit", value: "{commit}" }
- { label: "Flutter", command: ["flutter", "--version"], first_line: true }
- { label: "Tagged by", value: "{git_user}" }
- { label: "Date", value: "{date}" }If you supply a tag_release: block, scalar keys override the defaults
individually; a metadata: list replaces the default list wholesale.
Metadata source types β each metadata entry has a label and exactly one
source:
| Source | Meaning |
|---|---|
value: "{token}" |
Template string of built-in/JSON tokens. |
json_key: <key> |
Value read from the brand's transmute.json. |
file: <f.yaml> + yaml_key: <key> |
A key read from a YAML file in the brand dir. |
command: [...] (+ first_line: true) |
Stdout of a shell command (failure β unknown). |
Built-in tokens: {slug}, {platform}, {version}, {brand_dir},
{commit}, {git_user}, {date}, plus {<any key in transmute.json>} (e.g.
{appName}). A missing token renders as unknown.
A release tag records the base commit and the brand dir, and --switch is
deterministic β so you can recreate the exact built source for any release by
checking out the tag and switching to its brand:
git checkout release/<slug>/<platform>/<version> # e.g. release/fine/ios/2.0.6+22
transmute --switch <brand_dir>
# ...then run your build/release tooling for that platform.The base commit and brand dir to use are both recorded in the tag's annotation
(git show <tag>). When finished, return to your working branch and switch back
to your canonical brand before committing anything.
If your build/release pipeline is, for example, Shorebird, this is also where you would run the matching
shorebird release/shorebird patchfor the tagged<version>. The transmuter only handles the source-reproducingcheckout+--switch; the actual build command is whatever your project uses.
Tag names follow tag_template (default release/{slug}/{platform}/{version}),
so the {slug}/{platform} segments make them easy to filter, and the
annotation holds the full metadata you configured:
git tag -l 'release/*' # all releases
git tag -l 'release/fine/*' # all releases for one slug
git tag -l 'release/*/ios/*' # all iOS releases
git tag -n99 -l 'release/fine/ios/2.0.6+22' # full annotation for one release
git show release/fine/ios/2.0.6+22 # annotation + the commit it points atA typical multi-brand project setup:
my_flutter_app/
βββ transmute.json # Current brand configuration
βββ master_transmute.yaml # File mapping definitions
βββ transmute_operations.yaml # Optional custom operations
βββ pubspec.yaml
βββ lib/
β βββ client/
β βββ config.dart # β swapped per brand (different API endpoints, feature flags)
β βββ theme.dart # β swapped per brand (different colors, fonts, spacing)
βββ android/
β βββ app/
β βββ google-services.json # β swapped per brand (different Firebase project)
βββ ios/
β βββ Runner/
β βββ GoogleService-Info.plist # β swapped per brand
βββ brands/
βββ acme/
β βββ transmute.json
β βββ appicon_square_1024x1024.png
β βββ google-services.json
β βββ GoogleService-Info.plist
β βββ config.dart # Acme's API endpoints, feature flags
β βββ theme.dart # Acme's colors, fonts
β βββ ...
βββ globex/
βββ transmute.json
βββ appicon_square_1024x1024.png
βββ google-services.json
βββ GoogleService-Info.plist
βββ config.dart # Globex's API endpoints, feature flags
βββ theme.dart # Globex's colors, fonts
βββ ...
While regex-based transmute operations are great for changing individual values in config files, many real-world projects need to swap entire files per brand. This is one of the most powerful features of the brand management system.
Common files to swap per brand:
- Dart source files β
config.dart,theme.dart,constants.dart,routes.dartwith brand-specific API endpoints, feature flags, color themes, or navigation - Firebase configs β
google-services.json(Android) andGoogleService-Info.plist(iOS) pointing at different Firebase projects - Image assets β app icons, splash screens, logos, onboarding images
- Platform configs β
flutter_launcher_icons.yaml,flutter_native_splash.yamlwith brand-specific settings - Entitlements / provisioning β iOS entitlements files with different app group or keychain access group identifiers
All of this is managed through master_transmute.yaml β the brand directory
is flat (all files at the top level), and the YAML maps each file to one or
more destinations in the project tree.
This file defines how brand files map to project locations. Place it in your project root.
# Files where the source filename differs from the destination,
# or needs to be copied to multiple locations
file_mappings:
- source: appicon_square_1024x1024.png
destinations:
- android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png
- android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
- assets/images/brand/app_icon.png
# Files where the brand file has the same basename as the destination.
# The brand directory is flat β the basename is used to locate the
# source file, and the full path is the destination in the project.
files:
- transmute.json
- android/app/google-services.json
- ios/Runner/GoogleService-Info.plist
- assets/images/brand/logo.png
- lib/client/config.dart
- lib/client/theme.dart
- flutter_launcher_icons.yaml
- flutter_native_splash.yamlThe brand directory is flat β all source files are in one directory. The files section uses the basename of each path to find the source file in the brand directory.
The combination of whole-file swapping (via master_transmute.yaml) and
regex-based value replacement (via transmute operations) means you can
handle everything from swapping an entire config module to changing a single
string deep in a platform manifest β all as part of the same --switch
command.
# 1. Initial setup: copy brand files into a fresh project
transmute --copy ../brands/acme
# 2. Apply all transmute operations
transmute --transmute
# 3. Check status at any time
transmute --status
# 4. After making project changes, update the brand directory
transmute --update
# 5. Switch to a different brand (updates current brand first)
transmute --switch ../brands/globex +flutterfire
# 6. Quick switch without cleaning
transmute --switch ../brands/acme -clean -pub_getThe transmuter ships with 14 built-in operations. Use --showdefaultyaml to see the full YAML.
| ID | Description | JSON Key |
|---|---|---|
build_gradle_application_id |
applicationId in build.gradle | packageName |
build_gradle_kts_namespace |
namespace in build.gradle.kts | packageName |
build_gradle_kts_application_id |
applicationId in build.gradle.kts | packageName |
main_manifest_package |
package in main AndroidManifest.xml | packageName |
debug_manifest_package |
package in debug AndroidManifest.xml | packageName |
profile_manifest_package |
package in profile AndroidManifest.xml | packageName |
move_main_activity |
Move MainActivity to new package dir | packageName |
android_label |
android:label in AndroidManifest.xml | appName |
android_google_maps_api_key |
Google Maps API key in manifest | androidGoogleMapsSDKApiKey |
| ID | Description | JSON Key |
|---|---|---|
ios_bundle_identifier |
Bundle identifier in project.pbxproj | iosBundleIdentifier β packageName |
ios_display_name_info_plist |
CFBundleDisplayName in Info.plist | iosBundleDisplayName β appName |
ios_display_name_pbxproj |
CFBundleDisplayName in project.pbxproj | iosBundleDisplayName β appName |
ios_google_maps_api_key |
Google Maps API key in AppDelegate.swift | iosGoogleMapsSDKApiKey |
| ID | Description | JSON Key |
|---|---|---|
pubspec_version |
version in pubspec.yaml | pubspec_version |
The
βnotation indicates a fallback:iosBundleIdentifier β packageNamemeans it usesiosBundleIdentifierif present, otherwise falls back topackageName.
Always use --dryrun when trying something new:
transmute --transmute --dryrun# Generate a starting point with all defaults
transmute --writedefaultyaml
# Edit transmute_operations.yaml to add your custom operations
# Then run with your customizations active
transmute --transmute# Quick read-only check
transmute --check
# Interactive fix-up
transmute --verify# Non-interactive brand switch for CI (use project files, apply transmute values)
transmute --switch ../brands/release_brand --projectfile --transmutevalue +build
# Strict CI mode: fail if any unexpected prompt is encountered
transmute --switch ../brands/release_brand --projectfile --transmutevalue --fatal-prompts +build
# Or use --yes for full auto-confirm
transmute --switch ../brands/release_brand --yes +buildThis repository includes an AGENTS.md file β a structured guide
designed for AI coding assistants (Claude Code, GitHub Copilot, Gemini, etc.)
and autonomous agents. It provides the context an AI needs to help you set up
brands, write custom transmute operations, configure master_transmute.yaml,
and run the correct commands β without you having to explain the tool from
scratch each time.
If you use an AI assistant in your workflow, having AGENTS.md in the repo
means it can understand the transmuter's concepts, commands, and pitfalls
out of the box.
Distributed under the MIT license.
Originally forked from flutter_app_rebrand by sarj33t, it no
longer shares much of the original code, but flutter_app_rebrand was the tool that inspired me to create
flutter_app_transmuter in the first place.
- to activate from local copy of repo, allowing you to use 'transmute' command using the current source
dart pub global activate --source path .