Our Biggest Black Friday Sale — Ever!

Introducing unlimited access to all courses, all books, and our new monthly live professional development series! Just $899 $399 per year during our Black Friday event

Ends in... ::
Home iOS & Swift Tutorials

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.

5/5 9 Ratings

Version

  • Swift 5.5, iOS 15, Xcode 13

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.

Hello Xcode Project navigator showing the project files and some helper scripts.

The app itself doesn’t do much. Build and run. You’ll see a view controller with some text:

The main page of the Hello Xcode app.

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
Note: To navigate to a path on the terminal, use the command 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.

Scripts folder showing HelloXcode.swift file and its compiled counterpart.

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-librarywhich 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.

Scripts folder showing HelloXcode.swift file and its compiled counterpart renamed to 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.

Adding a New Run Script Phase for the project

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:

  1. Identifies the dependencies and compiles them.
  2. Compiles the source files.
  3. Links the compiled files with their compiled dependencies.
  4. 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.

A gif showing the addition of a run script phase and dragging it to the top

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.

Hello Xcode message in Xcode's build 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

The script after using input files

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.

Note: Items added to the Input Files list are referenced as $SCRIPT_INPUT_FILE_0, $SCRIPT_INPUT_FILE_1, … etc. Items in the Output Files list are $SCRIPT_OUTPUT_FILE_0, $SCRIPT_OUTPUT_FILE_1, … etc.

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:

Two new messages are showing in the build 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.

Some of the exported Build Settings values in the log.

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.

Adding a new user-defined build setting

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)
    }
  }
}
  1. 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.
  2. 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.
  3. Increment the build number.
  4. If the current build configuration is Release, break down the short version string and increment the last digit in it.
  5. 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.

Note: Reordering this script is important because you want to update the version numbers before Xcode reads the values from the plist file.
Note: If you leave the phases with their default name Run Script, it will be confusing if there are several. You can rename them by clicking on the title to make it editable.

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.

Changing the build configuration to release

You’ll see the two values updated in the .plist file.

Version values updated in the info.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))")
      }
    }
  }
}
  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.
  2. 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.
  3. 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.
  4. Load the list of files present in the AppIcons assets folder so you can modify them one by one.
  5. Fetch the current build configuration.
  6. 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
Note: Reordering this script is important because you want to change the images before Xcode copies the images to the compiled bundle.

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.

Simulator screenshots showing the 2 different App Icons

Note: With Xcode 13.0, you’ll have to delete the app from the simulator to see the icon change.

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.
Note: com.raywenderlich.swiftlint.yml is a SwiftLint configuration file to configure which rules to be used and which to be ignored. Feel free to go through the tool’s
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.

SwiftLint result printed by the run script phase that you just added

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.

Build failed when the allowed violations is reduced to 0

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:

  1. 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.
  2. 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.
  3. Call enter() on the DispatchGroup object you defined, then fetch the value from the publisher.
  4. If the publisher failed to provide the value for any reason, exit the execution with an error to interrupt the build process.
  5. Use the value received by the publisher to call startLinting(allowedWarnings:).
  6. 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!

More like this

Contributors

Comments