Swift scripting on Bitrise

John Sundell takes a look at how the power of Swift packages and SwiftPM itself can be used to write custom developer tools and scripts in Swift — and how those can then be easily deployed and run as part of a Bitrise workflow.

When Swift was first released in 2014, it was definitely focused on building apps for Apple’s platforms, such as iOS and the Mac. However, once Swift became an open source project the following year, things started to change — with official support for Ubuntu Linux built in, and with the introduction of the Swift Package Manager.

Since then, the Swift Package Manager (or SwiftPM for short) has not only evolved into a powerful and highly capable tool for running Swift code on Linux-based servers, but it’s now also integrated directly into Xcode — making it easier than ever to use it to build all sorts of projects.

While SwiftPM’s name might initially give the impression that it’s only used to manage a project’s dependencies, that’s only scratching the surface of what it’s capable of. In fact, Swift packages can not only be libraries and frameworks — but also completely stand-alone, executable Swift programs.

Let’s take a look at how we can use the power of Swift packages, and SwiftPM itself, to write custom developer tools and scripts in Swift — and how those can then be easily deployed and run as part of a Bitrise workflow.

The many use cases for scripting

When building any sort of app, framework or system, it’s very common to have to perform a certain set of workflow-related tasks on a regular basis. For example, we might need to update our app’s localized strings before submitting each new release to the App Store, or we might need to synchronize certain assets and configuration files with a central server.

While we could, of course, always perform those kinds of tasks manually — doing so doesn’t only get really boring after a while, it’s also often quite error prone, especially if we need to manually download and copy multiple files on a regular basis.

This is where scripting really shines, as writing a short and simple script for performing such regular tasks can both result in a much greater degree of consistency — and let us focus our time and energy on much more fun and challenging development tasks.

Let’s take a look at how we could write such a script in Swift, to see how Swift performs as a scripting language in a real-life scenario, and how several of the features that make Swift such a great language for app development also make it a fantastic language for scripting.

Synchronizing testing data

As an example, let’s say that we’re working on an app that uses a number of resource files for verification as part of its unit testing suite. Those might be JSON files that are used to verify that a certain part of our app behaves correctly when receiving various kinds of backend responses, they might be images used for rendering tests, and so on.

While using such resources as part of a test suite is in many cases a great way to avoid having to include large amounts of testing data within the tests themselves, keeping those files synced with the actual responses that our production backend will send can be quite challenging. It’s so easy for our resource files to end up getting outdated as our backend APIs change, and for that to cause false positives when running our tests.

Setting up a scripting package

So let’s write a script that makes sure that we’re always using up-to-date versions of our test resource files. We’ll start by creating a folder for our script, and we’ll then use SwiftPM’s init command within it — specifying that we want our new package to be an executable:


$ mkdir TestResourceDownload
$ cd TestResourceDownload
$ swift package init --type executable
Copy code


Next, let’s open our new package in Xcode, which can be done by simply calling open on the package’s Package.swift manifest file:


$ open Package.swift
Copy code


When scripting, we often want to leverage some form of external library in order to avoid having to write a lot of boilerplate code, and to speed up the overall process. After all, a script should feel lightweight and fast, both to write and to run. Since the tool that we’re about to build will have to work with several files and folders in order to download and store our resource files on disk, let’s import the open source Files package, which will make it much easier for us to write our file handling code.

To add such a third-party dependency to our script, let’s open up our Package.swift manifest file within Xcode and add a .package entry for Files within our dependencies array:


// swift-tools-version:5.1

import PackageDescription

let package = Package(
    name: "TestResourceDownload",
    dependencies: [
        // Tells SwiftPM where to find our dependency, and
        // what our version requirement is:
        .package(url: "https://github.com/johnsundell/files.git", from: "4.0.0"),
    ],
    targets: [
        .target(
            name: "TestResourceDownload",
            // Links our dependency to our main script target:
            dependencies: ["Files"]
        ),
        .testTarget(
            name: "TestResourceDownloadTests",
            dependencies: ["TestResourceDownload"]
        )
    ]
)
Copy code

