How to migrate an iOS app to Bazel

Posted
Also posted on BuildBuddy Blog

Do you have an iOS app, or really any Apple-based project, that you want to migrate to Bazel? With this guide I’ll show you how to migrate your project, using the Mastodon iOS project as an example.

We will use iOS based rules in the example migration, but similar rules exist in rules_apple for the other Apple platforms, including macOS, watchOS, tvOS, and visionOS.

The completed migration is available in my fork of mastodon-ios. You can follow along with the changes made in the following sections by checking out this commit first. At the end of some sections there will be a link to a new commit that includes the changes mentioned up to that point.

If Bazel is completely new to you, I recommend reading the official quick start guide. That will explain some foundational things that I don’t cover in this guide.

Table of contents

Bootstrapping Bazel

Before we can start to define the structure of our project, we need to get some foundational Bazel setup out of the way.

Bazelisk

There are two main ways to run Bazel: directly or via Bazelisk.

For this migration I’m going to use Bazelisk. I recommend using Bazelisk for multiple reasons:

If you don’t already have Bazelisk installed, I recommend installing it with Homebrew:

$ brew install bazelisk

This will install Bazelisk as bazel. Because of this, when you see bazel in future examples, it’s actually running Bazelisk (which then runs Bazel).

By the way, because we are going to be using newer features of Bazel, make sure your installed version of Bazelisk is at least v1.19.0.

.bazelversion

Now that we have Bazelisk installed, we need to tell it which version of Bazel we want it to download and run for us. We can do that by creating a .bazelversion file at the root of the project:

$ echo '7.0.2' > .bazelversion

MODULE.bazel

Since Bazel can be run in subdirectories of a project, it uses the existence of a repository boundary marker file to designate the root of the project (which Bazel calls a workspace). We’re going to use a MODULE.bazel file for this, since we will eventually have Bazel module dependencies to declare. We can start with an empty file to begin with:

$ touch MODULE.bazel

Verifying

At this point we can verify that we have Bazel and Bazelisk configured properly by running bazel info release:

$ bazel info release
release 7.0.2

At this commit we have Bazel bootstrapped for the Mastodon iOS project.

Defining targets

Now that we have the Bazel project bootstrapped, we can start adding targets to it.

Bazel targets are defined by instances of rules in packages. Packages are defined by BUILD files. First I’ll go over the rules we will use, and then I’ll show how we use them to define our targets.

Rules

I’m not going to cover every rule that you could use in an Apple-based project, but I will cover some of the more popular/interesting ones, even if they won’t all be used in this migration.

swift_library

A swift_library target defines a single Swift static library.

When built directly (ideally though an ios_build_test to ensure it’s in the correct configuration), swift_library produces .swiftmodule and .swiftdoc files. If the swift.enable_library_evolution and swift.emit_swiftinterface features are enabled, it also produces a .swiftinterface file. When depended on by an executable producing target, such as ios_application, it also produces a .a file.

objc_library

An objc_library target defines a single Objective-C static library. Use it instead of cc_library when compiling Objective-C or Objective-C++ code.

When depended on by an executable producing target, such as an ios_application target, objc_library produces a .a file.

Note: If srcs is empty, an objc_library target acts as a collection of headers, defines, include paths, or linkopts, which are propagated to dependent targets.

cc_library

A cc_library target defines a single C or C++ static library. Use it when compiling C or C++ code. While you can use objc_library to compile C or C++ code, using cc_library is more efficient, and is more clear in your intent.

When depended on by an executable producing target, such as an ios_application target, cc_library produces a .a file.

Note: If srcs is empty, a cc_library target acts as a collection of headers, defines, include paths, or linkopts, which are propagated to dependent targets.

experimental_mixed_language_library

An experimental_mixed_language_library target defines an Objective-C and Swift mixed-language static library. Use it for compiling mixed-language modules.

experimental_mixed_language_library is actually a macro that creates a swift_library, an objc_library, and some modulemaps to tie them together.

Note: Due to poor build performance, it is not recommended to have mixed-language modules. It’s recommended to only use this macro as a migration stopgap until you are able to demix them.

apple_intent_library

An apple_intent_library target generates source files for an .intentdefinition file. Use it if you have .intentdefinition resources.

Note: The swift_intent_library and objc_intent_library macros wrap apple_intent_library with swift_library and objc_library targets respectively. Use them instead of apple_intent_library directly if possible.

apple_resource_bundle

An apple_resource_bundle target generates a resource bundle. Use it if you require certain resources to be placed in a named .bundle, instead of directly placed in your top level bundle (e.g. an .app, .framework , or .xctest).

Note: apple_resource_bundle targets need to be listed in data attributes, not deps attributes.

ios_application

An ios_application target generates an application bundle.

Unlike Xcode, Bazel separates compilation and bundling targets. This means that an ios_application target (which is a bundling target) doesn’t list its source files, and instead requires that its primary module be a single static library dependency, such as a swift_library or an objc_library (which is a compilation target).

ios_app_clip

An ios_app_clip target generates an app clip bundle.

ios_app_clip is used nearly identically to ios_application (since app clips are on-demand applications), except that it also needs to be listed in a parent ios_application’s app_clips attribute.

