Stamping iOS Builds with Bazel

Stamping is the act of embedding build metadata into the product that we ship to customers. It can help with issue diagnosis, analytics, and so on. Conveniently, Bazel offers us a first-class solution, and it is very easy to take advantage of it in the context of iOS apps.

Workspace status script

The first step in enabling stamping is to create a workspace status script. For example, we can create a script that emits the current Git commit hash:

#!/usr/bin/env bash

set -eu -o pipefail

echo "STABLE_GIT_COMMIT $(git rev-parse HEAD)"

Now we need to tell Bazel to execute the script:

--workspace_status_command=./tools/workspace_status.sh

Reading the value at build time

For iOS apps, or really any Apple platform app, it is usually best to embed this data in a plist file so we can read it at runtime. First, we use a genrule to read the workspace status data and materialize a plist file:

genrule(
    name = "commit_plist",
    outs = ["Commit.plist"],
    cmd = """
commit="$$(sed -n 's/^STABLE_GIT_COMMIT //p' bazel-out/stable-status.txt)"
plutil -convert xml1 -o "$@" - <<EOF
{
    "GIT_COMMIT": "$${commit}"
}
EOF
""",
    stamp = True,
)

From there, it is just a matter of adding this target to the infoplists attribute of any Apple platform application target, like ios_application. Because rules_apple performs plist merging, this value will end up in the final Info.plist file that we ship in the app bundle.

A word on the stamp attribute

Notice the stamp = True attribute on the genrule? That is what allows the genrule action to access bazel-out/stable-status.txt and bazel-out/volatile-status.txt.

Without it, the action should not rely on those files being present. In this example, the important part is stamping the genrule that materializes the plist.

This is separate from the stamp attribute you may see on Apple rules like ios_application, where stamping controls whether build information is encoded into the binary. For this plist-based approach, we do not need to rely on link stamping at the application target level.

Reading the value at runtime

Because the value ends up in Info.plist, we can read it at runtime through the Bundle / NSBundle API.

Conclusion

There you have it: an easy way to stamp iOS builds. I hope it helps you discover and fix bugs in production more easily.

Making Developer Tools Available Through Bazel

Traditionally, when setting up a developer machine, instructions include something like “install the following tools using Homebrew”. What if we could always have tools available without asking developers to install anything but Bazel?

This is easily achievable with Bazel since it gives us a way to download and execute binaries. Before diving into the implementation, let’s first explore the downsides of asking developers to install tools on their own.

Problems with Homebrew for developer tools

brew install …

When developing on macOS, the “default” package manager is Homebrew, so we install tools like linters and formatters using it. However, it is not great for versioning in this use case. By default, we usually end up installing whatever version Homebrew currently resolves, unless we specifically do extra work to avoid that.

This is the first problem: we can’t expect people to ensure that they have exactly the same version of a tool as everybody else, especially if the organization is large.

Distributing tools

Just like we can’t easily enforce versions, we also can’t easily enforce tool replacement. Say that we built an internal linter and we want everybody to use it. What is the distribution mechanism? Perhaps an internal Brew formula? It works, but it is tedious to set up and maintain.

Using Bazel and rules_multitool

There have been a couple of community posts about rules_multitool, and I want to give my take on it.

How it works

This ruleset provides a convenient way to tell Bazel to download a binary and expose it as a target under the @multitool//tools/{TOOL_NAME} label. It takes in multitool.lock.json files and uses information from the lockfile to invoke Bazel repository rules, download the specified binary, and expose it as a runnable tool.

Setting it up

  1. Declare the dependency in MODULE.bazel as described here.

  2. Call its module extension from MODULE.bazel:

multitool = use_extension("@rules_multitool//multitool:extension.bzl", "multitool")
multitool.hub(lockfile = "//tools:multitool.lock.json")
use_repo(multitool, "multitool")