The magic of now having SwiftPM directly integrated into Xcode is that as soon as we modify our Package.swift manifest file, SwiftPM will automatically start fetching our dependencies and then update our Xcode project for us.

Writing a Swift script

With the above setup taken care of, we can now start scripting. SwiftPM has already created a main.swift file for us, which will be the main entry point into our script’s program execution. However, we’ll write most of our logic inside of a new file — let’s call it Download.swift — which will let us organize all of our script’s logic separate from its main execution file.

The first thing we’ll need to do is to handle the command line arguments that were passed into our script. We’ll accept two arguments — a URL to fetch a resource from, and the name of the file to which it should be saved. To resolve those, we’ll use a somewhat hidden feature of Foundation’s UserDefaults type, which lets us use it to read command line arguments.

Using that feature, we’ll write the first function within our new Download.swift file, looking like this:


private func resolveArguments() throws -> (url: URL, fileName: String) {
    let container = UserDefaults.standard

    guard let urlString = container.string(forKey: "url"),
          let url = URL(string: urlString) else {
        throw Error.missingArgument(name: "url")
    }

    guard let fileName = container.string(forKey: "file") else {
        throw Error.missingArgument(name: "file")
    }

    return (url, fileName)
}
Copy code


Above we’re using a custom Error type in order to make it much easier to both throw errors, and to debug our script if something ever goes wrong — since we’ll have a well-defined list of possible errors that could occur. That Error type looks like this:


enum Error: Swift.Error {
    case missingArgument(name: String)
    case resourceFolderNotFound
    case downloadFailed(Swift.Error)
    case failedToWriteToFile(path: String)
}
Copy code


Now that we’ve resolved what URL to download our file from, and the file name to use when saving it, let’s find the folder in which we’ll save all test resources that we’ll download. To do that in a way that lets us easily run our script both independently, and as part of our build and CI process, we’ll use the script’s own file system location as our starting point when finding the folder that we’re looking for:


private func findResourcesFolder() throws -> Folder {
    let thisFile = try File(path: "\(#file)")
    var parent = thisFile.parent

    // First we'll find the root folder of our app project,
    // which in this case is 'MyApp':
    while let lastParent = parent, lastParent.name != "MyApp" {
        parent = lastParent.parent
    }

    guard let rootFolder = parent else {
        throw Error.resourceFolderNotFound
    }

    do {
        // Our target folder is located within our testing
        // folder, which is called 'MyAppTests' in this case:
        return try rootFolder.subfolder(at: "MyAppTests/Resources")
    } catch {
        throw Error.resourceFolderNotFound
    }
}
Copy code

So far so good! Now all that’s left is to download our file from the URL that was passed into our script — which in this case can simply be done synchronously, using Data(contentsOf:), which will block our script’s execution until the download finished:


private func downloadData(from url: URL) throws -> Data {
    do { return try Data(contentsOf: url) }
    catch { throw Error.downloadFailed(error) }
}
Copy code


Before we proceed with a download, however, we should check that some amount of time has passed since we last updated our file. Especially if we’d like to put this script in some form of “critical path” (for example as part of our app’s build or testing phases), then we wouldn’t want to re-download the file each time that our script is run.

One way to perform such a time-based check is to look at any previously downloaded file’s last modification date, and then check if a certain time interval has passed since then — like this:


private func shouldDownloadFile(named fileName: String,
                                into folder: Folder) -> Bool {
    // We also add support for passing a '-force' argument on
    // the command line, which will trigger a download regardless
    // of how much time that has passed since the last one:
    guard !CommandLine.arguments.contains("-force") else {
        return true
    }

    guard let file = try? folder.file(named: fileName),
          let modificationDate = file.modificationDate else {
        return true
    }

    // Make sure that approximately 24 hours have passed:
    let threshold: TimeInterval = 24 * 60 * 60
    return modificationDate.timeIntervalSinceNow > threshold
}
Copy code