ios_extension

An ios_extension target generates an application extension bundle.

Similar to ios_application, ios_extension defines a bundling target, which means that it doesn’t list its source files, and instead requires that its primary module be a single static library dependency, such as a swift_library or an objc_library (which is a compilation target).

Note: Extensions are listed in the extensions attribute of the containing application instead of the deps attribute.

ios_framework

An ios_framework target causes the static library targets it depends on to be linked and bundled into a dynamic framework bundle, instead of the top-level bundle target they would have ultimately been linked into (e.g. an .app, .framework , or .xctest). These libraries still need to be depended on by your top-level bundle target. See the frameworks rules_apple documentation for more information on how to use this rule.

Since library dependencies have to be listed in deps attributes, regardless if you use dynamic frameworks or not, conditionally setting frameworks on your top-level bundle targets can be an easy way to switch between dynamic and static linking. This can enable workflows such as using dynamic frameworks for dev builds, which has faster incremental linking, and using static linking for release builds, which has faster startup time.[1]

Note: ios_framework is not intended to be used for distribution (i.e. consumed by Xcode). Use ios_dynamic_framework for that.

ios_unit_test

An ios_unit_test target generates a unit testing bundle.

Similar to ios_application, ios_unit_test defines a bundling target, which means that it doesn’t list its source files, and instead requires that its primary module be a single static library dependency, such as a swift_library or an objc_library (which is a compilation target).

ios_ui_test

An ios_ui_test target generates a UI testing bundle.

Similar to ios_application, ios_ui_test defines a bundling target, which means that it doesn’t list its source files, and instead requires that its primary module be a single static library dependency, such as a swift_library or an objc_library (which is a compilation target).

local_provisioning_profile

A local_provisioning_profile target defines a reference to a provisioning file that exists on the user’s machine. Use it when your provisioning profile is not checked into the workspace, such as per-user and/or managed by Xcode profiles.

Translating the Xcode project

When migrating a project that uses Xcode to build, which the Mastodon iOS project does, we have a blueprint in the form of the Xcode project that we can use to guide us on which Bazel targets we need to create. Each Xcode target will map to one or more Bazel targets, mostly formulaically. And since we now know which rules we can use for those Bazel targets, let’s get to translating.

Dependencies

Before talking about how any given target is translated, I wanted to mention how dependencies between targets are handled.

For each target dependency that an Xcode target has, the corresponding Bazel target will have the same dependency listed in one of its dependency attributes. For example, if the Xcode target A depends on targets B and C, then the Bazel target //some:A will have //a/package:B and //another/pkg:C included in its dependency attributes.

If a dependency is on a target that translates to multiple Bazel targets, e.g. an ios_extension and swift_library, then only the top-most target (ios_extension in this example) should be included in one of the Bazel target’s dependency attributes.

If a dependency is on a product defined in a Swift package, then a label of the form @swiftpkg_foo//:A, where A is a product in the Foo Swift package, should be included in one of the Bazel target’s dependency attributes. I’ll give more details on how Swift packages are handled with Bazel in a later section.

apple_support, rules_swift, rules_apple, and bazel_skylib

Before using rules from some of the core rulesets we need to add dependencies on apple_support, rules_swift, rules_apple, and bazel_skylib. We do that by adding bazel_deps to our MODULE.bazel file:

MODULE.bazel
bazel_dep(name = "apple_support", version = "1.14.0")
bazel_dep(name = "rules_swift", version = "1.16.0", repo_name = "build_bazel_rules_swift")
bazel_dep(name = "rules_apple", version = "3.3.0", repo_name = "build_bazel_rules_apple")
bazel_dep(name = "bazel_skylib", version = "1.5.0")

Static libraries

While the Mastodon iOS project doesn’t define any static library targets in its Xcode project (though it implicitly does through its use of Swift packages, which are discussed later), I wanted to mention that they are translated 1:1 with static library rules.

Provisioning profiles

If you have your provisioning profile stored in your workspace, you can reference it directly with the provisioning_profile attribute. If your provisioning profile is instead stored in the default Xcode location (i.e. ~/Library/MobileDevice/Provisioning Profiles), you’ll want to use the local_provisioning_profile rule to reference it. Finally, if you use Xcode’s “Automatic Code Signing” feature, you’ll want to use the xcode_provisioning_profile rule as well.

Swift packages (SwiftPM)

An Xcode project can declare dependencies on Swift packages, both remote and local. The easy way to handle those dependencies with Bazel is by using rules_swift_package_manager.

rules_swift_package_manager requires all Swift package dependencies to be declared in a Package.swift file. So any dependencies declared in an Xcode project need to be added to a new Package.swift file. I say “new” since there might already exist Package.swift files that declare local packages, and if you don’t want to migrate those packages to Bazel (which we aren’t going to for this migration), you’ll need to declare those packages as local dependencies in a new Package.swift file.[2]

Here is the new Package.swift file that includes the dependencies that were referenced in the Xcode project:

Package.swift
// swift-tools-version:5.7

import PackageDescription

