justin․searls․co

Adding swift-format to your Xcode build

Xcode 16 and later come with swift-format baked in. Unfortunately, Xcode doesn't hook it up for you: aside from a one-off "Format File" menu item, you get no automatic formatting or linting on local builds—and zero guidance for Xcode Cloud.

Beginning with the end in mind, here's what I ended up adding or changing:

.
├── ci_scripts
│   ├── ci_pre_xcodebuild.sh
│   ├── format
│   └── lint
├── MyApp.xcodeproj
│   └── project.pbxproj
└── script -> ci_scripts/

Configuring swift-format

Since I'm new around here, I'm basically sticking with the defaults. The only rule I customized in my project's .swift-format file was to set indents to 2 spaces. Personally, I rock massive fonts and zoom levels when I work, so the default 4-space indent can result in horizontal scrolling.

{
  "indentation" : {
    "spaces" : 2
  }
}

Running swift-format in Xcode Cloud

Heads-up: if you wire swift-format into your local build you can skip this step. I'm laying it out anyway because sometimes it's handy to run these scripts only in the cloud—and starting with that flexibility costs nothing.

When you add custom scripts on Xcode Cloud, you can implement any or all of these three specially named hook scripts:

  • ci_scripts/ci_post_clone.sh
  • ci_scripts/ci_pre_xcodebuild.sh
  • ci_scripts/ci_post_xcodebuild.sh

If that feels limiting, it gets better: these scripts can call anything else inside ci_scripts. Because I always name my projects' script directory script/, I capitulated by putting everything in ci_scripts and made a symlink:

# Create the directory
mkdir ci_scripts
# Add a script/ symlink
ln -s ci_scripts script

Create the formatting & linting scripts

Next, I created (and made executable) my pre-build hook script, a format script, and a lint script:

# Create the scripts
touch ci_scripts/ci_pre_xcodebuild.sh ci_scripts/lint ci_scripts/format
# Make them executable
chmod +x ci_scripts/ci_pre_xcodebuild.sh ci_scripts/lint ci_scripts/format

With that, a pre-build hook (which only runs in Xcode Cloud) can be written like this:

#!/bin/sh
# ci_scripts/ci_pre_xcodebuild.sh

# See: https://developer.apple.com/documentation/xcode/writing-custom-build-scripts

set -e

./lint
./format

The lint script looks like this (--strict treats warnings as errors):

#!/bin/sh
# ci_scripts/lint

swift format lint --strict --parallel --recursive .

And my format script (which needs --in-place to know it should overwrite files) is here:

#!/bin/sh
# ci_scripts/format

swift format --in-place --parallel --recursive .

Note that the above scripts use swift format as a swift subcommand, because the swift-format executable is not on the PATH of the sandboxed Xcode Cloud environment.

(Why bother formatting in CI if it won't commit changes? Because I'd rather learn ASAP that something's un-formattable than be surprised when I run ./script/format later.)

Configuring formatting and linting for local builds

If you're like me, you'll want to lint and format on every local build as well:

In your project file, select your app target and navigate to the Build Phases tab. Click the plus (➕) icon and select "New Run Script Phase" to give yourself a place to write this little bit of shell magic:

"$SRCROOT/script/format"
"$SRCROOT/script/lint"

You'll also want to uncheck "Based on dependency analysis", since these scripts run across the whole codebase, it doesn't make sense to whitelist specific input and output files.

Finally, because Xcode 15+ sandboxes Run Scripts from the filesystem by default, you also need to go to the Build Settings tab of the target and set "User Script Sandboxing" to "No" in the target's Build Settings.

In MyApp.xcodeproj/project.pbxproj you should see the setting reflected as:

ENABLE_USER_SCRIPT_SANDBOXING = NO

And that's it! Now, when building the app locally (e.g. Command-B), all the Swift source files in the project are linted and formatted. As mentioned above, if you complete this step you can go back and delete your ci_scripts/ci_pre_xcodebuild.sh file.

Why is this so hard?

Great question! I'm disappointed but unsurprised by how few guides I found today to address issues like this, but ultimately the responsibility lies with Apple to provide batteries-included tooling and, failing that, documentation that points to solutions for common tasks.


Got a taste for hot, fresh takes?

Then you're in luck, because you'll pay $0 for my 2¢ when you subscribe to my work, whether via RSS or your favorite social network.

I also have a monthly newsletter where I write high-tempo, thought-provoking essays about life, in case that's more your speed:

And if you'd rather give your eyes a rest and your ears a workout, might I suggest my long-form solo podcast, Breaking Change? Odd are, you haven't heard anything quite like it.