🔀 How to untangle and manage build distribution — Webinar, May 16th — Register
🔀 How to untangle and manage build distribution — Webinar, May 16th — Register

How to implement fastlane match in Swift using the App Store Connect API

Part I: Fetching provisioning profiles and signing certificates

Here at Runway we've written about the wonderful fastlane set of tools in the past. It's great! One of the best tools fastlane offers is match. Match interfaces with the App Store Connect API to sync your provisioning profiles and code signing credentials to a storage location of your choosing (usually a GitHub repo) so they can be accessed from a centralized place. fastlane match can also automatically renew credentials for your apps using the App Store Connect API. You can learn more about how to use fastlane match to automate iOS code signing tasks on our blog.

But fastlane is also built in Ruby, which carries along some requirements of at least a little Ruby knowledge to build your workflows and the ability to keep up a Ruby environment. What if you wanted a centralized way to manage signing certificates but wanted to avoid Ruby and fastlane match? Luckily, everything that’s needed to accomplish this is available in the App Store Connect API, and by leveraging our OpenAPI Swift client we can build a lot of this functionality in Swift relatively easily. If you haven’t gone through our tutorial for setting up an OpenAPI client for the App Store Connect API yet, we highly recommend that as a first step before continuing.

To get started, we’ll look at how to use the App Store Connect API to fetch provisioning profiles and signing certificates that we can later store in a location of our choosing. In Part 2 of this post, we’ll see how we can again leverage the App Store Connect API via our Swift client to regenerate provisioning profiles and signing certificates when needed.

Finding the profiles

The App Store Connect API documentation has a handy sidebar which lists out different sections of topics. Among them is a group called Provisioning, with sub-sections for Certificates and Profiles. Digging into these sections will give us the endpoints and models we'll need to interact with.

Let's start with profiles. The <code>v1/profiles<code> documentation endpoint will return all the profiles for our App Store Connect account. The return values include <code>data<code> of type <code>[Profile]<code> (an array of provisioning profiles) and paging information to get the next set of profiles until they are all downloaded.

We build a <code>Profile<code> type which contains much of the contents from the schema object being returned to us (which is a Profile resource) and extract from it the things we want.

public struct BundleID {
	public enum Platform {
        case ios
        case macOS

        init(schema: Components.Schemas.BundleIdPlatform?) {
            self = schema == .IOS ? .ios : .macOS
        }
    }
}

public struct Profile {
    public let name: String
    public let platform: BundleID.Platform
    public let content: String
    public let isActive: Bool

    init?(schema: Components.Schemas.Profile) {
        guard let name = schema.attributes?.name, 
                let content = schema.attributes?.profileContent,
                let state = schema.attributes?.profileState,
        else { return nil }

        self.name = name
        self.platform = BundleID.Platform(schema: schema.attributes?.platform)
        self.content = content
        self.isActive = state == .ACTIVE
    }
}
  1. The profile's name as it appears in the developer portal.
  2. The platform that the profile supports. Surprisingly this only includes options for iOS and macOS. This is likely because Apple's other platforms – tvOS, watchOS, visionOS – are other iOS variants and use the same platform code. macOS is a very different animal. Because the schema lists the API's return type as BundleIDPlatform we'll create our own corresponding type, which will come in handy later.
  3. The actual content of the profile – which will get serialized to disk and saved out for Xcode to read in.
  4. Whether or not the profile is active. The API call returns to us all profiles on your developer account so we'll use this to keep only the active ones.

The next thing to do is add the endpoint to our OpenAPI configuration so the generator will build out the code we need:

 generate:
  - types
  - client
accessModifier: internal
filter:
  operations:
    - bundleIds-get_collection
  paths:
    - v1/apps
    - v1/apps/{id}/appStoreVersions
    - v1/appStoreVersions/{id}/customerReviews
    - v1/profiles

Here we now have the <code>v1/profiles<code> path generating the needed code for the endpoint. Taking this, let's expand on our <code>APIClient<code> that we started in a prior post and add fetching of profiles:

extension APIClient {
    func fetchActiveProfiles() async throws -> [Profile] {
        let response = try await client.profiles_hyphen_get_collection()
        switch response {
        case .ok(let ok):
            switch ok.body {
            case .json(let json):
                return Profile.from(json, include: { $0.isActive })
            }
        default:
            break
        }

        return []
    }
}

extension Profile {
    static func from(_ response: Components.Schemas.ProfilesResponse, include: (Profile) -> Bool) -> [Profile] {
        response.data.compactMap { Profile(schema: $0) }.filter { include($0) }
    }
}

This API call looks really similar to the ones we've made before. The generated code gives us the `client.profiles_hyphen_get_collection()` code and we'll extract from an ok response the JSON body and create our array of profiles. We added a helper static function on `Profile` to parse the individual schemas from the whole response.

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

A date detour

If we try to run this code, we'll find that it doesn't work. There's an error coming from the decoding of the payload from the API, and the stack trace will point to the <code>expirationDate<code> property of a profile. The problem here is that our OpenAPI Runtime package uses an <code>ISO8601DateFormatter<code> by default, and the string coming back from the API doesn't match what that type can handle. To fix this we'll need to add our own formatter as a fallback in case the default fails. Thankfully this is pretty straightforward.

