Private Swift packages on CI/CD
Codemagic is the first CI/CD to make Apple M2 machines available to everyone (including the free tier!). This is a free upgrade from M1 machines with no price change.
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:
// 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:
[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:
[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:
<< 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:
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:
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:
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