How to Drastically Speed Up Multi-App Builds with Fastlane Resigning

In software development, slow build times aren’t just frustrating—they delay releases, disrupt feedback loops, and drive up CI/CD costs. With many CI/CD services charging by the hour, every unnecessary build adds up fast.

If your team manages multiple app flavors—whether for white-label apps, feature-limited versions, or targeting different backend environments like development, staging, and production—you might be stuck managing separate schemes and targets. Each flavor often requires a full build, even when the only changes are minor, like switching API endpoints and app icons.

But what if you could avoid those repetitive builds altogether?

In this post, I’ll show you how to dramatically cut build times using Fastlane’s resigning process to reuse a single binary across multiple flavors. We’ll walk through a practical example and explore how to replace traditional compile-time flags with runtime checks.

The Challenge of Multiple Binaries

Managing multiple app versions often leads to unnecessary build duplication. A single pull request might trigger several full builds despite only minor differences like API endpoints or branding.

Sometimes this is justified, especially when dealing with sensitive data or flavor-specific resources. But more often, the variations between builds are minimal and don’t warrant a full rebuild.

So, how do you eliminate redundant builds without sacrificing flexibility?

Resigning to the Rescue

Before diving into the resigning process, it’s important to understand what app signing is.

When you build an iOS app, it must be digitally signed before it can run on a device or be submitted to the App Store. This signing process ensures that:

• The app is verified as coming from a trusted developer (i.e you).

• The app has the correct entitlements and permissions to use system resources.

• Most importantly for this scenario: The app has not been modified or tampered with after it was built.

This is done using an Apple-issued signing certificate and a provisioning profile, both of which are managed through Apple’s Developer Portal. Without proper signing, Apple will reject the app.

What is an IPA File?

An IPA (iOS App Store Package) is the packaged and signed version of your iOS app. In simple terms, it’s just a zip file—try uncompressing it and you’ll see!

This package contains everything needed to install and run your app on an iOS device, including:

• The app binary (the executable code)

• Assets like icons, images, and launch screens

• The app’s Info.plist (metadata)

• The app’s digital signature

Why Resigning Matters

To distribute an app that’s been modified from its original IPA, you must re-sign it with a valid provisioning profile and signing identity. This “resigning” process is essential because any changes you make to the IPA’s contents invalidate the original digital signature. Fastlane, an open-source platform designed to automate building, testing, and releasing iOS and Android apps, simplifies this process. Its resign tool allows you to adapt one build for multiple app flavors without a full rebuild. To use resign as part of a Fastlane lane, follow this example:

desc "Resign App (minimal lane)"
lane :resign do |options|
  ipa_path = options[:ipa_path]
  flavor = options[:flavor]

  # Resign the IPA without modifying it
  resign(
    ipa: ipa_path,
    signing_identity: "Apple Distribution: XXX (XXXXXXXXXX)", # Replace with your certificate
    provisioning_profile: {
      "com.example.#{flavor}.myapp" => "path/to/profile",
    },
    bundle_id: "com.example.#{flavor}.myapp",
    use_app_entitlements: true
  )
end

That’s it! To run this lane, just supply flavor option, and run the lane for all your flavors. For more customization options—such as updating the display name, version number, build number, or entitlements—check out the official Fastlane documentation for the resign action.

Bonus tip: If managing paths manually for the provisioning profiles feels clunky, I wrote some helper functions to automatically select the correct provisioning profiles. However, this approach might not work perfectly in every setup. It’s never failed to work for me, but your mileage may vary. This method assumes your provisioning profiles directory (~/Library/MobileDevice/Provisioning Profiles/) is well-maintained and free of expired or duplicate profiles. Be sure to replace placeholder values (like your Team ID) with the correct ones for your account, and make sure Nokogiri (a Ruby gem for parsing XML/HTML) is installed (gem install nokogiri)

fastlane_require 'nokogiri'

desc "Resign App (minimal lane)"
lane :resign do |options|
  ipa_path = options[:ipa_path]
  flavor = options[:flavor]

  # Resign the IPA with the appropriate provisioning profiles and signing identity
  resign(
    ipa: ipa_path,
    signing_identity: "Apple Distribution: XXX (XXXXXXXXXX)", # Replace with your certificate
    provisioning_profile: all_bundle_profile_paths(flavor),   # Automatically selects the correct profiles
    bundle_id: all_bundle_identifiers(flavor)[0],             # Sets the main bundle identifier
    use_app_entitlements: true
  )
end

# Extracts the value for a given key from the provisioning profile's XML content
def profile_value(profile_contents, name)
  # Trim the content to start from the XML declaration (avoids parsing issues)
  profile_contents = profile_contents.slice(profile_contents.index('<?'), profile_contents.length)
  
  # Parse the profile's XML content
  doc = Nokogiri.XML(profile_contents)
  
  # Find the value for the specified key in the XML structure
  return doc.xpath("//key[text()='#{name}']")[0].next_element.text
end