let package = Package(
    name: "Mastodon-iOS",
    defaultLocalization: "en",
    platforms: [
        .iOS(.v16),
    ],
    dependencies: [
        .package(name: "ArkanaKeys", path: "Dependencies/ArkanaKeys"),
        .package(name: "MastodonSDK", path: "MastodonSDK"),
        .package(
            url: "https://github.com/Bearologics/LightChart.git",
            branch: "master"
        ),
        .package(
            url: "https://github.com/jdg/MBProgressHUD.git",
            from: "1.2.0"
        ),
        .package(
            url: "https://github.com/tid-kijyun/Kanna.git",
            from: "5.2.7"
        ),
    ]
)

Currently rules_swift_package_manager requires you to use Gazelle in order to create a swift_deps_index.json file.[3] So we need to add a bazel_dep on gazelle in addition to rules_swift_package_manager in our MODULE.bazel file. We also need to add a use_repo stanza for the Swift packages we directly depend on (though if you run the //:swift_update_pkgs Gazelle target, which we will define shortly, it will add the required stanza for you):

MODULE.bazel
bazel_dep(name = "rules_swift_package_manager", version = "0.28.0")
bazel_dep(name = "gazelle", version = "0.35.0")

# swift_deps START
swift_deps = use_extension(
    "@rules_swift_package_manager//:extensions.bzl",
    "swift_deps",
)
swift_deps.from_file(
    deps_index = "//:swift_deps_index.json",
)
use_repo(
    swift_deps,
    "swiftpkg_arkanakeys",
    "swiftpkg_arkanakeysinterfaces",
    "swiftpkg_kanna",
    "swiftpkg_lightchart",
    "swiftpkg_mastodonsdk",
    "swiftpkg_mbprogresshud",
)
# swift_deps END

And now we can define our Gazelle targets in a root BUILD file:

BUILD
load("@gazelle//:def.bzl", "gazelle", "gazelle_binary")
load(
  "@rules_swift_package_manager//swiftpkg:defs.bzl",
  "swift_update_packages",
)

# - Gazelle

# Ignore the `.build` folder that is created by running Swift package manager
# commands. The Swift Gazelle plugin executes some Swift package manager
# commands to resolve external dependencies. This results in a `.build` file
# being created.
# NOTE: Swift package manager is not used to build any of the external packages.
# The `.build` directory should be ignored. Be sure to configure your source
# control to ignore it (i.e., add it to your `.gitignore`).
# gazelle:exclude .build

# This declaration builds a Gazelle binary that incorporates all of the Gazelle
# plugins for the languages that you use in your workspace. In this example, we
# are only listing the Gazelle plugin for Swift from
# rules_swift_package_manager.
gazelle_binary(
    name = "gazelle_bin",
    languages = [
        "@rules_swift_package_manager//gazelle",
    ],
)

# This macro defines two targets: `swift_update_pkgs` and
# `swift_update_pkgs_to_latest`.
#
# The `swift_update_pkgs` target should be run whenever the list of external
# dependencies is updated in the `Package.swift`. Running this target will
# populate the `swift_deps.bzl` with `swift_package` declarations for all of the
# direct and transitive Swift packages that your project uses.
#
# The `swift_update_pkgs_to_latest` target should be run when you want to
# update your Swift dependencies to their latest eligible version.
swift_update_packages(
    name = "swift_update_pkgs",
    gazelle = ":gazelle_bin",
    generate_swift_deps_for_workspace = False,
    patches_yaml = "patches/swiftpkgs.yaml",
    update_bzlmod_stanzas = True,
)

# This target updates the Bazel build files for your project. Run this target
# whenever you add or remove source files from your project.
gazelle(
    name = "update_build_files",
    gazelle = ":gazelle_bin",
)

Note: Because of how rules_apple handles app icon assets (though possibly because of a bug), we need to patch the Tabman package to not include test assets. Our patches are defined in the patches/swiftpkgs.yaml file.

Also, in our .bazelrc file we added something to work around a current issue with rules_swift_package_manager and sandboxing.

To generate the swift_deps_index.json file, and/or update the MODULE.bazel use_repo stanza, run the //:swift_update_pkgs target:

$ bazel run //:swift_update_pkgs
INFO: Invocation ID: f2427bde-865b-43b3-bd3c-6a8489387f00
INFO: Analyzed target //:swift_update_pkgs (148 packages loaded, 11707 targets configured).
INFO: Found 1 target...
Target //:swift_update_pkgs up-to-date:
  bazel-bin/swift_update_pkgs-runner.bash
  bazel-bin/swift_update_pkgs
INFO: Elapsed time: 1.724s, Critical Path: 0.07s
INFO: 1 process: 1 internal.
INFO: Build completed successfully, 1 total action
INFO: Running command line: bazel-bin/swift_update_pkgs

At this commit Swift packages have been integrated for the Mastodon iOS project.

App extensions

App extensions, such as the MastodonIntent, NotificationService, ShareActionExtension, OpenInActionExtension, and WidgetExtension targets, are represented by a combination of an *_extension and static library rule. And the static library target will need to have the -application-extension (for Swift) or -fapplication-extension (for Objective-C) copt set.

You will need to set the infoplists and entitlements attributes to your Info.plist and .entitlements files, the bundle_id attribute to the value of the PRODUCT_BUNDLE_IDENTIFIER build setting, and the minimum_os_version attribute to the value of IPHONEOS_DEPLOYMENT_TARGET build setting.

Note: rules_apple processes Info.plist and .entitlements files in a slightly different manner than Xcode. In Xcode, you have build settings such as PRODUCT_BUNDLE_IDENTIFIER, CURRENT_PROJECT_VERSION, and PRODUCT_MODULE_NAME. And when it processes an Info.plist, it substitutes references to those build settings with their resolved value. rules_apple doesn’t use build settings, so it’s not able to do the same substitutions.

There are some attributes on bundling rules that rules_apple will substitute in place of a build setting, such as bundle_id for PRODUCT_BUNDLE_IDENTIFIER, but for the most part when using Bazel you’ll need to remove these build setting references from Info.plist and .entitlements files, or use a custom rule to expand those values for you. I decided to use the bazel_skylib expand_template rule to allow the build setting references to stay in the Info.plist files.

Since the Mastodon iOS project doesn’t use resource bundles, resources will be referenced directly with the data attribute of the static library target for the primary module.

With that in mind I added BUILD files for the app extension targets:

WidgetExtension/BUILD
load("@bazel_skylib//rules:expand_template.bzl", "expand_template")
load("@build_bazel_rules_apple//apple:ios.bzl", "ios_extension")
load("@build_bazel_rules_apple//apple:resources.bzl", "apple_intent_library")
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")

ios_extension(
    name = "WidgetExtension",
    bundle_id = "org.joinmastodon.app.WidgetExtension",
    entitlements = "WidgetExtension.entitlements",
    families = [
        "ipad",
        "iphone",
    ],
    infoplists = [":InfoPlist"],
    minimum_os_version = "16.0",
    resources = glob(
        [
            "**/*.lproj/**",
            "**/*.js",
            "**/*.xcassets/**",
        ],
        exclude = [".*"],
    ),
    visibility = ["//visibility:public"],
    deps = [":WidgetExtension.library"],
)

swift_library(
    name = "WidgetExtension.library",
    srcs = glob(["**/*.swift"]) + [":WidgetExtension.intent"],
    module_name = "WidgetExtension",
    deps = [
        "@swiftpkg_lightchart//:LightChart",
        "@swiftpkg_mastodonsdk//:MastodonSDKDynamic",
    ],
)

apple_intent_library(
    name = "WidgetExtension.intent",
    src = "Base.lproj/WidgetExtension.intentdefinition",
    language = "Swift",
    visibility = ["//visibility:public"],
)

expand_template(
    name = "InfoPlist",
    out = "Bazel.Info.plist",
    substitutions = {
        "$(CURRENT_PROJECT_VERSION)": "1",
        "$(MARKETING_VERSION)": "2024.3",
    },
    template = "Info.plist",
)

Note: We are using glob to collect our source files. This requires that none of the source files in the workspace are dead/unused, which can sometimes happen when using an Xcode project since it doesn’t have to reference all files in a directory. It also requires that all source files for a module live under that module’s directory. Best practice is to share code via modules instead of having multiple modules reference the same source file.

We also created and used an apple_intent_library target for the WidgetExtension.intentdefinition file. It has increased visibility since it’s also used by the Mastodon application target.

At this commit app extensions have been translated for the Mastodon iOS project.

Applications

Applications, such as the Mastodon target, are represented by a combination of an *_application and static library rule. Since applications are bundle targets, they are handled very similar to app extension targets, which means we need to apply the same translations in regards to Info.plist files and build settings.

With that in mind I added a BUILD file for the Mastodon target:

Mastodon/BUILD
load("@bazel_skylib//rules:expand_template.bzl", "expand_template")
load("@build_bazel_rules_apple//apple:ios.bzl", "ios_application")
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")

ios_application(
    name = "Mastodon",
    bundle_id = "org.joinmastodon.app",
    entitlements = "Mastodon.entitlements",
    extensions = [
        "//MastodonIntent",
        "//NotificationService",
        "//OpenInActionExtension",
        "//ShareActionExtension",
        "//WidgetExtension",
    ],
    families = [
        "ipad",
        "iphone",
    ],
    infoplists = [":InfoPlist"],
    minimum_os_version = "16.0",
    resources = glob(
        [
            "Resources/**",
            "Supporting Files/**",
        ],
        exclude = [
            ".*",
            "Resources/Preview Assets.xcassets",
        ],
    ),
    visibility = ["//visibility:public"],
    deps = [":Mastodon.library"],
)

swift_library(
    name = "Mastodon.library",
    srcs = glob(["**/*.swift"]) + [
        "//MastodonIntent:Intents.intent",
        "//WidgetExtension:WidgetExtension.intent",
    ],
    module_name = "Mastodon",
    visibility = ["//MastodonTests:__pkg__"],
    deps = [
        "@swiftpkg_kanna//:Kanna",
        "@swiftpkg_mastodonsdk//:MastodonSDKDynamic",
        "@swiftpkg_mbprogresshud//:MBProgressHUD",
    ],
)

expand_template(
    name = "InfoPlist",
    out = "Bazel.Info.plist",
    substitutions = {
        "$(CURRENT_PROJECT_VERSION)": "1",
        "$(MARKETING_VERSION)": "2024.3",
    },
    template = "Info.plist",
)

Note: The visibility of the Mastodon target was increased to allow it to be a test host for tests.

At this commit the iOS app has been translated for the Mastodon iOS project.

Tests

Tests, such as the MastodonTests and MastodonUITests targets, are represented by a combination of a *_unit_test/*_ui_test and static library rule. Since tests are bundle targets, they are handled very similar to application targets, which means we need to apply the same translations in regards to Info.plist files and build settings.

If a unit test has a host application, or a UI test has a target application, the test_host attribute needs to be set to the corresponding *_application Bazel target. In the case of unit tests, if the Allow testing Host Application APIs checkbox is unchecked, test_host_is_bundle_loader needs to be set to False.

With that in mind I added BUILD files for the MastodonTests and MastodonUITests targets:

MastodonTests/BUILD
load("@build_bazel_rules_apple//apple:ios.bzl", "ios_unit_test")
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")

ios_unit_test(
    name = "MastodonTests",
    bundle_id = "org.joinmastodon.MastodonTests",
    minimum_os_version = "16.0",
    test_host = "//Mastodon:Mastodon",
    deps = [":MastodonTests.library"],
)

swift_library(
    name = "MastodonTests.library",
    srcs = glob(["**/*.swift"]),
    module_name = "MastodonTests",
    testonly = True,
    deps = [
        "@swiftpkg_mastodonsdk//:MastodonSDKDynamic",
        "//Mastodon:Mastodon.library",
    ],
)
MastodonUITests/BUILD
load("@build_bazel_rules_apple//apple:ios.bzl", "ios_ui_test")
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")