Of course, you can put your multitool.lock.json wherever you like. I tend to keep it under the tools/ package.

  1. Add multitool.lock.json and give it some tools to work with, e.g.:
{
    "$schema": "https://raw.githubusercontent.com/theoremlp/rules_multitool/main/lockfile.schema.json",
    "bb": {
        "binaries": [
            {
                "kind": "file",
                "url": "https://github.com/buildbuddy-io/bazel/releases/download/5.0.350/bazel-5.0.350-linux-x86_64",
                "sha256": "d14e6a240dc5e8bc3ebb625ff7c139ba8e380f1440f9e2f60e9c1d7850d012c9",
                "os": "linux",
                "cpu": "x86_64"
            },
            {
                "kind": "file",
                "url": "https://github.com/buildbuddy-io/bazel/releases/download/5.0.350/bazel-5.0.350-darwin-arm64",
                "sha256": "f16cc54449eb62ee65ac7ec3b45d7bce7922225a3c004925cbb25982faa8a9cd",
                "os": "macos",
                "cpu": "arm64"
            }
        ]
    },
    "yq": {
        "binaries": [
            {
                "kind": "file",
                "url": "https://github.com/mikefarah/yq/releases/download/v4.52.4/yq_linux_arm64",
                "sha256": "4c2cc022a129be5cc1187959bb4b09bebc7fb543c5837b93001c68f97ce39a5d",
                "os": "linux",
                "cpu": "arm64"
            },
            {
                "kind": "file",
                "url": "https://github.com/mikefarah/yq/releases/download/v4.52.4/yq_linux_amd64",
                "sha256": "0c4d965ea944b64b8fddaf7f27779ee3034e5693263786506ccd1c120f184e8c",
                "os": "linux",
                "cpu": "x86_64"
            },
            {
                "kind": "file",
                "url": "https://github.com/mikefarah/yq/releases/download/v4.52.4/yq_darwin_arm64",
                "sha256": "6bfa43a439936644d63c70308832390c8838290d064970eaada216219c218a13",
                "os": "macos",
                "cpu": "arm64"
            }
        ]
    }
}

This is it. You can now execute bazel run @multitool//tools/yq or bazel run @multitool//tools/bb, and Bazel will download and execute them just fine.

Making it more convenient

Typing out the label from above will quickly get tedious. What if we could instead run the yq tool as easily as ./tools/yq?

To achieve that, we could do the following trick:

  1. Create a script at tools/_run_tool.sh with the following content:
#!/usr/bin/env bash

target="@multitool//tools/$(basename "$0")"

bazel run \
    --run_in_cwd \
    --noshow_progress \
    --show_result=0 \
    --ui_event_filters=-info \
    "$target" -- "$@"
  1. Create a symlink for every tool with its name, e.g. a yq symlink that links to tools/_run_tool.sh.

That will expand $(basename "$0") in tools/_run_tool.sh to the name of the symlink and allow you to execute ./tools/yq. Or, if you put the symlink at the root, it gets even more convenient: ./yq.

Conclusion

I think the trick with symlinks is quite powerful, and you don’t really have to use rules_multitool if you don’t want to. The same idea can work with any Bazel target that exposes a runnable tool.

Overall, I like this setup very much because it requires almost nothing from developers. They can just use the tools provided to them while not even thinking about versions.

Avoiding .DS_Store Cache Misses in Bazel

It is well known that macOS Finder .DS_Store files should never be checked in to a repo, or leave the single machine for that matter.

Fairly recently, I noticed that a lot of my iOS resource processing actions were missing the cache for seemingly no reason. That is, until I looked at the Bazel action inputs. There, I noticed that every action that missed the cache had an extra input. Of course, it was the .DS_Store file.

The problem

The problem popped up because of the act of balancing developer convenience and build correctness. Given the following glob pattern:

resources = glob(["Assets.xcassets/**"]),

we allow engineers to freely add or remove files in an iOS asset catalog without needing to constantly modify the list in the BUILD.bazel file.

This, of course, means that .DS_Store files can get picked up if the engineer ever opened a Finder window at the given path. One might say that rules_apple should take care of this. However, that’s easier said than done, since asset catalogs can host many different resource types. Plus, Apple might extend the list of accepted resources at any time, which would require a rules_apple release just to add a file extension to some list.

The solution

The solution to this problem is quite simple: just make a macro for the glob() function:

def safe_glob(include, **kwargs):
    exclude_pattern = kwargs.pop("exclude", []) + ["**/.DS_Store"]
    return native.glob(
        include = include,
        exclude = exclude_pattern,
        **kwargs
    )