You might’ve noticed that, so far, all of the functions that we’ve written have been private — and that’s because we don’t want to actually call them directly from within our main script file. Instead, we’ll compose all of the functions we’ve written up to this point into one main download function, which our script will then be able to call to perform its work:


func download() throws {
    let arguments = try resolveArguments()
    let folder = try findResourcesFolder()

    guard shouldDownloadFile(named: arguments.fileName, into: folder) else {
        return
    }

    let data = try downloadData(from: arguments.url)

    do {
        let file = try folder.createFileIfNeeded(withName: arguments.fileName)
        try file.write(data)
    } catch {
        let path = folder.path + arguments.fileName
        throw Error.failedToWriteToFile(path: path)
    }
}
Copy code

All of our core scripting logic is now completely finished, and all that’s left is to call into it from our main.swift file. To do that, we’re going to call download() using the do, try, catch pattern — which will enable us to print an appropriate error message in case we encounter one of the errors that we defined earlier:


do {
    try download()
} catch let error as Error {
    switch error {
    case .missingArgument(let name):
        print("Missing argument: \(name)")
    case .resourceFolderNotFound:
        print("Couldn't find a MyAppTests/Resources folder")
    case .downloadFailed(let error):
        print("Download error: \(error)")
    case .failedToWriteToFile(let path):
        print("Couldn't write to the file: \(path)")
    }
    
    exit(1)
} catch {
    print("An unknown error occured: \(error)")
    exit(1)
}
Copy code


The above calls to exit(1) are really important, as that’s how we’ll signal to any parent process that our script failed.

That’s it! We’re now ready to use our new script to automatically download our test resources on a regular basis. One way to make that happen is to run it using a Run Script build phase as part of our Xcode project’s unit testing target — like this:

undefined

With the above in place, our script will now be executed every time that we run our unit testing suite from within Xcode, and since we added that check to only perform each download every 24 hours, we won’t be adding much additional latency to our tests.

Running our script on Bitrise

Our script can also easily be run directly on Bitrise, by opening up the Workflow Editor for our app, and then adding the following shell script into a new Do anything with Script step (which should be placed before our Xcode Test for iOS step, to ensure that our tests are running with the latest resources):


#!/usr/bin/env bash
# fail if any commands fails
set -e
# debug log
set -x

# run our script
cd TestResourceDownload
swift run TestResourceDownload \
-url "https://api.myapp.com/items" \
-file "items.json"
Copy code

Since Bitrise comes with SwiftPM support built-in, that’s really all that we have to do to invoke any sort of custom developer tool or script that we’ve built. Really cool!

Integrating a Swift script into a Bitrise workflow is really easy.
Integrating a Swift script into a Bitrise workflow is really easy.

Conclusion

Scripting can be a fast and fun way to automate repetitive, boring and error prone development workflow tasks — and the fact that iOS developers can now easily write their own tools in a language that they already know is incredibly powerful.

Since a script written using SwiftPM has access to many of the same APIs that we use when building apps — such as all of the Swift standard library and Foundation — the learning curve is not as steep as you first might think. If you have a set of tasks that could benefit from being automated, try writing a Swift script to handle them, and then deploy it as part of your Bitrise workflow.

Thanks for reading! 🚀

No items found.

Explore more topics

App development

Best practices from engineers on how to use Bitrise to build better apps, faster.

Community

Meet other Bitrise engineers, technology experts, power users, partners and join our BUGs.

Company

All the updates about Bitrise events, sponsorships, employees, and more.

Insights

Mobile development, latest tech, industry insights, and interviews with experts.

Mobile DevOps

Learn why mobile development is unique and requires a set of unique practices.

Releases

Stay tuned for the last updates, new features, and product improvements.

Get the latest from Bitrise

Join other Mobile DevOps engineers who receive regular emails from Bitrise, filled with tips, news, and best practices.