ios_ui_test(
    name = "MastodonUITests",
    bundle_id = "org.joinmastodon.MastodonUITests",
    minimum_os_version = "16.0",
    test_host = "//Mastodon:Mastodon",
    deps = [":MastodonUITests.library"],
)

swift_library(
    name = "MastodonUITests.library",
    srcs = glob(["**/*.swift"]),
    module_name = "MastodonUITests",
    testonly = True,
)

At this commit the tests have been translated for the Mastodon iOS project.

Frameworks

Since we are using rules_swift_package_manager for the MastodonSDK target, we don’t have any framework targets to define. If we did though, we would use the ios_framework rule to define a bundle similar to our application or app extensions, and reference the framework with the frameworks attribute. This is in addition to listing dependencies in the deps attribute for any static library targets that happen to be bundled in the framework.

Codegen

Bazel actions can’t modify source files in the workspace. So in order to use code generation with Bazel you need to use a genrule or a custom rule to generate files, which you then depend on in your srcs attributes.

The Mastodon iOS project uses Sourcery and SwiftGen to modify source files in-place, which it then checks into source control. In order to allow building the project with either xcodebuild or Bazel, we will leave code generation as an external process.

Integrating with Xcode

If we were performing this migration more than a year ago this section would be a lot larger, and could probably be its own post. Thankfully since the start of last year we have a fast, stable, and mature community backed solution in the form of rules_xcodeproj.