Now just load this symbol and use it instead of plain glob(...).

Conclusion

A neat trick to get around the fact that neither .bazelignore nor REPO.bazel solve this problem. I bet similar annoying files exist on Linux as well as Windows.

External Repo File Checks In Bazel 9

In a quest to speed up Bazel builds we tend to pick every available low-hanging fruit once somebody discovers it. One of those used to be telling Bazel not to check external repos for file changes, since that can take a while in a dependency-heavy repo.

Prior Art

Historically we used --noexperimental_check_external_repository_files to skip checks for files in external repositories. That flag still exists in Bazel (source), and bazelrc-preset.bzl still sets it (source).

Bazel 9 gained the repo contents cache via --repo_contents_cache. Cacheable external repos can now be served out of that cache.

That matters because Bazel does not treat repo-contents-cache-backed files as the old EXTERNAL_REPO case. In the source they are tracked as EXTERNAL_OTHER instead. Bazel 9 also added --experimental_check_external_other_files to control checks for those paths..

Conclusion

If you have repo contents cache enabled and your goal is the old “don’t spend time stat’ing external repos on no-op builds” behavior, you likely want both:

  • --noexperimental_check_external_repository_files
  • --experimental_check_external_other_files=false

The old flag still matters for repos that are not served out of the repo contents cache. The new flag matters for cache-backed repos.

If repo contents cache is disabled, --experimental_check_external_other_files=false can still help with those broader EXTERNAL_OTHER checks, but it does not replace the old external-repository flag.

Cleaning up old Bazel patterns

From time to time, it is worth cleaning up old Bazel stuff in your repositories. This is especially useful before a major Bazel upgrade, because it reduces the amount of migration noise you need to deal with. Most of these cleanups are not difficult, but they make the codebase a little easier to deal with.

The suggestions below are relevant if you are on Bazel 8.1.0 or newer.

Sets

Starting with Bazel 8.1, Starlark has native support for sets, which removes the need to use sets from bazel_skylib.

So instead of:

sets.make([1, 2, 3])

you can write:

set([1, 2, 3])

Native sets support the usual set algebra, such as union, intersection, difference, and symmetric difference, so this should cover most use cases where you previously reached for bazel_skylib.

Remove function_transition_allowlist when creating transitions

The conventional wisdom used to be that you needed to create a private _allowlist_function_transition attribute for Starlark transitions to work.

That is no longer necessary in modern Bazel versions, so you can remove:

"_allowlist_function_transition": attr.label(
    default = "@bazel_tools//tools/allowlists/function_transition_allowlist",
),

repo_name is usually no longer worth keeping

Historically, many repositories used reverse-DNS-style names for external dependencies because that was the common WORKSPACE convention. With Bzlmod, the module name is usually the better default.

For example:

bazel_dep(name = "rules_swift", version = "3.6.1", repo_name = "build_bazel_rules_swift")

can become:

bazel_dep(name = "rules_swift", version = "3.6.1")

This makes labels and load statements shorter and easier to write by hand.

Just make sure you update any remaining references to the old apparent repository name, such as @build_bazel_rules_swift, before removing repo_name.

Start using REPO.bazel

I already wrote about this in my article about dropping .bazelignore and in suppressing warnings in external Swift repositories, so I encourage reading those if you want more details.

The short version is that REPO.bazel gives you a better place to express repository-wide behavior. It marks a repository boundary and lets you set repository-level attributes in a way that fits better with modern Bazel.

compatibility_level is a no-op

If you are a rules author, do not spend time tuning compatibility_level. Starting with Bazel 8.6.0 and 9.1.0, both compatibility_level and max_compatibility_level are no-ops.

This makes me extremely happy because this thing often created more pain for users than it solved. If you introduce a breaking change, it is better to provide clear error messages and an actionable migration path instead of relying on Bazel module version selection to protect users.

A word about flags

In every major Bazel version, there are flags that get removed, become no-ops, or get flipped.

I do not recommend tracking all of that manually. There is a good chance you will miss something, or sometimes get it wrong. The better approach is to use bazelrc-preset.bzl, which applies version-appropriate flags for the Bazel version you are using.

