Författare: Narek Mailian

  • Building a Feature Flag System with Swift Macros and Property Wrappers

    Feature flags are one of those things that start simple and slowly take over your codebase. You add one, then another, and before you know it you’re maintaining a growing list of keys, default values, and registration boilerplate that all need to stay in sync. Every new flag means touching multiple places — and every touch is a chance to mess something up.

    Swift macros and property wrappers can fix this. Macros let the compiler generate the repetitive registration code, while a property wrapper turns consuming a flag into a single line that’s reactive out of the box. In this post, I’ll walk through building both — a macro that eliminates flag declaration boilerplate, and a property wrapper that eliminates usage boilerplate — and wire them together into a complete feature flag system.

    The Problem: Boilerplate That Scales Linearly

    Here’s what a feature flag setup tends to look like in practice — no frameworks, just raw Swift:

    final class FeatureFlags: ObservableObject {
        static let shared = FeatureFlags()
        
        // Every flag needs a key, a default, and a computed accessor
        private let defaults: [String: Bool] = [
            "greenBackground": false,
            "showRemoteConfigList": true,
            "newOnboarding": false,
            "darkModeOverride": true
        ]
        
        @Published private var serverFlags: [String: Bool] = [:]
        
        func isEnabled(_ key: String) -> Bool {
            serverFlags[key] ?? defaults[key] ?? false
        }
        
        // Convenience accessors — one per flag, manually kept in sync
        var greenBackground: Bool { isEnabled("greenBackground") }
        var showRemoteConfigList: Bool { isEnabled("showRemoteConfigList") }
        var newOnboarding: Bool { isEnabled("newOnboarding") }
        var darkModeOverride: Bool { isEnabled("darkModeOverride") }
        
        // Also need a list of all keys for debugging/admin screens
        static let allKeys = ["greenBackground", "showRemoteConfigList", "newOnboarding", "darkModeOverride"]
        
        func fetchFromServer() {
            // ...
        }
    }

    And to actually consume a flag in SwiftUI, you’d need to observe the whole container as a @StateObject, trigger the fetch manually, and remember which property maps to which flag:

    struct ContentView: View {
        @StateObject private var flags = FeatureFlags.shared
        
        var body: some View {
            VStack {
                Text("Hello")
            }
            .background(flags.greenBackground ? Color.green : Color.gray)
            .onAppear {
                flags.fetchFromServer()
            }
        }
    }

    Every flag lives in three places: the defaults dictionary, a convenience accessor, and the allKeys array. Add a new flag? Touch all three. Rename one? Hope you remembered to update the string key. Forget to add it to allKeys? Your debug screen silently misses it. On top of that, the flag definitions (names and defaults) are tangled up with the fetching logic, the published state, and the convenience accessors — all in one class. At a glance, it’s hard to tell which flags exist and what their defaults are without reading through the entire implementation. The whole thing scales linearly in the worst way — more flags, more noise.

    Now imagine this instead:

    // Define your flags — the macro generates the rest
    @FeatureFlagContainer
    final class FeatureFlags {
        let greenBackground = false
        let showRemoteConfigList = true
    }
    
    // Use them in SwiftUI — the property wrapper handles fetching and reactivity
    struct ContentView: View {
        @FeatureFlag(\.greenBackground) var greenBackground
    
        var body: some View {
            VStack {
                Text("Hello")
            }
            .background(greenBackground ? Color.green : Color.gray)
        }
    }

    That’s it. The macro generates all the FeatureFlagKey boilerplate from simple let declarations, and the property wrapper handles fetching, observing, and defaulting. Two Swift features, each solving half of the problem.

    Let’s build both, starting with the macro.

    Building the Macro

    To create this, start a new Swift Macro package in Xcode (File → New → Package → Swift Macro). First, declare the macro interface:

    @attached(member, names: arbitrary)
    public macro FeatureFlagContainer() = #externalMacro(
        module: "FeatureFlagMacros",
        type: "FeatureFlagsMacro"
    )

    There are two kinds of macros in Swift: freestanding (can go anywhere) and attached (modify the type they’re applied to). Ours is attached — it inserts additional member declarations into the class it decorates. The names: arbitrary parameter tells the compiler we’ll be generating members with names it can’t predict ahead of time.

    Now for the implementation. The macro conforms to MemberMacro and receives the full syntax tree of the annotated type:

    import SwiftSyntax
    import SwiftSyntaxBuilder
    import SwiftSyntaxMacros
    
    public struct FeatureFlagsMacro: MemberMacro {
        public static func expansion(
            of node: AttributeSyntax,
            providingMembersOf declaration: some DeclGroupSyntax,
            conformingTo protocols: [TypeSyntax],
            in context: some MacroExpansionContext
        ) throws -> [DeclSyntax] {
            var flagKeys: [String] = []
            var transformedMembers: [DeclSyntax] = []
    
            for member in declaration.memberBlock.members {
                guard let varDecl = member.decl.as(VariableDeclSyntax.self),
                      varDecl.bindingSpecifier.tokenKind == .keyword(.let),
                      let identifier = varDecl.bindings.first?.pattern.as(IdentifierPatternSyntax.self),
                      let initializer = varDecl.bindings.first?.initializer?.value else {
                    continue
                }
    
                let keyName = identifier.identifier.text
                flagKeys.append(keyName)
    
                transformedMembers.append(
                    DeclSyntax(
                        """
                        public let \(raw: keyName) = FeatureFlagKey(key: "\(raw: keyName)", defaultValue: \(raw: initializer.description))
                        """
                    )
                )
            }
    
            let allCasesFunction = DeclSyntax(
                """
                public func allCases() -> [String: NSObject] {
                    return [
                        \(raw: flagKeys.map { key in
                            "\"\(key)\": \(key).defaultValue.asRemoteConfigValue"
                        }.joined(separator: ",\n"))
                    ]
                }
                """
            )
            transformedMembers.append(allCasesFunction)
    
            let nestedKeysStruct = DeclSyntax(
                """
                public struct Keys {
                    \(raw: transformedMembers.map(\.description).joined(separator: "\n"))
                }
                """
            )
    
            return [nestedKeysStruct]
        }
    }

    Filtering Members

    The guard statement at the top is doing the heavy lifting:

    guard let varDecl = member.decl.as(VariableDeclSyntax.self),
          varDecl.bindingSpecifier.tokenKind == .keyword(.let),
          let identifier = varDecl.bindings.first?.pattern.as(IdentifierPatternSyntax.self),
          let initializer = varDecl.bindings.first?.initializer?.value else {
        continue
    }

    It filters for let declarations with an initializer — exactly the pattern we use to define flags. Anything else (methods, computed properties, vars) gets skipped. We extract both the identifier (the property name) and the initializer value (the default).

    Generating Code

    For each flag, the macro generates a FeatureFlagKey instance using the property name as both the variable name and the string key:

    let keyName = identifier.identifier.text
    flagKeys.append(keyName)
    
    transformedMembers.append(
        DeclSyntax(
            """
            public let \(raw: keyName) = FeatureFlagKey(key: "\(raw: keyName)", defaultValue: \(raw: initializer.description))
            """
        )
    )

    So let greenBackground = false becomes public let greenBackground = FeatureFlagKey(key: "greenBackground", defaultValue: false). The key string is always derived from the property name, which eliminates the mismatch risk entirely.

    The Nested Struct Workaround

    One important limitation of Swift macros: they can only add code, never remove or modify existing code. That means we can’t replace the original let declarations in-place. Instead, we wrap all generated members in a nested Keys struct:

    let nestedKeysStruct = DeclSyntax(
        """
        public struct Keys {
            \(raw: transformedMembers.map(\.description).joined(separator: "\n"))
        }
        """
    )

    This keeps the generated FeatureFlagKey properties separate while still being easily accessible through FeatureFlags.Keys.

    Registering the Plugin

    Finally, register the macro in your package’s main.swift:

    import SwiftCompilerPlugin
    import SwiftSyntaxMacros
    
    @main
    struct MacrosPlugin: CompilerPlugin {
        let providingMacros: [any Macro.Type] = [
            FeatureFlagsMacro.self
        ]
    }

    Making It Reactive with a Property Wrapper

    The macro handles registration, but we also want feature flags to update the UI automatically when their values change at runtime. This is where a property wrapper comes in.

    If you haven’t worked with property wrappers before: the @propertyWrapper attribute tells Swift that a type wraps additional behavior around a property. When you annotate a property with @FeatureFlag, you’re not just storing a boolean — you’re embedding logic to manage that value, like subscribing to updates.

    @propertyWrapper
    class FeatureFlag: ObservableObject {
        private let key: String
        private let defaultValue: Bool
        private var cancellables = Set<AnyCancellable>()
        
        @Published var wrappedValue: Bool
        var projectedValue: FeatureFlag { self }
        
        init(_ keyPath: KeyPath<FeatureFlags, FeatureFlagKey>) {
            self.key = FeatureFlags.shared[keyPath: keyPath].key
            self.defaultValue = FeatureFlags.shared[keyPath: keyPath].defaultValue
            self.wrappedValue = FeatureFlagFetcher.shared.isEnabled(key) ?? defaultValue
            listenForUpdates()
        }
        
        private func listenForUpdates() {
            FeatureFlagFetcher.shared.$serverFlags
                .receive(on: DispatchQueue.main)
                .sink { [weak self] flags in
                    guard let self = self else { return }
                    self.wrappedValue = flags[self.key] ?? self.defaultValue
                }
                .store(in: &cancellables)
        }
    }

    Key Paths for Type-Safe Initialization

    The initializer takes a key path rather than a string:

    init(_ keyPath: KeyPath<FeatureFlags, FeatureFlagKey>) {
        self.key = FeatureFlags.shared[keyPath: keyPath].key
        self.defaultValue = FeatureFlags.shared[keyPath: keyPath].defaultValue
        self.wrappedValue = FeatureFlagFetcher.shared.isEnabled(key) ?? defaultValue
        listenForUpdates()
    }

    A key path in Swift is a type-safe reference to a property. By requiring a KeyPath<FeatureFlags, FeatureFlagKey>, the compiler verifies at build time that the flag you’re referencing actually exists. Usage looks like this:

    @FeatureFlag(\.greenBackground) var greenBackground

    The \. prefix is shorthand for a key path rooted at FeatureFlags. The wrapper then uses it to fetch both the flag’s key string and default value from the shared container.

    The Projected Value

    There’s one more piece worth understanding:

    var projectedValue: FeatureFlag { self }

    In Swift, property wrappers can expose a projected value via the $ prefix. By returning self, we let SwiftUI (or anything else) access the full FeatureFlag instance — not just the raw boolean. This is what makes $greenBackground work as an ObservableObject that SwiftUI can subscribe to.

    When to Apply Flag Changes

    Before we wire everything up, it’s worth pausing on a design decision that our property wrapper makes for us: when do fetched flag values actually take effect? There are three common strategies, each with real trade-offs:

    Fetch before launch. The app waits for fresh flag values before showing any UI. This guarantees consistency — every view renders with the correct flags from the start. The downside is a potentially noticeable delay on the launch screen, especially on slow connections. For apps where showing the wrong state for even a moment is unacceptable, this is the safest bet.

    Fetch in the background, apply immediately. This is what our implementation does. The app launches with defaults, fetches in the background, and updates the UI the moment new values arrive. It’s responsive, but it can cause visible jumps — a button might appear, a color might change, or worse, a feature that was briefly available gets yanked away mid-session. For cosmetic flags this is usually fine. For flags that gate critical flows, it can be jarring or even break user expectations.

    Fetch in the background, apply on next launch. The app fetches fresh values and stores them locally, but only applies them the next time the app starts. No UI jumps, no mid-session surprises. The trade-off is staleness — users always see flag values that are at least one session old. This is the most common approach in production apps, and arguably the safest default for most use cases.

    Our property wrapper uses the second approach because it’s the most interesting to demonstrate — you can actually see the flags update in real time. Adapting it for the third approach is straightforward: instead of subscribing to live updates, read cached values at init time and write fetched values to UserDefaults for the next launch.

    Putting It All Together

    With the macro and property wrapper in place, we need a fetcher to simulate retrieving flags from a server:

    final class FeatureFlagFetcher: ObservableObject {
        static let shared = FeatureFlagFetcher()
        @Published private(set) var serverFlags: [String: Bool] = [:]
        
        func isEnabled(_ key: String) -> Bool? {
            return serverFlags[key]
        }
    
        func fetchFromServer() {
            DispatchQueue.global().asyncAfter(deadline: .now() + 3) {
                DispatchQueue.main.async {
                    self.serverFlags = [
                        "greenBackground": true,
                        "showRemoteConfigList": true
                    ]
                }
            }
        }
    }

    And a SwiftUI view to see everything in action:

    struct ContentView: View {
        @FeatureFlag(\.greenBackground) var greenBackground
        @FeatureFlag(\.showRemoteConfigList) var showRemoteConfigList
        @State private var isShowingFeatureList = false
    
        var body: some View {
            VStack(spacing: 20) {
                Text("Feature Flag Demo")
                    .font(.headline)
                    .foregroundColor(.white)
                            
                if showRemoteConfigList {
                    Button("Show Feature Flags") {
                        isShowingFeatureList.toggle()
                    }
                    .padding()
                    .background(Color.blue)
                    .foregroundColor(.white)
                    .clipShape(Capsule())
                }
            }
            .padding()
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .background(greenBackground ? Color.green : Color.gray)
            .ignoresSafeArea()
            .onAppear {
                FeatureFlagFetcher.shared.fetchFromServer()
            }
            .sheet(isPresented: $isShowingFeatureList) {
                FeatureFlagListView()
            }
        }
    }

    Run the app. For the first few seconds, you’ll see the default state (gray background, no button). Once the simulated fetch completes, the background turns green and the feature flag list button appears — all driven by the property wrapper reacting to the fetcher’s published values.

    Wrapping Up

    By combining a macro for registration, a property wrapper for reactivity, and a simple fetcher for the data source, we’ve built a feature flag system where adding a new flag is a single line of code. No duplicate key strings, no manual array maintenance, no risk of things falling out of sync.

    The macro pattern extends well beyond feature flags — anywhere you have a list of similar declarations with repetitive boilerplate is a good candidate. The key constraint to remember is that macros can only add code, never modify or remove it, which is why the nested struct pattern comes in handy.

    Happy coding!

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