xcodeproj

To use rules_xcodeproj, we add a bazel_dep for it to our MODULE.bazel file, and define an xcodeproj target for the project in the root BUILD file:

MODULE.bazel
bazel_dep(name = "rules_xcodeproj", version = "1.16.0")
BUILD
load("@rules_xcodeproj//xcodeproj:defs.bzl", "top_level_target", "xcodeproj")

xcodeproj(
    name = "xcodeproj",
    generation_mode = "incremental",
    project_name = "MastodonBazel",
    top_level_targets = [
        top_level_target(
            "//Mastodon:Mastodon",
            target_environments = ["simulator", "device"],
        ),
        "//MastodonTests:MastodonTests",
        "//MastodonUITests:MastodonUITests",
    ],
)

Note: Any targets listed in xcodeproj.top_level_targets will need to grant visibility to rules_xcodeproj. They can do so by having "@rules_xcodeproj//xcodeproj:generated" in their visibility attribute.

At this commit rules_xcodeproj has been integrated into the Mastodon iOS project.

Generating the Xcode project

We can now generate a Bazel integrated Xcode project by running a single command:

$ bazel run //:xcodeproj
INFO: Analyzed target //:xcodeproj (1 packages loaded, 1 target configured).
INFO: Found 1 target...
Target //:xcodeproj up-to-date:
  bazel-bin/xcodeproj-runner.sh
INFO: Elapsed time: 0.089s, Critical Path: 0.00s
INFO: 3 processes: 3 internal.
INFO: Build completed successfully, 3 total actions
INFO: Running command line: bazel-bin/xcodeproj-runner.sh

