Etikett: Objective-C

  • 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!