Private Swift packages on CI/CD

Sponsored

Codemagic logo
Codemagic CI/CD for mobile teams

What do you get when you put love for iOS and DevOps together? Answer: Codemagic CI/CD

At work, we have recently started to use Swift packages to share common code across iOS apps maintained by different products and teams.

Due to the nature of the code we are sharing, we needed to host the packages on internal GitHub repositories accessible only to our organisation.

This caused a myriad of issues when trying to build our app on CI/CD using these new packages as we had previously only depended on public packages in the past.

In this article I will go through the solution we came up with and how we leveraged the use of GitHub personal access tokens to make it work on CI/CD.

The requirements

GitHub Actions is our CI/CD tool of choice and we use its @actions/checkout action to clone our repository.

This action clones code from GitHub using HTTPS authentication with a short-lived private access token scoped to the current repository. This token is generated by GitHub Actions automatically and is available as an environment variable on every workflow.

For this reason, our runners are not set up to use SSH keys and by default only have access to the repository that triggered the workflow. Access to any other repositories must be granted explicitly through the use of an elevated access token, which must be created manually.

We wanted to preserve this behaviour and only grant access during the execution of a workflow, so we decided to create a personal access token with access to clone all repositories in our organisation and come up with a solution that would allow us to make Xcode use it to resolve all Swift packages.

Building the app locally

All developers in our team use SSH keys to authenticate with GitHub and have access to all repositories in our organisation.

For this reason, we decided to define our private Swift package dependencies using SSH URLs in the Package.swift manifest:

Package.swift
// swift-tools-version:5.7

import PackageDescription

let package = Package(
    name: "YourAwesomePackage",
    products: [
        .library(
            name: "YourAwesomePackage",
            targets: ["YourAwesomePackage"]
        )
    ],
    dependencies: [
        .package(url: "git@github.com:your-org/your-dependency.git", exact: "1.0.0")
    ],
    targets: [
        .target(
            name: "YourAwesomePackage",
            dependencies: [.product(name: "YourDependency", package: "your-dependency")]
        )
    ]
)

This approach allowed us to build the app locally without having to change anything in our development environment.

Building the app on CI/CD

Modifying the global git config

By now you might be thinking that I have contradicted myself. I started the article by saying that we wanted to use personal access tokens to build our app on CI/CD, but then I went ahead and defined our dependencies using SSH URLs.

The cool thing about git is that you can provide overrides for the way it clones repositories through the git config file. This way, we were able to tell git to use HTTPS URLs instead of SSH URLs when cloning repositories on CI/CD while keeping SSH keys locally:

override-git-config
[url "https://github.com/"]
	insteadOf = git@github.com:

We committed the override-git-config file to our repository and then used it to override the global git config on CI/CD for the current session only through environment variables:

Note that I am using a fastlane lane in this example, but you can use any other tool to achieve the same result.

desc "Set up git credentials"
private_lane :cache_git_crendetials do
  ENV["GIT_CONFIG_GLOBAL"] = "#{ENV['PWD']}/override-git-config"
end

We had to override the global git config file because xcodebuild uses it to clone repositories outside of our repository’s folder when resolving Swift packages.

Setting the credential helper

By default, macOS runners have a system git config file which defaults to using the keychain as the git credential helper.

This causes any access token used for authentication to be stored in the keychain forever, causing the runner to have access to clone repositories even after the workflow has finished.

To override this behaviour, we had to override the system git config file we created earlier to use cache as the git credential helper instead:

override-git-config
[credential]
	helper = cache --timeout 900
[url "https://github.com/"]
	insteadOf = git@github.com:

The beauty of this approach is that, due to the way the cache credential helper works, the token will always be stored in an in-memory cache and will never be written to a file.

Furthermore, as we are setting a timeout of 900 seconds, the credentials will be removed from the cache 15 minutes after they are used (e.g. after cloning a repository).

In macOS runners, the only way we could tell git to stop caching the token to the keychain was by also ignoring the system git config. We did this by setting an environment variable so that this change would only take effect for the duration of the current session:

desc "Set up git credentials"
private_lane :cache_git_crendetials do
  ENV["GIT_CONFIG_GLOBAL"] = "#{ENV['PWD']}/override-git-config"
  ENV["GIT_CONFIG_NOSYSTEM"] = "true"
end

Storing the access token in the cache

After we had the credential helper all set up and ready to go, we needed to store the access token in the cache so that xcodebuild could use it to clone the private Swift packages.

We created a script that would retrieve the access token from the environment and store it in the cache using the git credential-cache command:

store-access-token.sh
<< eof tr -d ' ' | git credential-cache store
  protocol=https
  host=github.com
  username=nonce
  password=$GIT_TOKEN
eof

The beauty about this command, which we found in this gist by Robert Citek, is that it will call git credential-cache store without having to create a file with the credentials in it first 🎉.

We then used the script in the same fastlane lane we created earlier:

Fastfile
desc "Set up git credentials"
private_lane :cache_git_crendetials do
  ENV["GIT_CONFIG_GLOBAL"] = "#{ENV['PWD']}/override-git-config"
  ENV["GIT_CONFIG_NOSYSTEM"] = "true"
  sh("./store-access-token.sh")
end

Removing the access token from the cache

The last thing to do was to store the access token in the cache right before the application is built and remove it straight after, even if the build fails.

First, we created a new lane to remove the access token from the in-memory cache. This is as simple as stopping the credential helper process:

Fastfile
desc "Remove git credentials"
private_lane :remove_git_credentials do
  ENV["GIT_CONFIG_GLOBAL"] = "#{ENV['PWD']}/override-git-config"
  ENV["GIT_CONFIG_NOSYSTEM"] = "true"
  sh("git credential-cache exit")
end

Then, we wrapped our build commands in begin/ensure blocks to make sure that the credentials are cached and removed at the right times:

Fastfile
desc "Build the applicatiom"
lane :build do
    # ...
    begin
      cache_git_crendetials

    gym(
        clean: true,
        configuration: 'Debug',
        derived_data_path: './derived_data',
        scheme: 'Debug',
        workspace: 'HelloWorld.xcworkspace'
    )
    ensure
      remove_git_credentials
    end
end