# Finds all provisioning profiles matching the given bundle identifier
def all_profiles_for_bundle_identifier(bundle_identifier)
  directory = "#{Dir.home}/Library/MobileDevice/Provisioning Profiles/"
  profile_glob = "#{directory}*.mobileprovision"

  profiles = []
  
  # Iterate over all .mobileprovision files
  Dir.glob(profile_glob).each do |filename|
    contents = File.open(filename).read
    
    # Extract the application identifier and remove the team prefix
    identifier = profile_value(contents, "application-identifier").delete_prefix('XXXXXXXX.') # Replace with your team identifier

    # Add profile to the list if it matches the bundle identifier
    profiles << filename if identifier == bundle_identifier
  end
  profiles
end

# Returns the path of the correct provisioning profile for the given bundle identifier
def profile_path_for_bundle_identifier(name)
  profiles = all_profiles_for_bundle_identifier(name)

  if profiles.size > 1
    # Error if multiple profiles are found for the same bundle ID
    UI.error("Multiple provisioning profiles found for identifier: #{name}")
    raise "Multiple provisioning profiles found for identifier: #{name}"
  elsif profiles.empty?
    # Error if no matching profile is found
    UI.error("No provisioning profiles found for identifier: #{name}")
    return nil
  else
    # Return the single matching profile
    return profiles.first
  end
end

# Lists all bundle identifiers for the main app and its extensions
def all_bundle_identifiers(flavor)
  [
    "com.example.#{flavor}.myapp",        # Main app bundle ID
    "com.example.#{flavor}.myapp.widget", # Widget extension bundle ID
  ]
end

# Maps each bundle identifier to its corresponding provisioning profile path
def all_bundle_profile_paths(flavor)
  # Creates a hash: { bundle_id => profile_path }
  Hash[all_bundle_identifiers(flavor).collect { |identifier| [identifier, profile_path_for_bundle_identifier(identifier)] }]
end

Customizing Your IPA: Modifying Assets Before Resigning

While resigning alone is enough to distribute the IPA, you probably need to modify its contents for different builds. For instance, you may want to change the app icons, the launch screen, or modify configuration files. Since the resign action doesn’t support this, you need to extract the app package, update the necessary assets, repackage, and then re-sign. Here’s a more involved example:

desc "Resign App (Resign, update app icon and launch image)"
lane :resign_app do |options|
  ipa_path = options[:ipa_path]
  filename = File.basename(ipa_path)
  flavor = options[:flavor]

  # Create a temporary directory for processing the IPA
  tmp_dir = Dir.mktmpdir
  UI.message("Temporary directory created: #{tmp_dir}")

  # Extract the original .ipa file into the temporary directory
  Zip::File.open(ipa_path) do |zip_file|
    zip_file.each do |file|
      file.extract(File.join(tmp_dir, file.name))
    end
  end

  # Locate the app bundle and Info.plist within the extracted IPA
  # Make sure to replace "MyApp.app" with your actual app bundle name. 
  # This is typically the app's display name inside the Payload directory.
  app_dir = File.join(tmp_dir, "Payload", "MyApp.app")
  plist_path = File.join(app_dir, "Info.plist")

  # Set the app icon name based on the flavor provided
  app_icon_name = "AppIcon-#{flavor}"
  production_app_icon_name = "AppIcon-production"  # Fallback if specific icon is missing
  icon_path = File.join(app_dir, "#{app_icon_name}60x60@2x.png")

  # Check if the specific flavor's app icon exists; if not, use the production icon
  if !File.exist?(icon_path)
    UI.message("Icon for #{flavor} not found. Falling back to production app icon.")
    app_icon_name = production_app_icon_name
  else
    UI.message("Icon for #{flavor} found.")
  end

  # Update the app icon references in Info.plist
  sh("plutil -replace CFBundleIcons.CFBundlePrimaryIcon.CFBundleIconName -string '#{app_icon_name}' #{plist_path}")
  sh("plutil -replace CFBundleIcons.CFBundlePrimaryIcon.CFBundleIconFiles -json '[\"#{app_icon_name}60x60@2x.png\", \"#{app_icon_name}76x76@2x.png\"]' #{plist_path}")
  sh("plutil -replace CFBundleIcons~ipad.CFBundlePrimaryIcon.CFBundleIconName -string '#{app_icon_name}' #{plist_path}")
  sh("plutil -replace CFBundleIcons~ipad.CFBundlePrimaryIcon.CFBundleIconFiles -json '[\"#{app_icon_name}60x60@2x~ipad.png\", \"#{app_icon_name}76x76@2x~ipad.png\"]' #{plist_path}")

  # Update the launch screen storyboard based on the flavor
  flavor_capitalized = flavor.capitalize
  launch_screen_storyboard_name = "LaunchScreen#{flavor_capitalized}"
  sh("plutil -replace UILaunchStoryboardName -string '#{launch_screen_storyboard_name}' #{plist_path}")

  # Repackage the modified app directory back into an .ipa
  # 'ditto' is a macOS command-line utility used for copying files and creating archives.
  # - The '-c' flag tells 'ditto' to create an archive (compress the files).
  # - The '-k' flag specifies that the archive should be in ZIP format, which is required for IPA files.
  # - '--sequesterRsrc' preserves resource forks and metadata.
  sh("ditto -ck --sequesterRsrc #{tmp_dir} #{ipa_path}")

  # Clean up the temporary directory after processing
  FileUtils.rm_rf(tmp_dir)

  # Finally, re-sign the IPA with the correct provisioning profile and signing identity
  resign(
    ipa: ipa_path,
    signing_identity: "Apple Distribution: XXX (XXXXXXXXXX)",  # Replace with your certificate
    provisioning_profile: all_bundle_profile_paths(flavor),
    bundle_id: all_bundle_identifiers(flavor),
    use_app_entitlements: true
  )
