Skip to main content

Manage automation tasks using Swift Package Manager

Now that we have Swift Package Manager for including framework dependencies, wouldn't it be great if we could use it to install and run our command line tool dependencies as well? What if I told you that this was already possible, allowing you to add tools like SwiftLint to your project within Package.swift and run automation tasks easily and with minimal configuration.

When working on software projects there are usually a set of different scripts for running project automation, or maybe a selection of different command line tools all the developers install on their system. For a Swift project many of these tools are written in Swift, yet we still end up installing them with other package managers such as Homebrew or Mint. These package managers are fantastic and perfect for installing our tools, however when we already have Swift Package Manager (SPM) it would be great if we could use that directly.

We will explore setting up all of our command line tool dependencies using Swift Package Manager, discuss how to run them and then move onto how to write automation tasks using a small CLI within our project.

Installing and running

Swift command line tools can be added to our project by simply adding them as a dependency within Package.swift, alongside framework dependencies we may already be using. We will be running the tools directly and so there is no need to add them as dependencies to any of our targets, we just need SPM to download them for us.

let package = Package(
    name: "Uploader",
    products: [
        .executable(name: "uploader-cli", targets: ["Uploader"])
    ],
    dependencies: [
        .package(
            url: "https://github.com/realm/SwiftLint",
            from: "0.39.1"
        ),
        .package(
            url: "https://github.com/nicklockwood/SwiftFormat",
            from: "0.44.4"
        ),
        .package(
            url: "https://github.com/apple/swift-argument-parser",
            from: "0.0.1"
        )
    ],
    targets: [
        .target(
            name: "Uploader",
            dependencies: ["Uploader", "ArgumentParser"]
        )
    ]
)

For our Uploader Package.swift alongside ArgumentParser which we are using as a dependency on our main target, we can include tools: SwiftLint and SwiftFormat. As they are added just like other dependencies, we can specify the version of the command line tool to use, allowing all developers on the project to use the same version. This is especially important for tools that are linting or formatting code, as it means the same rules will be applied for all developers.

Running our tools is now as simple as using swift run, simple! The same process can be used locally and on CI, which means we get the same versions of the tools in both places!

swift run swiftlint
swift run swiftformat .

Managing automation tasks

On top of running command line tools as we have done so far, it would be great if we could also run all of our project automation in the same way. We could of course simply use Swift scripts, however, if any of them need dependencies this would need to be managed and having a bunch of different scripts may not be as discoverable as it could be.

An alternative is that we create a small CLI within Package.swift that provides a set of automation tasks via subcommands that can then be called in a similar way to the lanes in Fastlane for those who are familiar with it.

The first step to creating our CLI is adding a target that will contain the code for our automation tasks.

targets: [
    ...
    .target(
        name: "UploaderTasks",
        dependencies: ["ArgumentParser", "ShellOut"]
    )
]

The next step is adding an executable product that uses the new target. Make sure to use a simple name for the executable so that it is easy to run, a personal preference being task. Our automation tasks will now be ran using swift run task TASK_NAME.

products: [
    ...
    .executable(name: "task", targets: ["UploaderTasks"])
]

The main command

Our task executable is developed just like any other CLI, although it will be a simple one containing very little code. We could do it manually within main.swift, however, as we will need to run different tasks as arguments to the main command we will need to be parsing these arguments and making it easy for users to find the tasks they can run. To make this really easy there is the ArgumentParser framework from Apple.

Within the main.swift file we create a struct for our main command. This configures the command name, offers text for the help message and provides our tasks as subcommands. We will be providing lint, install and uninstall. To execute our program we call Tasks.main() at the bottom of the main.swift.

struct Tasks: ParsableCommand {
    static let configuration = CommandConfiguration(
        commandName: "tasks",
        abstract: "An automation task runner for Uploader.",
        subcommands: [Linting.self, Install.self, Uninstall.self]
    )
}

Tasks.main()

Before creating our first subcommand, we need to make sure it is easy for our subcommands to execute shell commands such as running other CLI tools or scripts. For doing this there is a great framework called ShellOut. Rather than calling it directly we can write a small wrapper function that handles errors, streams output as output from our CLI and allows execution to continue after an error.