Conclusion

There is probably more cleanup work that I am missing. However, these are low-hanging improvements that are usually easy to apply and easy to review.

Running Multiple Bazel Targets in a Single Invocation

There are many instances where it would be really convenient to run multiple targets at once. By default, Bazel will not execute all targets even if you pass multiple ones:

bazel run //:lint //:format

In this case, only one of them would be executed.

Enter rules_multirun

rules_multirun is a set of rules that helps with running multiple targets either sequentially or in parallel. It is developed and maintained by Keith Smiley.

Running multiple targets

It is extremely easy to get started. First, load the multirun rule and use it like this:

load("@rules_multirun//:defs.bzl", "multirun")

multirun(
    name = "xcodeproj",
    testonly = True,
    commands = ["//apps/app1:xcodeproj", "//apps/app2:xcodeproj"],
    jobs = 0,
)

Here, I used the multirun rule to create a single runnable target that generates Xcode projects for two of my apps:

bazel run //:xcodeproj

Execution modes

The jobs attribute specifies whether targets should run sequentially or in parallel. The default value is 1, which means that targets will run one after the other. In the example above, I explicitly set it to 0 to make sure both Xcode projects are generated in parallel.

This is something that needs to be decided on a case-by-case basis, since parallel execution might not be a good fit for tools that modify files.

Why testonly

This is typically not required, but given the specifics of rules_xcodeproj and my project setup, I need to pass it in this scenario.

That is because xcodeproj passes testonly = True as soon as you add test targets, and it does that to satisfy Bazel’s restriction that non-test targets cannot depend on test-only targets.

So, like I said, this is typically not needed, but you might run into it, so I figured it was worth explaining.

Other rules from rules_multirun

rules_multirun is a set of rules, not just the multirun rule. There is a rule for configuring individual targets and commands, rules for executing targets with transitions, and more.

It is best to consult the rules_multirun docs on GitHub for the full list of available options.

Conclusion

This is a ruleset that I find extremely convenient in my daily work, and I tend to strive to optimize that last mile of developer experience whenever I can.

One thing I intentionally changed: your intro said Bazel executes only the “first” target, but then the example said only format runs. I made it “only one of them” to avoid the contradiction.

Suppressing Warnings in External Swift Dependencies with Bazel

It’s very common to want to apply some Bazel feature only to your first-party repo while omitting external dependencies.

A common case in the Swift world is suppressing warnings for external dependencies brought in by rules_swift_package_manager, since we usually can’t do much about third-party code. There are countless other examples too, like treating warnings as errors for our own code while avoiding that for third-party deps.

REPO.bazel to the rescue

I wrote about REPO.bazel in an earlier article, where I explained how to replace .bazelignore with glob semantics.

For the use cases described in the intro of this article, REPO.bazel is extremely useful. It lets us apply Bazel features, which I’ve also written about before, only to our own repo.

Suppressing warnings in external Swift libraries

To achieve this, we need to do two things.

First, suppress warnings globally in .bazelrc:

# Suppress Swift warnings
common                --features=swift.suppress_warnings
common                --host_features=swift.suppress_warnings

# Suppress clang warnings
common                --features=suppress_warnings
common                --host_features=suppress_warnings

Then, disable those features for our first-party repo using the repo(...) function in REPO.bazel. The repo(...) function accepts the same arguments as package(...):

repo(
    features = [
        "-swift.suppress_warnings",
        "-suppress_warnings",
    ],
)

Conclusion

And that’s really it. A neat trick to have in your toolbox.

I also want to make it clear that I wasn’t aware of this trick until a fellow Apple rules maintainer, Aaron Sky, shared it in the Bazel Slack workspace.

Hot Reloading a Bazel-Based iOS App with InjectionNext

When working on a medium to large iOS app, it can be daunting to constantly rebuild and manually go through app screens just to test your changes. Yes, Xcode previews exist, but in my experience, they can be slow on larger projects. They also require real code in the preview setup, which can be tricky to get right if you use dependency injection, since the code that registers all the dependencies probably will not run, often leading to crashes.

Enter InjectionNext

