📣 Join our Flight Deck mobile engineering meetup on Nov 20th in SF — RSVP
📣 Join our mobile engineering meetup on Nov 20th in SF — RSVP

How to build a Tuist plugin and publish it using SPM and version control

Note: In a previous blog post, we wrote some code that enables us to generically define modules that we'll be building on top of in this post. The source code can be found here.

In that prior post, we looked at how to get started with the Tuist Xcode project generator and how you could use it to create a module structure for your iOS projects. In this article we'll take the simple module system we built, and learn how to extract it into a Tuist plugin which can be reused across projects. This can be super helpful to share extended Tuist functionality across your organization, or even to share with the wider world as an open-source project.

Tuist plugins​

Plugins in Tuist are used to add new functionality to suit your project. There are a few core kinds of plugins that can be built:

  • Project description helpers. These are extensions to Tuist’s <code>ProjectDescription<code> offerings. We’ll be making use of this type of a plugin to build out our module system.
  • Templates. Tuist has a handy <code>scaffold<code> command which can ease the burden of adding boilerplate to your project. In our case, we could make a scaffold plugin which adds a new module to a project.
  • Task. Task plugins actually add functionality to Tuist using the ProjectAutomation framework. An example of a task plugin is the SwiftLint runner built out by the Tuist team.
  • ResourceSynthesizer. By default, Tuist can create accessors in code for things like assets, strings, fonts, and files contained in your main bundle or the resource bundle of one of your targets. The ResourceSynthesizer plugin is a customization point for you to add your own kinds of generated accessors.

This is but an overview of the available kinds of plugins in Tuist. Be sure to dig into the docs for the ones you’re interested in to get the details on how to build them out.

In this post, we’ll be making a Project description helper plugin.

Creating a project description helper plugin

As noted above, we previously wrote some code that enables us to generically define modules (the source code can be found here). First, we can make it more reusable by extracting it to a local plugin. From there, it becomes easier to publish the plugin via git source control so it can be available more broadly.

The first step is to put a new directory at the root of our repository called <code>ModuleDefinition<code>. Inside that we'll put two things: a <code>Plugin.swift<code> file and a folder called <code>PackageDescriptionHelpers<code>. It's important to note that each plugin type has its own structural requirements. For custom project description helpers, we only need those two things. So now our repo looks like this:

Project.swift
Targets
Tuist
  |- Config.swift
  |- ProjectDescriptionHelpers
ModuleDefinition
  |- Plugin.swift
  |- ProjectDescriptionHelpers

You may notice a similarity between the Tuist directory and our plugin's directory: they both contain the <code>ProjectDescriptionHelpers<code> folder where our extra code will go. Before we get there, let's look at the straightforward code of <code>Plugin.swift<code>.

import ProjectDescription