func runShell(_ command: String, continueOnError: Bool = false) throws {
    do {
        try shellOut(
            to: command,
            outputHandle: .standardOutput,
            errorHandle: .standardError
        )
    } catch {
        if !continueOnError {
            throw ShellError()
        }
    }
}

struct ShellError: Error, CustomStringConvertible {
    var description: String {
        "Failed when running shell command"
    }
}

If the outputHandle and errorHandle weren't connected to standard output and standard error, we would need to print the output of the shellOut call. By using these handles it will appear in the terminal we are running our task from as it is produced. This means we don't need the output from shellOut or the error it throws as the information has already been shown to the user by the point it is received.

Lint

For each task we want to add, we create a subcommand to our main Tasks command. We already registered these above and will now work through creating each of the subcommands, starting with the lint task. We are running SwiftFormat in lint mode and then SwiftLint, allowing execution to continue on error so that we get all of the failures out of both tools.

extension Tasks {
    struct Linting: ParsableCommand {
        static var configuration = CommandConfiguration(
            commandName: "lint",
            abstract: "Lint the Uploader codebase, such as static analysis."
        )

        func run() throws {
            try runShell("swift run swiftformat . --lint", continueOnError: true)
            try runShell("swift run swiftlint")
        }
    }
}

This task is ran using swift run task lint.

We can create another subcommand to format code using SwiftLint auto-correct and SwiftFormat in its regular mode, feel free to add this to your project in the same way as writing our Linting struct.

Install and uninstall

If our Swift project is itself a CLI, our users will need to install and uninstall it, both tasks either being provided by a script or a Makefile. We can offer these tasks via subcommands in our task runner.

extension Tasks {
    struct Install: ParsableCommand {
        static var configuration = CommandConfiguration(
            commandName: "install",
            abstract: "Install Uploader for running globally."
        )

        func run() throws {
            try runShell("swift build -c release")
            try runShell(
                "install .build/release/uploader-cli /usr/local/bin/uploader"
            )
        }
    }
}

Users can now clone our repository and then run swift run task install to install the application globally on their system. We can also provide an uninstall task, in case they wish to remove our application.

extension Tasks {
    struct Uninstall: ParsableCommand {
        static var configuration = CommandConfiguration(
            commandName: "uninstall",
            abstract: "Uninstall Uploader and remove from system."
        )

        func run() throws {
            try runShell("rm -f /usr/local/bin/uploader")
        }
    }
}

Help

A really nice feature of ArgumentParser is that it generates a help message from the command structs we have created. This aids discovery of the tasks that can be executed on our project by using swift run task -h or --help.

OVERVIEW: A task runner for Uploader.

USAGE: tasks 

SUBCOMMANDS:
  lint        Lint the Uploader codebase, such as static analysis.
  install     Install Uploader for running globally.
  uninstall   Uninstall Uploader and remove from system.

Using this on CI

Now that our command line tools and automation tasks are managed using SPM, using our project on CI services such as GitHub Actions and Bitrise couldn't be easier. We simply call the same commands we use locally and SPM will handle checking out the correct versions, building and executing our tools.

swift build
swift test
swift run task lint

Most CI services will also come with built-in support for caching, so we could speed things up by caching the SPM dependencies for future runs.

Wrap up

Swift Package Manager is a really great way to manage our Swift projects and it is nice being able to use the first-party official build tool and dependency manager. Now we can manage all of our automation and project command line tools using SPM as well, bringing all the project configuration together with one tool.

By building our automation tasks using a CLI within the project, it keeps everything together and easily discoverable. It is also great to be able to build all of this in Swift, meaning the source code and automation is all based on the same language and technologies.

I hope the article was useful. If you have any feedback or questions please feel free to reach out.

Thanks for reading!

Like what you read? Please share the article.

Avatar of Andrew Lord

WRITTEN BY

Andrew Lord

A software developer and tech leader from the UK. Writing articles that focus on all aspects of Android and iOS development using Kotlin and Swift.

Want to read more?

Here are some other articles you may enjoy.