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.