Using Swift Scripts with Xcode
Learn how to run Swift scripts as part of the Xcode build phase, giving you control to configure or validate your app while building your project. By Ehab Yosry Amer.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Using Swift Scripts with Xcode
25 mins
Building apps with Swift is a lot of fun — and it’s also fun to use it in the build process itself.
Xcode lets you run your own scripts as part of the build phases, but instead of limiting yourself to shell scripts only, you can leverage your knowledge and expertise in Swift and do more with less effort.
In this tutorial, you’ll learn how to create build scripts in Swift and create operations for your build process. You’ll create four different scripts that cover a wide variety of operations you can perform on your projects. For example:
- Creating a basic script and executing it as you build the project
- Configuring input and output files for scripts
- Reading the project’s settings and adding custom values yourself
- Altering project resources during build time
- Executing command-line operations from your code
- Loading remote data and interrupting the build process
Getting Started
Download the starter project by clicking the Download Materials button at the top or bottom of the tutorial. Unzip it and open HelloXcode.xcodeproj in the starter folder.
The app itself doesn’t do much. Build and run. You’ll see a view controller with some text:
For this tutorial, you’ll work in the Scripts folder.
Writing Hello Xcode
Create a new Swift file under Scripts and name it HelloXcode.swift. Add the following code:
import Foundation
@main
enum MyScript {
static func main() {
print("Hello Xcode")
}
}
The code above prints “Hello Xcode” in the console.
Then, open the terminal, navigate to the Scripts folder and execute this command:
xcrun swiftc -parse-as-library HelloXcode.swift
cd
followed by the path you want to reach. For example cd /Users/your_user_name/SwiftBuildPhase/Starter/Scripts/
This will compile your Swift file and create an executable binary with the same name.
Next, run this command:
./HelloXcode
This will print Hello Xcode in your terminal window.
You just created a very basic application that does nothing except print the text Hello Xcode. You compiled this application and executed it. If you double-click the compiled file, it’ll open a new terminal window with some more messages before and after Hello Xcode.
........Scripts/HelloXcode ; exit;
Hello Xcode
logout
Saving session...
...copying shared history...
...saving history...truncating history files...
...completed.
[Process completed]
xcrun
can take several parameters one of them is -parse-as-library
which specifies to treat this file as a library otherwise, it’ll complain about the @main
attribute.
You can also specify what kind of SDK you want to compile the code against. Ideally, since you’re going to execute them from Xcode, it makes sense to use the macOS SDK. You can also rename the output file to something specific using the -o
attribute.
Your build command should be:
xcrun --sdk macosx swiftc -parse-as-library HelloXcode.swift -o CompiledScript
This command compiles HelloXcode.swift using the macOS SDK. The output file will be named CompiledScript.
Understanding Build Phases
Compiling your Swift file and executing it from Xcode is as easy as doing it from the terminal. All you need to do is define a New Run Script Phase and add the commands you executed on the terminal into it.
From the Project navigator, select the project file. Then in Build Phases add a new phase and select New Run Script Phase from the drop-down menu.
The Build Phases tab is the central point for Xcode’s build operation. When you build any project, Xcode does a number of steps in order:
- Identifies the dependencies and compiles them.
- Compiles the source files.
- Links the compiled files with their compiled dependencies.
- Copies resources to the bundle.
However, you may want to add your own operations at specific moments. For this tutorial, you’ll add new operations to the beginning — so when you add a new run script phase, drag it to the top of the list.
Going back to the new phase you added a moment ago, delete the commented line and add these commands:
xcrun --sdk macosx swiftc -parse-as-library Scripts/HelloXcode.swift \
-o CompiledScript
./CompiledScript
The difference between this and what you did before on the terminal is that Xcode executes these scripts on the path of the project file — that’s why adding Scripts/ is important.
Build the project to try out the new script and build phase you just added, then open the build log when it finishes. You’ll see the message you printed logged directly in Xcode’s log.
Exploring Input and Output Files
Xcode’s run phase allows you to specify files as configuration instead of having them explicit in the script. Think of it as sending a file as a parameter to a function. This way, your scripts can be more dynamic and portable to use.
In the run phase you added, add to the Input Files list $(SRCROOT)/Scripts/HelloXcode.swift and update the script to:
xcrun --sdk macosx swiftc -parse-as-library $SCRIPT_INPUT_FILE_0 \
-o CompiledScript
./CompiledScript
This doesn’t cause any changes to the execution of the scripts, but it makes Xcode more aware of the files you will change or use in your scripts. It will validate the existence of the input files and will make sure that files that are an output of a script then an input of another are used in the correct order.
Accessing Environment Variables
Sending file paths as parameters can be useful, but it’s not enough. Sometimes you’ll need to read information related to the project itself — like, is this a release or debug build? What is the version of the project? Or the name of the project?
When Xcode executes any run script phase, it shares all its build settings through environment variables. Think of environment variables as global variables. You can still read them from your Swift code. In HelloXcode.swift, add the following in main()
:
if let value = ProcessInfo.processInfo.environment["PRODUCT_NAME"] {
print("Product Name is: \(value)")
}
if let value = ProcessInfo.processInfo.environment["CONFIGURATION"] {
print("Configuration is: \(value)")
}
The code above reads the values for the environment variables “PRODUCT_NAME” and “CONFIGURATION” and prints them to Xcode’s log.
Build and open the build log. You’ll see the two values printed in the log:
When you open the details of the run script phase from the log, you’ll see all the build settings exported. You can read any of those values the same way you did with PRODUCT_NAME and CONFIGURATION. You can also read more to understand what each stands for in Apple’s Build Settings Reference.
In some cases, you’ll want to add your own custom settings for the project. This won’t make any difference for Xcode or its build process, but it could make a huge difference for your own custom scripts. Select the project file and go to Build Settings. Click on the + at the top, select Add User-Defined Setting and name the setting CUSTOM_VALUE. Enter This is a test value! as its value.
To read the new value you just added, add the following to HelloXcode.swift:
if let value = ProcessInfo.processInfo.environment["CUSTOM_VALUE"] {
print("Custom Value is: \(value)")
}
Build and you’ll see the new value you wrote printed in the log.
Incrementing the App Version
The next script you’ll create is designed to increment the version number of your app with each build. While you probably won’t ever have cause to increment the version number so frequently, this demonstrates how you could do it.
There are two values in the project’s Info.plist that represent the version:
- Bundle version: Represented by a number
- Bundle version string (short): Represented by two or three numbers like 1.12 (Major.Minor) or 1.3.16 (Major.Minor.Patch)
In this script, you’ll increment the bundle version with every build and the short string only when the build is a release build.
Create a new Swift file under the Scripts folder named IncBuildNumber.swift. Add the following to it:
import Foundation
@main
enum IncBuildNumber {
static func main() {
// 1
guard let infoFile = ProcessInfo.processInfo
.environment["INFOPLIST_FILE"]
else {
return
}
guard let projectDir = ProcessInfo.processInfo.environment["SRCROOT"] else {
return
}
// 2
if var dict = NSDictionary(contentsOfFile:
projectDir + "/" + infoFile) as? [String: Any] {
guard
let currentVersionString = dict["CFBundleShortVersionString"]
as? String,
let currentBuildNumberString = dict["CFBundleVersion"] as? String,
let currentBuildNumber = Int(currentBuildNumberString)
else {
return
}
// 3
dict["CFBundleVersion"] = "\(currentBuildNumber + 1)"
// 4
if ProcessInfo.processInfo.environment["CONFIGURATION"] == "Release" {
var versionComponents = currentVersionString
.components(separatedBy: ".")
let lastComponent = (Int(versionComponents.last ?? "1") ?? 1)
versionComponents[versionComponents.endIndex - 1] =
"\(lastComponent + 1)"
dict["CFBundleShortVersionString"] = versionComponents
.joined(separator: ".")
}
// 5
(dict as NSDictionary).write(
toFile: projectDir + "/" + infoFile,
atomically: true)
}
}
}
- First, you need the location of the Info.plist file for the current project, which Xcode stored in the environment variable INFOPLIST_FILE, and the path of the project from SRCROOT.
- Next, with those values, you have the path of the .plist file. Read the file as a dictionary and fetch the two version values currently stored in it.
- Increment the build number.
- If the current build configuration is Release, break down the short version string and increment the last digit in it.
- Overwrite the .plist file with the dictionary after all the changes.
Go to the project’s Build Phases and add a new run script phase. You can copy the contents of the previous build script tab, but remember to add the new IncBuildNumber.swift file to the Input Files list.
Build the project a few times and check Info.plist with each build. You’ll see the Bundle version value change.
Change the Build Configuration of the project’s scheme to Release instead of Debug and build the project again.
You’ll see the two values updated in the .plist file.
Changing the App Icon
The third script you’ll implement changes the AppIcon based on your current build configuration. In most projects, you’d have a different icon set for each configuration, but for this one, you’ll do something a little more sophisticated. You’ll alter the images directly and rewrite them.
In the current Scripts folder, ImageOverlay.swift provides addOverlay(imagePath:text:)
, which is a function for macOS using AppKit to add a text overlay in the bottom left corner on an existing image file. You’ll use it in this script.
Also, there is Shell.swift. It allows you to execute shell commands from Swift. You’ll learn about this in more detail in the next script.
Create a new Swift file in the same folder named AppIconOverlay.swift and add the following:
import Foundation
@main
enum AppIconOverlay {
static func main() {
// 1
guard
let srcRoot = ProcessInfo.processInfo.environment["SRCROOT"],
let appIconName = ProcessInfo.processInfo
.environment["ASSETCATALOG_COMPILER_APPICON_NAME"],
let targetName = ProcessInfo.processInfo.environment["TARGET_NAME"]
else {
return
}
// 2
let appIconsPath =
"\(srcRoot)/\(targetName)/Assets.xcassets/\(appIconName).appiconset"
let assetsPath =
"\(srcRoot)/\(targetName)/Assets.xcassets/"
let sourcePath =
"\(srcRoot)/Scripts/AppIcon.appiconset"
// 3
_ = shell("rm -r \(appIconsPath)")
_ = shell("cp -r \(sourcePath) \(assetsPath)")
// 4
guard let images =
try? FileManager.default.contentsOfDirectory(atPath: appIconsPath)
else {
return
}
// 5
let config = ProcessInfo.processInfo.environment["CONFIGURATION"] ?? ""
// 6
for imageFile in images {
if imageFile.hasSuffix(".png") {
let fileURL = URL(fileURLWithPath: appIconsPath + "/" + imageFile)
addOverlay(imagePath: fileURL, text: "\(config.prefix(1))")
}
}
}
}
- As with the previous script, you read the environment variables. Here, you need the path of the project file, the target name and the name of the AppIcon assets.
- Then, you define the paths you’ll use: the path to the AppIcon assets, the path to the assets folder as a whole and the path of the original unmodified assets present in the scripts folder.
- Delete the icon assets from the project and copy the unmodified ones from Scripts through shell commands. This is like a reset to the images so you don’t keep adding overlays on top of each other.
- Load the list of files present in the AppIcons assets folder so you can modify them one by one.
- Fetch the current build configuration.
- Loop over the files and, if the file is a PNG image, add an overlay of the first letter of the current configuration on top of it.
Add a new script to the build phases like the previous two and move it to the top — but this time, you want to include the three Swift files, Shell.swift, OverlayLabel.swift and ImageOverlay.swift, directly in the script and the AppIconOverlay.swift file as part of the Input Files. The shell script in the new run phase should be:
xcrun --sdk macosx swiftc \
-parse-as-library $SCRIPT_INPUT_FILE_0 Scripts/Shell.swift \
Scripts/OverlayLabel.swift Scripts/ImageOverlay.swift -o CompiledScript
./CompiledScript
Along with your input file, you’re passing all the needed Swift files for your code to compile successfully. This means you’re not limited to writing one file, and you can create your own reusable code for your scripts.
Build the project once on Debug and once on Release. Observe the changes on the AppIcon.
Linting Your Project
In your previous scripts, you automated some operations on the resources used by the project. In this script, you’ll run a validation on the project itself using SwiftLint and interrupt the build process as a whole based on a remote configuration.
If you’re not familiar with SwiftLint, it’s a command-line tool that allows you to validate your Swift code against a set of rules to make sure it’s organized properly. It helps developers to quickly spot any formatting issues in their code and always keep the code organized and visually clean.
You’ll need to install SwiftLint on your machine before you start working on this script. The GitHub page provides different ways to install it. It’s best not to install it as a pod in the project, but instead to install it directly on the machine.
Once you finish installing it, open a terminal window and navigate through the command line to the project’s folder. Then, run the following command:
swiftlint --config com.raywenderlich.swiftlint.yml
You’ll see the result on the terminal with this message:
......ContentView.swift:42:1: warning: Line Length Violation: Line should be 120 characters or less: currently 185 characters (line_length)
Done linting! Found 1 violation, 0 serious in 2 files.
documentation to learn more how to configure it.
This lint violation is intentional. Your script will run the same operation directly in the build process and check the number of violations found. If it doesn’t pass a certain threshold, your build process will continue. If it’s exceeded, the build will stop with an error.
Create a new Swift file in the Scripts folder and name it LintingWithConfig.swift. Add the following in the new file:
import Foundation
import Combine
@main
enum LintingWithConfig {
static func main() {
startLinting(allowedWarnings: 1)
}
static func startLinting(allowedWarnings: Int = 0) {
}
}
The code adds the standard main()
that you saw in all the previous scripts and is only calling startLinting(allowedWarnings:)
. You’ll do the first set of work on this method.
Utilizing the Shell
As mentioned earlier, Shell.swift provides a function to execute shell commands from Swift and returns the result of this command as a string.
So, to run the same command you just executed in a shell, all you need to do is add the following line in startLinting(allowedWarnings:)
:
let lintResult = shell("swiftlint --config com.raywenderlich.swiftlint.yml")
print(lintResult)
Add this new script to your build phases in a new run script phase and reorder it to the top. Remember to add the Swift file to the input files list, or you could hard code the file directly. You also need to compile Shell.swift with it.
xcrun --sdk macosx swiftc \
-parse-as-library $SCRIPT_INPUT_FILE_0 Scripts/Shell.swift -o CompiledScript
./CompiledScript
Build and open the log to see its output.
Controlling the Exit
You received the result of the command as a string. Add the following to the end of startLinting(allowedWarnings:)
:
var logResult = lintResult
.components(separatedBy: "Done linting!").last ?? "Found 0"
logResult = logResult.trimmingCharacters(in: CharacterSet(charactersIn: " "))
.components(separatedBy: " ")[1]
let foundViolations = Int(logResult) ?? 0
The first line separates the result by the text “Done linting!”, since you’re only interested in the part after you only take the last part of the array. To get the number of violations, the code separates the last part into single words and takes only the second word, which contains the number of violations found.
Next, you want to compare the found violations against the allowed number.
Add the following:
if foundViolations > allowedWarnings {
print("""
Error: Violations allowed exceed limit. Limit is \(allowedWarnings) \
violations, Found \(foundViolations)!
""")
exit(1)
}
exit(0)
If the number exceeds the allowed, you print an informative message then exit the execution with an error.
exit(_:)
allows you to terminate an execution. Passing any value other than zero means that there was an error and that is why the execution was terminated. Passing zero means that the operation finished everything required and the execution ended normally. In situations when you’re using scripts within scripts, you’ll use those numbers to identify one error from the other.
In main()
, change the value sent to startLinting(allowedWarnings:)
to 0 and build.
As expected, your build process stopped with an error.
Loading Remote Configuration
You don’t want to hard code the allowed violations in your script. Ideally, you never want to have any violations and the ideal number should be zero. SwiftLint also provides that. But in larger projects that have a large team and a complex CI/CD pipeline, keeping this number as a strict zero would be inefficient. Allowing yourself and your team some flexibility would be very nice. A simple example for this is when you want to apply a new linting rule that would execute on thousands of lines of old code and you don’t want to update all of it in one go. You’ll want to take it one step at a time while keeping everything else under control.
For the next part, you’ll load the allowed violations limit from an external file in an asynchronous request before you execute the SwiftLint command.
At the end of the file, add this structure:
struct LintConfig: Codable {
let allowedWarnings: Int
}
Replace the content of main()
with the following:
var cancelHandler: AnyCancellable?
let group = DispatchGroup()
// 1
guard let projectDir = ProcessInfo.processInfo.environment["SRCROOT"] else {
return
}
let configURL = URL(fileURLWithPath: "\(projectDir)/AllowedWarnings.json")
// 2
let publisher = URLSession.shared.dataTaskPublisher(for: configURL)
let configPublisher = publisher
.map(\.data)
.decode(type: LintConfig.self, decoder: JSONDecoder())
.eraseToAnyPublisher()
// 3
group.enter()
cancelHandler = configPublisher.sink { completion in
// 4
switch completion {
case .failure(let error):
print("\(error)")
group.leave()
exit(1)
case .finished:
print("Linting Config Loaded")
}
} receiveValue: { value in
// 5
startLinting(allowedWarnings: value.allowedWarnings)
}
// 6
group.wait()
cancelHandler?.cancel()
Here’s what this does:
- Fetch the path of the project to build the path of the file that has the allowed violations limit. Normally, you’d want to have this file on a remote server, but for this tutorial, you’ll treat this file as the remote location.
- Create a combine publisher to load the contents of the file and map this publisher to the
LintConfig
structure you defined in the previous step. - Call
enter()
on the DispatchGroup object you defined, then fetch the value from the publisher. - If the publisher failed to provide the value for any reason, exit the execution with an error to interrupt the build process.
- Use the value received by the publisher to call
startLinting(allowedWarnings:)
. - Call
wait()
on the DispatchGroup object to force the execution to wait.
Using DispatchGroup is very important here since combine is calling the request asynchronously. Without it, your script will not wait for the publisher to receive any value and will just finish execution before running your SwiftLint step. Calling group.leave()
when the publisher receives the data isn’t needed since startLinting(allowedWarnings:)
calls exit(0)
at the end.
Build and open the log. Your build will succeed and will show the same info as when you hard-coded the limit through code.
From Finder, open the file AllowedWarnings.json and change its contents to:
{"allowedWarnings":0}
Build again. As expected, the build will fail because the config file doesn’t allow any violations.
Where to Go From Here?
The sky is the limit for what you can do in the build phases with Swift. You can download resources used by the project from a remote server, you can validate more things in your project directly from Xcode or you can automate configuration changes or even upload the compiled binary yourself. You can literally program your own CI/CD if you want to. :]
To learn more about the command-line tool you were using to compile the Swift files, check Swift’s GitHub docs.
Also, you should refer to Apple’s Reference for all the Environment variables.
We hope you enjoyed this tutorial. If you have any questions or comments, please join the forum discussion below!