InjectionNext is an app that uses the -interposable linker feature to dynamically swap classes so that changes are reflected without rebuilding the app.

To set it up in a Bazel-based iOS project, I recommend the following:

  1. Integrate the InjectionNext Swift package using rules_swift_package_manager.

  2. Make sure to set the -interposable linker flag in debug mode only on your ios_application target:

linkopts = select({
    # InjectionNext hot reload needs debug app symbols to stay
    # interposable so injected dylibs can rebind calls to replacement
    # implementations instead of always hitting the original image.
    "//:debug": ["-interposable"],
    "//conditions:default": [],
}),
  1. Similar to the linker flag, make sure that InjectionNext is linked to your app only in debug mode on the ios_application target:
deps = [":App.library"] + select({
    "//:debug": ["@swiftpkg_injectionnext//:InjectionNext"],
    "//conditions:default": [],
}),
  1. Finally, you need the InjectionNext macOS app. Launch it, then click the option to launch Xcode from there.

NOTE: Please use the version I linked above or newer, because this is the version where my patch to make it work with rules_xcodeproj landed.

Further Source Changes Needed

Unfortunately, this is still not enough. To make everything work, you need to add @objc func injected() to every UIKit view or view controller where you call functions like setNeedsLayout() and layoutIfNeeded().

Of course, doing that manually for every view is tedious. The solution is either to integrate Inject, a Swift package that does this for you and also handles SwiftUI, or to write your own Swift macro, such as @HotReloadable, which you apply to the relevant types and which generates this code for you in debug mode.

Conclusion

I know this can seem like a lot of work, but I firmly believe that it quickly starts paying dividends as soon as you start iterating on your app, since it saves so much time.

NOTE: To make the integration with rules_swift_package_manager work, you need a release that includes my fix for collecting .s files.

A Practical Introduction to Bazel Persistent Workers

Typically, Bazel rules execute actions that usually correspond to tool processes on the host OS. Sometimes this behavior can incur startup costs, like bootstrapping a JVM or initializing a compiler. To work around that, Bazel has the concept of persistent workers.

A persistent worker is essentially a long-lived process that accepts work requests and responds with work responses. Imagine a process that keeps a compiler alive and dispatches sources to compile without paying the startup cost every time.

Creating a rule that leverages workers

Because this is a fairly advanced concept in Bazel, and usually only rule authors deal with it, I tried to come up with a simple example that demonstrates it.

An uppercase rule

We will write a rule that simply uppercases the text in a given file. To begin, we need to meet a few requirements. The first one is adding dependencies in our MODULE.bazel:

bazel_dep(name = "swift_argument_parser", version = "1.7.1")
bazel_dep(name = "rules_swift", version = "3.6.1")

These will come into play a bit later.

Creating a rule

Like I said, this is a simple rule, but the code may look a bit scary at first. Create uppercase.bzl at the root of the directory:

def _uppercase_impl(ctx):
    out = ctx.actions.declare_file(ctx.label.name + ".out")
    args_file = ctx.actions.declare_file(ctx.label.name + ".worker_args")

    # These are the per-action arguments. Bazel will send these to the
    # persistent worker inside each WorkRequest.
    ctx.actions.write(
        output = args_file,
        content = "\n".join([
            "--input=" + ctx.file.src.path,
            "--output=" + out.path,
        ]),
    )

    ctx.actions.run(
        executable = ctx.executable._worker,
        inputs = [
            ctx.file.src,
            args_file,
        ],
        outputs = [out],
        arguments = [
            # For worker actions, the last argument is special:
            # it must be an @flagfile containing the per-request args.
            "@" + args_file.path,
        ],
        mnemonic = "UppercaseWorker",
        execution_requirements = {
            "supports-workers": "1",
            "requires-worker-protocol": "json",
        },
    )

    return [DefaultInfo(files = depset([out]))]


uppercase = rule(
    implementation = _uppercase_impl,
    attrs = {
        "src": attr.label(
            allow_single_file = True,
            mandatory = True,
        ),
        "_worker": attr.label(
            default = "//tools:worker",
            executable = True,
            cfg = "exec",
        ),
    },
)