let plugin = Plugin(name: "ModuleDescription"

All we have to do here is define the plugin by name. Tuist will compile the plugin with the given name, and in our helpers we can then use `import ModuleDescription` to access the plugin's code. Tuist will look in the neighboring `ProjectDescriptionHelpers` directory for the code which will make up the plugin.

Now in our project's <code>Tuist/Config.swift<code> file we can import the plugin like so:

import ProjectDescription

let config = Config(
    plugins: [
        .local(path: .relativeToManifest("../../ModuleDescription")),
    ]
)

Here, we declare that the plugin we are looking for is two levels above the manifest (the repo's root) and in the <code>ModuleDescription<code> directory.

We'll then move all the files that make up the module description code over to the <code>ModuleDescription/ProjectDescriptionHelpers<code> directory (that code was previously in its own directory and easy to move over). Let's then run <code>tuist edit<code> so we can edit the Swift code that makes up the project.

One thing that's easy to overlook in the resulting Xcode project is that you're working in an Xcode workspace with 2 projects: Manifests and Plugins. Let's look at the Plugins project first, as the Manifests project will depend on the Plugins project.

First thing to do is try to build the Plugins project and fix any build failures. Once that target is building, we can flip over to the Manifests project and try to build the Manifests target. There will be build failures here as we've moved a sizable chunk of code over to our new plugin. In our case, where we declare our modules we'll need to use <code>import ModuleDescription<code> in order to use it, just like any other framework or package that we would use in our iOS projects.​

You may find other build failures with things like not using proper access control. It's important to think about how your plugin is accessed, just like you would with any other framework. It has an API that you control, and things are exposed from the plugin via <code>public<code> types, methods, and functions. It's important to consider what a good API for your plugin looks like. In our example, you’ll find that we had to create some public initializers for struct types which were previously provided by the generated <code>internal<code> initializers from the Swift compiler.

One other thing to think about for your plugin's API is if it needs any kind of configuration. In our case, we need to inject the bundle identifier of the base app for things like resource bundles and extensions that we’ll need to build in this customization. To handle this requirement, we can add a new <code>PluginConfiguration<code> class with a static member on it for the bundle identifier. And with the entry point to the plugin being project creation, that's the logical place to have the identifier injected:

public init(bundleID: String, modules: Set, additionalTargets: [Target] = [], packages: [Package] = [], schemes: [Scheme] = [], additionalFiles: [FileElement] = [], settings: Settings = .moduleSettings())
{
    PluginConfiguration.bundleID = bundleID
    // the rest of the Project init
}

It's important to note that we can’t set the bundle ID in Project.swift because expressions are not allowed in that file — you'll get an error if you try to set it directly.

Now that we've sorted out our build failures in the Manifests project, everything should build correctly. Congratulations — we've made a local Tuist plugin! Now, the next step is to make this plugin remote in git so that we can share it out to the rest of our team and beyond.

Taking our plugin remote

There are multiple advantages to moving custom plugins to a remote repository. Not only can we distribute them far and wide, but we can leverage Swift Package Manager to write tests and build different versions of our plugin. The local plugin's structure is not a typical Swift package style, so we'll need to make some modifications to the manifest to get things working correctly. Start off by creating a bare repository and running the command to create a package with a library product:

swift package init --type library --name ModuleDescription

This generates a boilerplate Swift package for us with the following structure:

Package.swift
Sources
 |- ModuleDescription
   |- ModuleDescription.swift
Tests
 |- ModuleDescriptionTests
   |- ModuleDescriptionTests.swift

Tuist does not use Swift Package Manager to do its resolution, so it needs the <code>Plugin.swift<code> and <code>ProjectDescriptionHelpers<code> of the plugin to be at the top level. As such, go ahead and delete the <code>Sources<code> and <code>Tests<code> directories of the package. The only part of the initial package scaffolding we need is the <code>Package.swift<code> manifest. We'll then copy over our local plugin’s contents to the new repo and add a <code>Tests<code> directory so our new structure looks like this:

Package.swift
Plugin.swift
ProjectDescriptionHelpers
Tests

This is unlike most Swift packages in that we are foregoing the standard directory structure. The reason is that our primary client is going to be Tuist via a <code>git clone<code>, and the package will give us affordances like ensuring that the code builds and letting us write and run tests. To get things running, we then need to overhaul the Package.swift file:

import PackageDescription

let package = Package(
    name: "tuist-module-description-plugin",
    platforms: [.macOS(.v12)],
    products: [
        .library(
            // 1
            name: "tuist-module-plugin",
            targets: ["ModuleDescription"]
        ),
    ],
    dependencies: [
        // 2
        .package(url: "https://github.com/tuist/projectdescription", from: "3.22.0"),
    ],
    targets: [
        .target(
            name: "ModuleDescription",
            dependencies: [
                .product(name: "ProjectDescription", package: "ProjectDescription"),
            ],
            // 3
            path: "ProjectDescriptionHelpers"
        ),
        .testTarget(
            name: "ModuleDescriptionTests",
            dependencies: ["ModuleDescription"],
            // 3
            path: "Tests"
        ),
    ]
)
  1. In the top section you can call the package whatever you want, since this is not being distributed as a package or depended on as a package. The important name is what is in the Plugin file, as that's how clients will <code>import<code> your plugin.
  2. Because our plugin is extending <code>ProjectDescriptionHelpers<code> (which is provided by Tuist when we run <code>tuist build<code> or <code>tuist edit<code>), we will still need those. As a helpful convenience the Tuist project has a package of just <code>ProjectDescription<code> which we can use as a dependency. It's much smaller than depending on all of Tuist.
  3. An interesting feature of SPM is the ability to set custom paths. This lets us preserve the top level <code>ProjectDescriptionHelpers<code> directory (and add one for <code>Tests<code> while we are at it) so Swift Package Manager can resolve everything, while keeping the directory structure we require.

You can now open the package in Xcode and build it, write some tests, and run those tests. Everything should work as expected. Push this repo up to your remote on GitHub, GitLab, Bitbucket, or other git host.

The last thing to do is update the client project to point at the remote plugin:

import ProjectDescription

let config = Config(
    plugins: [
        .git(url: "https://github.com/taphouseio/tuist-module-plugin.git", sha: "4c0d6ee84b632ee5346cdd3fb4d42d9acc0b9a14")
    ]
)

We've changed the plugin location from <code>.local<code> with a path, to <code>.git<code> with a URL. You may be used to declaring a dependency’s version as being “up-to next major” or pinned to an exact version. Tuist’s plugin dependency system is not as capable as SPM’s, so we need to point to either a git tag or a commit hash (in git parlance, a tag is a fancy way of labeling a commit).
‍

A word of warning: it will be far easier to reference plugins in your Config file by their SHA (either the full hash or the first 7 characters as that guarantees uniqueness). When tag-based plugins are requested, Tuist assembles a special URL to request the plugin — and it doesn’t always get the download URL quite right. Specifying SHAs directly tends to be much more reliable.

Once this is in place then we can delete the local plugin content and try building again. Everything should still work as we'd expect! 🪄

Plugin and play

We've covered a lot of ground here. We extracted the code from our local Tuist <code>ProjectDescriptionHelpers<code> that define our module system, made it a local plugin, and then uploaded that plugin to git where we can share it with our other projects and other users all over the world. Now you can go forth to build and distribute Tuist plugins for all your project generation needs, helping you build apps more consistently, with less fuss.

‍

Curious to see how your team's mobile releases stack up?
Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.
Don’t have a CI/CD pipeline for your mobile app yet? Struggling with a flaky one?
Try Runway Quickstart CI/CD to quickly autogenerate an end-to-end workflow for major CI/CD providers.
Try our free tool ->
Sign up for the Flight Deck — our monthly newsletter.
We'll share our perspectives on the mobile landscape, peeks into how other mobile teams and developers get things done, technical guides to optimizing your app for performance, and more. (See a recent issue here)
The App Store Connect API is very powerful, but it can quickly become a time sink.
Runway offers a lot of the functionality you might be looking for — and more — out‑of‑the‑box and maintenance‑free.
Learn more
App Development

Release better with Runway.

Runway integrates with all the tools you’re already using to level-up your release coordination and automation, from kickoff to release to rollout. No more cat-herding, spreadsheets, or steady drip of manual busywork.

Release better with Runway.

Runway integrates with all the tools you’re already using to level-up your release coordination and automation, from kickoff to release to rollout. No more cat-herding, spreadsheets, or steady drip of manual busywork.

Don’t have a CI/CD pipeline for your mobile app yet? Struggling with a flaky one?

Try Runway Quickstart CI/CD to quickly autogenerate an end-to-end workflow for major CI/CD providers.

Looking for a better way to distribute all your different flavors of builds, from one-offs to nightlies to RCs?

Give Build Distro a try! Sign up for Runway and see it in action for yourself.

Release better with Runway.

What if you could get the functionality you're looking for, without needing to use the ASC API at all? Runway offers you this — and more — right out-of-the-box, with no maintenance required.