Distribute binary frameworks in Swift Packages and how to automate the process

Distribute binary frameworks in Swift Packages and how to automate the process

Β·

5 min read

What are binary frameworks in Swift

Apple introduced a new way of building and distributing binary frameworks with Xcode 11.

The new XCFramework bundle type allows

  • module-stable frameworks: binaries don't break once a new Siwft is released.
  • multi-platform support: devices running iOS, macOS, tvOS, and watchOS + Simulator builds.

Apple's documentation covers the basics of the what and how but I found this community-driven repository most helpful.

How and why to distribute such framework(s) as Swift package(s)

With Xcode 12 it is possible to distribute binary frameworks as Swift packages. The documentation from Apple is actually pretty good and gives a clear warning:

Carefully consider whether you want to distribute your code in binary form because doing so comes with drawbacks. For example, a Swift package that contains a binary is less portable because it can only support platforms that its included binaries support. In addition, binary dependencies are only available for Apple platforms, which limits the audience for your Swift package.

My use case is actually not to create a Swift Package which then relies on code from binary framework(s). In my organization, we maintain several frameworks. Some of them will be open-sourced as Swift Packages. Some of them won't. But those non-open-sourced frameworks (= .xcframework) can still be distributed via Swift Package Manager (SPM). The advantage is that all frameworks can be distributed and consumed in a harmonized way.

We want to keep things simple:

  • Each Swift Package, independent of if it contains source code or a binary framework, shall be hosted in its own GitHub repository.
  • Hence a Swift Package wrapping a binary framework has a single product with a single binary target.
  • The GitHub repository gets tagged with release(s) so that consumers can pin to a particular version of the Swift Package.
// swift-tools-version:5.3
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let version = "1.8.0"

let package = Package(
    // ...
    targets: [
        .binaryTarget(name: "<ModuleName>", url: "https://<url>/\(version)/<ModuleName>.xcframework.zip",
                      checksum: "4aa868a8d68dc28c72f564c7a2123654c91da5ff1b6483fb773b97f069e831d4"),
    ]
)

Challenges to keep them up-to-date

If a new binary framework was generated then this needs to be reflected in the respective Swift package. This can be considered a hassle as multiple steps have to be executed:

  • update version information used in URL of binary target pointing to the new xcframework.zip
  • update checksum in the binary target (i.e downloading latest xcframework.zip and run swift package compute-checksum <PathToDownloadedXcframwork.zip>)
  • verify build
  • commit changes
  • create a new release for the GitHub repository

Solution Proposal

Several steps can be automated with help of GitHub actions(s).

I'll start with defining a workflow that can be triggered manually (workflow_dispatch).

name: Manual workflow

on:
  workflow_dispatch:
    inputs:
      newVersion:
        description: 'New Version'
        default: '1.8.1'
        required: true

jobs:

  updateBinaryTarget:
    runs-on: macos-latest

    steps:
    - uses: actions/checkout@v2
    - name: Update Package.swift
      run: |
        ./scripts/updateChecksum.sh ${{ github.event.inputs.newVersion }}
    - name: Create Pull Request
      uses: peter-evans/create-pull-request@v3

The user who triggers the action has to enter the new version number (a default version is supplied).

The first step is to checkout (a.k.a clone) the repository so that the Package.swift file is availabe.

In the second step I am using a shell script to download the binary framework of that version, calculate the checksum, and update Package.swift with new checksum and version information.

The content of the shell script is specific to this repository but can be tweaked relatively easy assuming the package manifest

  • has a single binary target and
  • makes use of let version = "versionInfo" notation to drive the download.

It could even be further generalized but for the sake of illustration, the current code should be sufficient.

#!/bin/sh

# Opiniated shell script to update version and checksum information for a binary target in a Swift Package
# Script assumes that a Swift Package has
# - a statement `let version = "<version>"`
# - only 1 binary target

# also the paths and naming pattern of the zip file is hard-coded so the shell script needs to be adjusted for other packages

# Check for arguments
if [ $# -eq 0 ]; then
    echo "No arguments provided. First argument has to be version, e.g. '1.8.1'"
    exit 1
fi
# assuming this script is executed from directory which contains Package.Swift
# take version (e.g. 1.8.1) as argument
NEW_VERSION=$1
# download new zip file
curl -L -O https://eidinger.info/PLCrashReporterXCFrameworks/$NEW_VERSION/CrashReporter.xcframework.zip --silent
# calculate new checksum
NEW_CHECKSUM=$(swift package compute-checksum CrashReporter.xcframework.zip)
# print out new shasum for convenience reasons
echo "New checksum is $NEW_CHECKSUM"
# replace version information in package manifest
sed -E -i '' 's/let version = ".+"/let version = "'$NEW_VERSION\"/ Package.swift
# replace checksum information in package manifest
sed -E -i '' 's/checksum: ".+"/checksum: "'$NEW_CHECKSUM\"/ Package.swift
# print out package manifes for convenience reasons
cat Package.swift
# delete downloaded zip file
rm CrashReporter.xcframework.zip

I disagree with the notion that zipped xcframework filename should contain the version number. I recommend that the version is used in the URL path. Especially since I was not able to use a zipped xcframework filename with version information which does not match with the artifact module name. But maybe that's an Xcode 12.4 bug .. who knows ..

That explains why I am hosting xcframworks from a well known crash reporter framework for my example and why I am not using their zipped xcframeworks like PLCrashReporter-XCFramework-1.8.1.zip ;)

As third step, I am using a community GitHub action to create a pull request with the changes. A PR can be used to trigger workflows, e.g. to verify the build.

But the changes to Package.swift could also be automatically committed with a new release.

Complete source code is available here:

Did you find this article valuable?

Support Swifty Tech by Marco Eidinger by becoming a sponsor. Any amount is appreciated!