Generating "MastodonBazel.xcodeproj"
INFO: Analyzed target @@rules_xcodeproj~1.16.0~internal~rules_xcodeproj_generated//generator/xcodeproj:xcodeproj (1 packages loaded, 1 target configured).
INFO: Found 1 target...
INFO: Elapsed time: 0.308s, Critical Path: 0.07s
INFO: 4 processes: 2 internal, 2 local.
INFO: Build completed successfully, 4 total actions
INFO: Running command line: /private/var/tmp/_bazel_brentley/b406c5544781724b8a84c3c6fa8dad13/rules_xcodeproj.noindex/build_output_base/execroot/_main/bazel-out/darwin_arm64-dbg/bin/external/rules_xcodeproj~1.16.0~internal~rules_xcodeproj_generated/generator/xcodeproj/xcodeproj-installer.sh --xcodeproj_bazelrc /private/var/tmp/_bazel_brentley/b406c5544781724b8a84c3c6fa8dad13/execroot/_main/bazel-out/darwin_arm64-fastbuild/bin/xcodeproj-runner.sh.runfiles/_main/xcodeproj.bazelrc --extra_flags_bazelrc /private/var/tmp/_bazel_brentley/b406c5544781724b8a84c3c6fa8dad13/execroot/_main/bazel-out/darwin_arm64-fastbuild/bin/xcodeproj-runner.sh.runfiles/_main/xcodeproj-extra-flags.bazelrc --bazel_path /Users/brentley/Library/Caches/bazelisk/downloads/sha256/93772ce53afbe2282d0d727137a19c835eaa6f328964d02024bf3c234993bf7b/bin/bazel --execution_root /private/var/tmp/_bazel_brentley/b406c5544781724b8a84c3c6fa8dad13/execroot/_main
Updated project at "MastodonBazel.xcodeproj"

If you open the generated project (i.e. xed MastodonBazel.xcodeproj) and run the Mastodon scheme, it should build, install to the simulator, and launch successfully:

The rules_xcodeproj generated project open in Xcode and running in the Simulator

Ensuring Bazel sees the correct Xcode version

Currently there are additional steps that need to be taken in order for Bazel to correctly detect when Xcode versions change. I recommend reading the “Xcode Version Selection and Invalidation” section of the rules_apple docs for details on what and why. If you only build your project inside of Xcode, or with the rules_xcodeproj command-line API, there is nothing else you need to do to manage your Xcode version with Bazel, because it applies these steps for you.

Generating release archives

Currently the Archive action in Xcode doesn’t work when using rules_xcodeproj. This means that you’ll need to generate release archives on the command line directly with Bazel, and then upload them to the App Store.

For example, you can generate the .ipa archive for the Mastodon app by running the following command:

$ bazel build //Mastodon --ios_multi_cpus=arm64

And then you can manually upload the archive. Or you can use the xcarchive rule from rules_apple to generate an .xcarchive bundle, and then use Xcode to upload the archive.

Leveraging remote caching and remote execution

One of the main reasons to use Bazel, instead of another build system such as xcodebuild, is to leverage its remote caching and execution capabilities. For a detailed overview on both of those capabilities, I recommend reading my post on the subject.

For this post I’ll detail the steps I would take for any Bazel migration, starting from no caching and ending with both caching and remote execution.

Disk cache

Bazel supports a form of “remote cache” that uses the local filesystem instead of a remote server. It’s called the “disk cache” and is enabled with the --disk_cache flag.

There are pros and cons to using the disk cache:

With those in mind, we are going to start out by enabling the disk cache for all use cases in the .bazelrc file:

.bazelrc
# Cache

common --disk_cache=~/bazel_disk_cache

Remote cache

Using a remote cache allows for multiple machines to benefit from the work performed by a single machine. It can be enabled with the --remote_cache flag.

The disk cache can be used concurrently with a remote cache, but I only recommend that setup for local development. That’s because normally the remote cache will only accept writes from CI, so the disk cache can still provide a benefit to developers, but CI will rarely see benefits, and usually degradation, by also using the disk cache.

We can adjust the .bazelrc file to enable a remote cache, disable uploads for local development, and disable the disk cache on CI (by using a dedicated config):

.bazelrc
common --remote_cache=grpcs://remote.buildbuddy.io
common --noremote_upload_local_results
common:upload --disk_cache=
common:upload --remote_upload_local_results

# CI

common:ci --config=upload

We used BuildBuddy’s Remote Cache because it’s free for personal or open source projects. We also used the --[no]remote_upload_local_results flag to control uploading to the remote cache, but in your project you probably want to use some sort of authentication and authorization method to limit this instead (e.g. BuildBuddy API keys).

At this commit a remote cache has been integrated into the Mastodon iOS project.

Debugging cache hits

There might be times when you get fewer cache hits than you are expecting. If that’s the case, I recommend reading “Debugging Remote Cache Hits” in the Bazel docs, which has information that will aid you in finding the problem.

rules_xcodeproj cache warming

rules_xcodeproj most likely builds your targets with a slightly different configuration than when you build them on the command line. Because of this, rules_xcodeproj offers a command-line API that allows you to build targets under the same configuration that it uses. This allows us to build targets on CI in a way that will populate the remote cache with blobs that developers will then get cache hits on.

We can add a new config to the .bazelrc file to ensure that we don’t get CI specific modifications:

.bazelrc
# Cache warming

common:warming --config=upload
common:warming --remote_download_minimal

Here is the cache warming command we would run on CI:

$ bazel run //:xcodeproj -- --generator_output_groups=all_targets 'build --config=warming'
INFO: Analyzed target //:xcodeproj (0 packages loaded, 0 targets configured).
INFO: Found 1 target...
Target //:xcodeproj up-to-date:
  bazel-bin/xcodeproj-runner.sh