end

What’s Happening Here?

1. Extraction:

The lane creates a temporary working directory and extracts the IPA file into it. This provides access to the app bundle for modification, so you can change the icons, plists etc.

2. Icon Selection and Launch Screen Selection:

The script checks if an icon exists for the specified flavor. If it doesn’t, it defaults to the production icon. This allows your single binary to adapt its look depending on runtime conditions. In this example, we’ve already added all icons to the asset catalog (.xcassets).

Note: In the extracted IPA, the ’.xcassets’ catalog is compiled into a ’.car’ file. This format makes it harder to directly inspect or modify icons compared to how they’re organized in Xcode. Consider using a tool like AssetCatalogTinkerer to view asset catalog contents.

3. Metadata Updates:

Various plutil (a tool to modify .plist files) commands update the app’s Info.plist so that icons and launch screens reflect the chosen flavor.

Note: The Info.plist inside an IPA may look different from how it appears in Xcode.

4. Repackaging and Resigning:

The modified directory is compressed back into an IPA.

5. Resigning

Fastlane’s resign action re-signs the app using the appropriate provisioning profile and signing identity. This single-step resigning process is much faster than a full rebuild.

From Build Time Flags to Runtime Checks

That covers the resigning process! Now you can easily create multiple versions of your app—each with its own icons, launch screens, and bundle identifiers—all without needing separate builds.

But as mentioned earlier, visual changes are only part of the equation. You likely want different functionality or behavior across these app versions. So, how can you handle that without creating separate builds?

Many teams rely on build-time flags to manage configuration, which forces them to maintain distinct builds for each flavor. For example:

#if DEBUG
let apiEndpoint = "https://dev.api.example.com"
#else
let apiEndpoint = "https://api.example.com"
#endif

However, since the resigning method doesn’t actually rebuild your app, these checks won’t have any impact. Instead, you’ll need to perform these checks at runtime. There are several ways to handle this, such as checking the provisioning profile used, adding a configuration file before resigning and reading its value, or—probably the easiest method—just inspecting the bundle identifier:

let bundleID = Bundle.main.bundleIdentifier ?? ""
let apiEndpoint: String

if bundleID.contains("develop") {
    apiEndpoint = "https://dev.api.example.com"
} else {
    apiEndpoint = "https://api.example.com"
}

That’s it! You now have an app that looks and behaves differently across multiple environments—all achieved at a fraction of the build time. By building once and customizing through resigning, you can streamline your workflow and deploy faster than ever.

Conclusion

Advantages of Resigning

Single Binary for All Flavors:

With runtime checks, you compile one binary and then adjust its behavior by resigning it and modifying the bundle identifier. This one-binary approach significantly reduces build times, simplifies your CI/CD pipeline, and allows for faster releases—once QA approves a build, you can instantly resign and deploy the exact same version without rebuilding.

• Consistent Testing and Production Builds:

By resigning the same binary for all environments, you ensure that what’s tested is exactly what gets released. This reduces the risk of last-minute bugs or inconsistencies between QA, staging, and production.

Drawbacks of Resigning

• Risk of Sensitive Information Leakage:

Sharing a single binary across all environments can increase the risk of exposing debug information or development features in production. For instance, given the above example, someone snooping through the binaries would easily be able to find the development endpoint in the production builds. If this is an issue, consider either obfuscating these in code, or putting them in configuration files you can easily modify before resigning.

This approach is particularly risky for demo or trial versions. Even if you strip out unnecessary assets before resigning, the underlying code—including debug tools, experimental features, and internal logic—still remains. For demos, it’s often better to create a separate, stripped-down build rather than relying on modifying a production IPA.

• Larger App Size:

Including all flavor-specific code and assets in a single binary can result in a larger app size. If each flavor of your app has unique resources, bundling them all together can unnecessarily bloat the app. Manually removing unused assets before resigning can help, but it won’t eliminate embedded code that isn’t needed for every environment.

Is Resigning Right for Your Workflow?

Resigning isn’t a one-size-fits-all solution, but for many teams managing multiple app flavors with few differences between them, it can be a game-changer.

I’ve successfully implemented this approach in real-world projects, and it’s helped teams save time and resources. If you’re curious about how this could work for your app or need guidance getting started, feel free to reach out—I’m always happy to help!

Happy building and faster shipping!

Kommentarer

Lämna ett svar

Din e-postadress kommer inte publiceras. Obligatoriska fält är märkta *