The important part here is the arguments list. For worker actions, Bazel treats the last argument specially when it is an @flagfile. The contents of that file become the per-request arguments inside the WorkRequest. Any arguments before that are considered startup arguments for the worker process.

Now that the rule is in place, we need to create the actual worker binary. Because Swift is my language of choice, we will write it using Swift, but you can implement it in any language.

The Swift worker

Typically, I would split this out into multiple Swift files, but for the sake of simplicity, I will shove everything into one Swift file called worker.swift:

import Foundation
import ArgumentParser

struct WorkRequest: Decodable {
    var arguments: [String]?
    var requestId: Int?

    // Bazel may send other fields such as inputs, verbosity, etc.
    // JSONDecoder ignores unknown fields by default, which is what we want.
}

struct WorkResponse: Encodable {
    var requestId: Int
    var exitCode: Int
    var output: String
}

struct UppercaseArgs: ParsableArguments {
    @Option(name: .long)
    var input: String

    @Option(name: .long)
    var output: String
}

func expandArgs(_ args: [String]) throws -> [String] {
    var expanded: [String] = []

    for arg in args {
        if arg.hasPrefix("@") {
            let path = String(arg.dropFirst())
            let contents = try String(contentsOfFile: path, encoding: .utf8)

            for line in contents.split(separator: "\n") {
                let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)

                if !trimmed.isEmpty {
                    expanded.append(trimmed)
                }
            }
        } else {
            expanded.append(arg)
        }
    }

    return expanded
}

func runOne(_ rawArgs: [String]) throws {
    let args = try UppercaseArgs.parse(expandArgs(rawArgs))

    let inputText = try String(contentsOfFile: args.input, encoding: .utf8)

    try inputText.uppercased().write(
        toFile: args.output,
        atomically: true,
        encoding: .utf8
    )
}

func writeResponse(requestId: Int, exitCode: Int = 0, output: String = "") {
    let response = WorkResponse(
        requestId: requestId,
        exitCode: exitCode,
        output: output
    )

    do {
        let data = try JSONEncoder().encode(response)

        FileHandle.standardOutput.write(data)
        FileHandle.standardOutput.write(Data("\n".utf8))
    } catch {
        // Important: do not print normal logs to stdout.
        // In worker mode, stdout is reserved for WorkResponse JSON.
        FileHandle.standardError.write(
            Data("failed to encode WorkResponse: \(error)\n".utf8)
        )
        exit(1)
    }
}

func persistentLoop() {
    let decoder = JSONDecoder()

    while let line = readLine() {
        do {
            let request = try decoder.decode(
                WorkRequest.self,
                from: Data(line.utf8)
            )

            let requestId = request.requestId ?? 0
            let arguments = request.arguments ?? []

            do {
                try runOne(arguments)
                writeResponse(requestId: requestId)
            } catch {
                writeResponse(
                    requestId: requestId,
                    exitCode: 1,
                    output: String(describing: error)
                )
            }
        } catch {
            writeResponse(
                requestId: 0,
                exitCode: 1,
                output: "failed to decode WorkRequest: \(error)"
            )
        }
    }
}

@main
struct Worker {
    static func main() {
        let startupArgs = Array(CommandLine.arguments.dropFirst())

        if startupArgs.contains("--persistent_worker") {
            persistentLoop()
        } else {
            // Non-worker fallback path. This lets the same executable still work
            // when Bazel uses local execution instead of worker execution.
            do {
                try runOne(startupArgs)
            } catch {
                FileHandle.standardError.write(Data("\(error)\n".utf8))
                exit(1)
            }
        }
    }
}

A persistent worker has a small protocol contract with Bazel: it should accept the --persistent_worker flag, read WorkRequests from stdin, and write WorkResponses to stdout. If the same binary is run without --persistent_worker, it should behave like a normal one-shot tool. This fallback path is useful because Bazel may still run the action without the worker strategy.

One small but important detail: in worker mode, stdout belongs to the worker protocol. If you need to log something, write it to stderr instead.

I will not get into every detail here. I do expect the reader to be familiar with Swift and the general concept of Bazel workers.

We are missing the actual Bazel target for the worker at tools/BUILD.bazel:

load("@rules_swift//swift:swift_binary.bzl", "swift_binary")