In our <code>APIClient<code> initializer, we'll need to specify a custom <code>Configuration<code> to the generated <code>Client<code> class:

public final class APIClient {
    let client: Client
    let session: URLSession

    public init(session: URLSession = .shared) {
        self.session = session
        self.client = try! Client(
            serverURL: Servers.server1(),
            configuration: Configuration(dateTranscoder: APIDateTranscoder()),
            transport: URLSessionTransport(),
            middlewares: [
                JWTMiddleware(),
            ]
        )
    }
}

private var fallbackFormatter: DateFormatter = {
    let formatter = DateFormatter()
    formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX"
    return formatter
}()

struct APIDateTranscoder: DateTranscoder {
    func encode(_ date: Date) throws -> String {
        ISO8601DateFormatter().string(from: date)
    }

    func decode(_ dateString: String) throws -> Date {
        if let date = ISO8601DateFormatter().date(from: dateString) {
            return date
        } else if let date = fallbackFormatter.date(from: dateString) {
            return date
        }

        throw DecodingError.dataCorrupted(
            .init(codingPath: [], debugDescription: "Expected date string to be ISO8601-formatted.")
        )
    }
}

The manager gets created with an API client, calls our new method to fetch the profiles, and can take a profile and write it to disk in the directory Xcode looks for. This is great!

Signing the deal

Provisioning profiles are but only one part of the signing process. We also need a certificate which cryptographically signs the profiles and signs our apps too. Let's fetch some certificates!

We'll start again with our model (which is a Certificate resource) and the key information is in its <code>attributes<code> property. Notice the <code>platform<code> attribute of the certificate is our <code>BundleIDPlatform<code> that we built out earlier!

public enum CertificateType: String {
    case iosDevelopment = "IOS_DEVELOPMENT"
    case iosDistribution = "IOS_DISTRIBUTION"
    case macAppDistribution = "MAC_APP_DISTRIBUTION"
    case macInstallerDistribution = "MAC_INSTALLER_DISTRIBUTION"
    case macAppDevelopment = "MAC_APP_DEVELOPMENT"
    case developerIDKext = "DEVELOPER_ID_KEXT"
    case developerIDApplication = "DEVELOPER_ID_APPLICATION"
    case development = "DEVELOPMENT"
    case distribution = "DISTRIBUTION"
    case passTypeID = "PASS_TYPE_ID"
    case passTypeIDWithNFC = "PASS_TYPE_ID_WITH_NFC"
}

public struct Certificate {
    public let name: String
    public let platform: BundleID.Platform
    public let type: CertificateType
    public let content: String

    init?(schema: Components.Schemas.Certificate) {
        guard let name = schema.attributes?.name,
              let type = CertificateType(rawValue: schema.attributes?.certificateType?.rawValue ?? ""),
              let content = schema.attributes?.certificateContent
        else { return nil }

        self.name = name
        self.platform = BundleID.Platform(schema: schema.attributes?.platform)
        self.type = type
        self.content = content
    }
}

We've now got a <code>Certificate<code> type with the necessary details picked out from the returned schema. We reused our <code>BundleID.Platform<code> type from earlier and made a new enum for the different types of certificates that could be returned.

Let's build the API client call next. The endpoint to hit is <code>v1/certificates<code> so that gets added to our OpenAPI config file paths (see the configuration above). Next we'll build the <code>APIClient<code> code.

import Foundation

extension APIClient {
    func fetchActiveCertificates() async throws -> [Certificate] {
        let response = try await client.certificates_hyphen_get_collection()
        switch response {
        case .ok(let ok):
            switch ok.body {
            case .json(let json):
                return Certificate.from(response: json, include: { $0.expiration > Date() })
            }

        default:
            break
        }

        return []
    }
}

extension Certificate {
    static func from(response: Components.Schemas.CertificatesResponse, include: (Certificate) -> Bool) -> [Certificate] {
        response.data.compactMap({ Certificate(schema: $0) }).filter({ include($0) })
    }
}

Like we did with profiles and apps, we can build out a <code>CertificateManager<code> to help fetch the certificates and write them to the keychain.

import Foundation

public final class CertificateManager {
    private let client: APIClient
    public private(set) var certificates = [Certificate]()

    public init(client: APIClient = APIClient()) {
        self.client = client
    }

    public func fetchActiveCertificates() async throws {
        certificates = try await client.fetchActiveCertificates()
    }

    public func writeCertificateToKeychain(_ cert: Certificate) throws {
        // check the keychain to see if the certificate name exists already (SecItemCopyMatching)

        // if the certificate exists, delete it (SecItemDelete)

        // add the certificate (SecItemAdd)
    }
}

The Keychain APIs are difficult to work with. If you have a favorite package already for handling them then it should be able to plug in here just fine, otherwise check out the KeychainServices documentation to fill that in. It's a bit out of scope for this post.

That's a wrap

In this article you've seen how we can use the App Store Connect API to fetch provisioning profiles and signing certificates, just like fastlane match does! There's a lot more the API can do, like creating and deleting/revoking those items. This is the foundation for a very powerful set of tools that can help you centralize your org’s code signing identities so you can integrate them into your workflows precisely how you want to.

‍

Mobile DevOps

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.