INFO: Elapsed time: 0.145s, Critical Path: 0.00s
INFO: 1 process: 1 internal.
INFO: Build completed successfully, 1 total action
INFO: Running command line: bazel-bin/xcodeproj-runner.sh '--generator_output_groups=all_targets' 'build --config=warming'

INFO: Analyzed target @@rules_xcodeproj~override~internal~rules_xcodeproj_generated//generator/xcodeproj:xcodeproj (3 packages loaded, 515 targets configured).
INFO: Found 1 target...
INFO: Elapsed time: 4.002s, Critical Path: 3.48s
INFO: 13 processes: 5 internal, 7 local, 1 worker.
INFO: Build completed successfully, 13 total actions

Note: --generator_output_groups=all_targets will build every target, in every target_environment, that could be built by the project generated by //:xcodeproj.

If you don’t want to build every target (e.g. you don’t want to cache tests or device builds), you can adjust the top_level_targets, focused_targets, and/or unfocused_targets attributes to reflect the subset of targets that you want to build. You can do this by defining additional xcodeproj targets, or by adjusting the existing //:xcodeproj target before running the cache warming command (and then throwing away the modifications after).

Build event service

Now that we have a remote cache enabled, we should also enable uploading events to a Build Event Service (BES). Using BES gives you greater insight into your builds, asynchronously to when the build actually happened (which can be immensely useful for CI builds).

Here are some benefits of using BES:

We can adjust the .bazelrc file to upload to a BES endpoint:

.bazelrc
common --bes_backend=grpcs://remote.buildbuddy.io
common --bes_results_url=https://app.buildbuddy.io/invocation/
common --bes_upload_mode=nowait_for_upload_complete

We used BuildBuddy’s Build and Test UI because it’s free for personal or open source projects.

Now when we perform a build there will be a link to the build results UI, which when opened shows detailed information about the build performed:

$ bazel build //Mastodon
INFO: Invocation ID: e69835b2-672f-4a4f-bb00-c73ca8850859
INFO: Streaming build results to: https://app.buildbuddy.io/invocation/e69835b2-672f-4a4f-bb00-c73ca8850859
INFO: Analyzed target //Mastodon:Mastodon (0 packages loaded, 0 targets configured).
INFO: Found 1 target...
Target //Mastodon:Mastodon up-to-date:
  bazel-bin/Mastodon/Mastodon.ipa
INFO: Elapsed time: 0.253s, Critical Path: 0.00s
INFO: 1 process: 1 internal.
INFO: Build completed successfully, 1 total action
INFO:
INFO: Streaming build results to: https://app.buildbuddy.io/invocation/e69835b2-672f-4a4f-bb00-c73ca8850859
BuildBuddy Build Results UI for a build of the //Mastodon target

At this commit a BES endpoint has been integrated into the Mastodon iOS project.

Remote execution

Using a remote execution service allows Bazel to run actions on an external cluster of executors. Because of the inherint latency involved in that (e.g. network transfer and staging of remote input trees), the remote executors need to be a fair bit faster than your local machine, or your build needs to be “wide enough”, for a remote build to be faster than a local one.

Enabling remote execution after you have remote caching set up is as simple as setting the --remote_executor flag and ensuring that remote is the first strategy set in --spawn_strategy. (which it is by default). You might also need to set some default platform properties with the --remote_default_exec_properties flag to ensure that actions are routed to the right executors.

We can adjust the .bazelrc file to have a dedicated remote config which sets --remote_executor and some default platform properties for an Apple silicon Mac:

.bazelrc
# Remote exectuion

common:remote --config=upload
common:remote --remote_executor=grpcs://remote.buildbuddy.io
common:remote --remote_default_exec_properties=OSFamily=darwin
common:remote --remote_default_exec_properties=Arch=arm64

Note: BuildBuddy’s anonymous and personal tiers don’t include Mac remote executors. Contact them if you want to use Mac remote execution.

At this commit remote execution has been integrated into the Mastodon iOS project.

Optimizing

Now that everything is building, and we have a functioning remote cache, it’s time to optimize the build further. Ideally we wouldn’t need to do anything further at this point, but currently Bazel doesn’t have the best default settings for all projects. And even if it did, the structure of the build graph itself has a large impact on build performance.

Optimal Bazel settings

If you build with Xcode, rules_xcodeproj will apply a lot of optimal settings for you. I’ll still list them here, along with some settings it doesn’t set for you, in case you need to build outside of Xcode.

At this commit we applied all of the flags discussed in the following sections to the Mastodon iOS project.

Cacheability

The oso_prefix_is_pwd, relative_ast_path, and remap_xcode_path features in apple_support, and the swift.cacheable_swiftmodules feature in rules_swift, remove absolute paths from binaries produced by rules_apple and rules_swift. It’s highly recommended that you use these features if using a remote cache, otherwise you might get a low cache hit rate on anything involving those binaries. As long as you are using a version of apple_support and rules_swift that is at least 1.5.0, these features are enabled by default. Otherwise, you can enable them with --features=oso_prefix_is_pwd,relative_ast_path,remap_xcode_path,swift.cacheable_swiftmodules.