swift_binary(
    name = "worker",
    srcs = ["worker.swift"],
    visibility = ["//visibility:public"],
    deps = ["@swift_argument_parser//:ArgumentParser"],
)

Trying out the rule

At the root, it is time to create a BUILD.bazel, load our rule, and build it:

load("//:uppercase.bzl", "uppercase")

uppercase(
    name = "hello",
    src = "hello.txt",
)

hello.txt is just a text file that I created to demonstrate the rule.

Building and verifying

To try out our new rule, execute:

bazel build :hello --spawn_strategy=worker,sandboxed --worker_verbose

We set --spawn_strategy=worker,sandboxed to make sure that our rule runs using the worker strategy and falls back to the standard sandboxed strategy. The fallback is important because there are actions that run because of rules_swift that do not necessarily use workers.

--worker_verbose is here just to make it easier to see that our worker is being used.

The output should look something like this:

INFO: Analyzed target //:hello (102 packages loaded, 649 targets configured, 2 aspect applications).
INFO: Created new non-sandboxed singleplex SwiftCompile worker (id 5, key hash -1813863811), logging to /Users/adincebic/Library/Caches/bazel/_bazel_adincebic/19f2a862cd16d28bfab74de8ca294508/bazel-workers/worker-5-SwiftCompile.log
INFO: Created new non-sandboxed singleplex UppercaseWorker worker (id 6, key hash -755134554), logging to /Users/adincebic/Library/Caches/bazel/_bazel_adincebic/19f2a862cd16d28bfab74de8ca294508/bazel-workers/worker-6-UppercaseWorker.log
INFO: Found 1 target...
Target //:hello up-to-date:
  bazel-bin/hello.out
INFO: Elapsed time: 19.851s, Critical Path: 19.03s
INFO: 60 processes: 30 internal, 26 darwin-sandbox, 4 worker.
INFO: Build completed successfully, 60 total actions

To verify the result, inspect bazel-bin/hello.out. It should contain the uppercase version of hello.txt.

And that’s it.

A few notes

This is the simplest example I could come up with, and it comes with a few caveats:

  • My worker implementation does not implement cancellation.
  • This is a singleplex worker, meaning Bazel sends it one request at a time.
  • The parsing logic could be more robust.
  • The worker ignores fields like inputs and verbosity from WorkRequest, which is fine for this example but probably not what you would do in a production worker.

Conclusion

This is one of those advanced Bazel concepts that you do not run into often, even if you write your own rules, purely because it is not always needed. But if you ever need persistent workers, I hope this gets you started.

Why My Xcode Extension Kept Asking for File Permissions

Recently, I worked on developing an Xcode source editor extension that needed to run some of our internal code formatters. These formatters are driven by configuration files that define how the tools should be executed. Because Xcode extensions must be sandboxed, they can’t directly access arbitrary file locations, including these configuration files.

To work around this, we used a container app to prompt users to select the location of the configuration files. We then created security-scoped bookmarks and passed them to the extension process. As expected, the standard way to share data between processes—such as an app and its extension—is by using Apple’s App Groups capability.

After setting this up, I noticed that the extension kept prompting the user to grant access to the shared files, even though both the app and extension were part of the same app group. This was unexpected—intuitively, accessing files within your own shared container shouldn’t trigger permission prompts.

The mistake

Coming from an iOS background, I defined the app group ID like this:

<key>com.apple.security.application-groups</key>
<array>
	<string>group.example.app</string>
</array>

After running both the app and the extension and inspecting ~/Library/Group Containers/, it was clear that the shared container had been created. However, what I missed is that on macOS, App Group identifiers must be prefixed with the Team ID (for example, TEAMID.group.example.app). This allows the system to correctly associate the app group with your developer account and properly link the app and its extension.

Without this prefix, the container may still appear to exist, but entitlement validation and access behavior can be inconsistent—leading to issues like repeated permission prompts.

Conclusion

This turned out to be one of those frustrating issues where the root cause isn’t immediately obvious, even after checking open-source projects and documentation. To be fair, Apple does document this requirement—but it’s easy to overlook, especially since iOS does not require this detail and doesn’t expose the same behavior as clearly.