Note: When using these features you may need to provide additional information to lldb for debugging to work properly.

Local performance

Bazel’s implementation of sandboxing on macOS is slow.[6] Because of that I recommend, at least for non-release builds, disabling sandboxing. This can be achieved by setting the --spawn_strategy flag to remote,worker,local, and setting the --noworker_sandboxing flag.

The swift.use_global_module_cache feature in rules_swift sets the Swift module cache to a fixed location, allowing it to be reused by multiple compilations. This can result in up to 10 times faster compilations. As long as you are using a version of rules_swift that is at least 1.5.0, this feature is enabled by default. Otherwise, you can enable it with --features=swift.use_global_module_cache.

Bazel calculates digests of all input and output files, in order to determine when actions need to be rerun. By default it uses the SHA-256 hash function to calculate these digests. Since version 6.4.0 Bazel now supports using the BLAKE3 hash function instead, by setting the --digest_function startup flag to blake3. For builds with large binaries, which iOS apps usually have, using BLAKE3 can result in up to 5 times faster digest calculations. And since the actions producing these large binaries (e.g. linking) are normally on the critical path, this can speed up incremental development.

Note: If you use remote capabilities, such as remote caching, remote execution, or a build event service, they also have to support the digest function you set with --digest_function. In case you were wondering, all of BuildBuddy’s products (i.e. Build and Test UI, Remote Build Cache, and Remote Build Execution) support BLAKE3 digests 😊.

Speaking of digests, Bazel keeps an in-memory cache of file digests, since computing the digests is expensive. The default number of entries in this cache is set to 50,000, which can be too low for some projects. I recommend adjusting this up by setting the --cache_computed_file_digests flag to 500000. This number seems to have a minimal impact on the amount of memory that the Bazel server retains.

Remote performance

There are a few settings I recommend adjusting for optimal remote caching, remote execution, and build event service usage.

The --experimental_remote_cache_async flag allows execution of actions that don’t depend on the output of a given action to start before the outputs of that action finish uploading. That’s a lot of words to say that uploading of outputs becomes as asynchronous as possible, while still blocking dependent actions from starting. The bazel command will also block at the end of a build while it waits for all uploads to finish.

Similarly, setting the --bes_upload_mode flag to nowait_for_upload_complete will prevent the bazel command from blocking at the end of a build while it uploads to a build event service. If another build starts while uploads are still happening in the background, that build will wait to start until the previous uploads finish. You can have previous uploads cancelled rather than delay a build by using a value of fully_async instead.

Speaking of BES related flags, if your build event service isn’t ResultStore (e.g. BuildBuddy), you should set --nolegacy_important_outputs to reduce the size of the uploaded events.

The --remote_cache_compression flag causes Bazel to compress uploads and decompress downloads with the zstd algorithm. This reduces the average number of bytes transferred by around 70%, which for some projects has resulted in a 45% faster overall build time.

Some types of actions we shouldn’t remotely cache or build. We can tell Bazel about that by using the --modify_execution_info flag. The rules_apple documentation has a nice explaination on the suggested value for this flag.

If using remote execution, you might have to set some additional platform properties for optimal performance. In the case of BuildBudddy’s product, I recommend setting these properties:

Finally, when using remote caching or remote execution, you’ll want to use an increased value for the --jobs flag. A higher value will result in more concurrent downloads and/or remote actions being executed. You’ll need to experiement to determine the correct value for you, since different projects or host machines can benefit from, or potentially suffer from[7], different values.

Modularization

To get the most benefit out of Bazel’s incremental compilation support, you need to have your project split into many modules. If your project is only a couple large modules, then when you make changes most of that code will need to be recompiled. But if your project is made up of many small modules, then when you make changes a smaller portion of the code will need to be recompiled.

You can think of modularization (the process of splitting large modules into smaller modules) as one way to optimize your build graph for Bazel. Another would be adjusting your dependencies such that your build graph is “wide” instead of “deep”. This allows for more parallel compilation, and when using remote execution the wider the build graph is the better.

Next steps

At this point you should have a well functioning iOS Bazel project. Congratulations 🎉!

Next steps from here depend on what is the highest priority for you and your team. You could continue focusing on modularization, profile and optimize build performance, or maybe look into custom macros and rules to make your specific workflows more efficient or easier to set up.

In you need any help, the #apple and #rules_xcodeproj channels in the Bazel Slack workspace are a great place to ask questions.


  1. When rules_apple gains support for mergable libraries this type of workflow will be a lot easier to support. ↩︎

  2. This is an area that I think can be improved upon in rules_swift_package_manager. For example, if you are only using a Package.swift file, without an Xcode project, I think it would be nice to be able to use rules_swift_package_manager to build the declared products in it, without having to migrate those to Bazel manually. ↩︎

  3. There is a plan that before rules_swift_package_manager reaches 1.0.0 it will remove the need for the swift_deps_index.json file, and the need to use Gazelle with it. ↩︎

  4. A “blob” is an REAPI term for artifacts that are stored in a cache. ↩︎

  5. Bazel issue #5139. ↩︎

  6. Bazel issue #8320. ↩︎

  7. Bazel issue #21954. ↩︎