My Swift Package Manager Release Workflow

I am currently maintaining numerous Swift Packages that don't receive a constant flow of updates, but do receive updates when new Swift updates come out, or as I think of useful additions.

To ensure that I can make some of these less frequent updates without too much friction and with confidence in their correctness I rely heavily on GitHub Actions, which I'll go over in this blog post.

I have 2 workflows that I use across my projects, one for running tests and the other for performing releases.

Tests Workflow

The tests workflow runs on every commit.

name: Tests

on: [push]

jobs:
macos_tests:
name: macOS Tests (SwiftPM)
runs-on: macos-latest
strategy:
fail-fast: false
matrix:
xcode: ["11.4"]

steps:
- uses: actions/checkout@v2

- name: Select Xcode ${{ matrix.xcode }}
run: sudo xcode-select --switch /Applications/Xcode_${{ matrix.xcode }}.app

- name: Cache SwiftPM
uses: actions/cache@v1
with:
path: .build
key: ${{ runner.os }}-xcode_${{ matrix.xcode }}-swiftpm-deps-${{ github.workspace }}-${{ hashFiles('Package.resolved') }}
restore-keys: |
${{ runner.os }}-xcode_${{ matrix.xcode }}-swiftpm-deps-${{ github.workspace }}

- name: SwiftPM tests
run: swift test --enable-code-coverage

- name: Convert coverage to lcov
run: xcrun llvm-cov export -format="lcov" .build/debug/PersistPackageTests.xctest/Contents/MacOS/PersistPackageTests -instr-profile .build/debug/codecov/default.profdata > coverage.lcov

- name: Upload coverage to Codecov
uses: codecov/codecov-action@v1
with:
fail_ci_if_error: true

xcode_tests:
name: ${{ matrix.platform }} Tests (Xcode)
runs-on: macos-latest
strategy:
fail-fast: false
matrix:
xcode: ["11.4"]
platform: ["iOS", "tvOS"]

steps:
- uses: actions/checkout@v2

- name: Select Xcode ${{ matrix.xcode }}
run: sudo xcode-select --switch /Applications/Xcode_${{ matrix.xcode }}.app

- name: Cache SwiftPM
uses: actions/cache@v1
with:
path: CIDependencies/.build
key: ${{ runner.os }}-xcode_${{ matrix.xcode }}-swiftpm-ci-deps-${{ github.workspace }}-${{ hashFiles('CIDependencies/Package.resolved') }}
restore-keys: |
${{ runner.os }}-xcode_${{ matrix.xcode }}-swiftpm-ci-deps-${{ github.workspace }}

- name: Cache DerivedData
uses: actions/cache@v1
with:
path: ~/Library/Developer/Xcode/DerivedData
key: ${{ runner.os }}-${{ matrix.platform }}_derived_data-xcode_${{ matrix.xcode }}
restore-keys: |
${{ runner.os }}-${{ matrix.platform }}_derived_data

- name: Run Tests
run: swift run --configuration release --skip-update --package-path ./CIDependencies/ xcutils test ${{ matrix.platform }} --scheme Persist --enable-code-coverage

- name: Upload coverage to Codecov
uses: codecov/codecov-action@v1
with:
fail_ci_if_error: true

watchos_build:
name: watchOS Build (Xcode)
runs-on: macos-latest
strategy:
fail-fast: false
matrix:
xcode: ["11.4"]

steps:
- uses: actions/checkout@v2

- name: Select Xcode ${{ matrix.xcode }}
run: sudo xcode-select --switch /Applications/Xcode_${{ matrix.xcode }}.app

- name: Cache SwiftPM
uses: actions/cache@v1
with:
path: CIDependencies/.build
key: ${{ runner.os }}-xcode_${{ matrix.xcode }}-swiftpm-ci-deps-${{ github.workspace }}-${{ hashFiles('CIDependencies/Package.resolved') }}
restore-keys: |
${{ runner.os }}-xcode_${{ matrix.xcode }}-swiftpm-ci-deps-${{ github.workspace }}

- name: Cache DerivedData
uses: actions/cache@v1
with:
path: ~/Library/Developer/Xcode/DerivedData
key: ${{ runner.os }}-watchOS_derived_data-xcode_${{ matrix.xcode }}
restore-keys: |
${{ runner.os }}-watchOS_derived_data

- name: Build for watchOS
run: swift run --configuration release --skip-update --package-path ./CIDependencies/ xcutils build watchOS --scheme Persist

linux_tests:
name: SwiftPM on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-16.04, ubuntu-latest]
swift: ["5.2.3"]

steps:
- uses: actions/checkout@v2

- name: Install swiftenv
run: |
eval "$(curl -sL https://swiftenv.fuller.li/install.sh)"
echo "::set-env name=SWIFTENV_ROOT::$HOME/.swiftenv"
echo "::add-path::$SWIFTENV_ROOT/bin:$PATH"

- name: swift test
run: swift test --enable-test-discovery

The tests are split in to 4 sections:

macOS tests, which are run via swift test

iOS and tvOS tests, which are run via Xcode

watchOS build, which is run via Xcode but does not run tests because tests do not work on watchOS

Linux tests, which are run via swift test on Ubuntu

macOS, iOS, and tvOS tests gather test coverage and upload it to Codecov, which provides some insight it to how much new code is covered by tests.

For the iOS and tvOS tests, along with the watchOS build, I used xcutils. xcutils is another tool of mine that is used to improve the CLI of Xcode. Here it is used to run the tests/build against the latest versions of iOS/tvOS/watchOS, which means it should work on any machine and is resistant to changes made by GitHub.

On Linux the --enable-test-discovery flag is passed to swift test to remove the need for a LinuxMain.swift file that much be kept in sync with the tests.

These tests have helped me match many mistakes before merging, especially for platforms such as watchOS and Linux that are less frequently used.

Release Workflow

The release workflow is triggered by the creation of a git tag that starts with a v.

name: Release

on:
push:
tags:
- "v*"

jobs:
create_release:
name: Create Release
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v2

- name: Fetch tag
run: git fetch --depth=1 origin +${{ github.ref }}:${{ github.ref }}

- name: Get the release version
id: release_version
run: echo "::set-output name=version::${GITHUB_REF/refs\/tags\//}"

- name: Get release description
run: |
description="$(git tag -ln --format=$'%(contents:subject)\n\n%(contents:body)' ${{ steps.release_version.outputs.version }})"
# Fix set-output for multiline strings: https://github.community/t/set-output-truncates-multiline-strings/16852
description="${description//'%'/'%25'}"
description="${description//$'\n'/'%0A'}"
description="${description//$'\r'/'%0D'}"
echo "$description"
echo "::set-output name=description::$description"
id: release_description

- name: Create Release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ steps.release_version.outputs.version }}
release_name: ${{ steps.release_version.outputs.version }}
body: ${{ steps.release_description.outputs.description }}
prerelease: ${{ startsWith(steps.release_version.outputs.version, 'v0.') || contains(steps.release_version.outputs.version, '-') }}

build_docs:
name: Build Docs
runs-on: macos-latest
strategy:
fail-fast: false
matrix:
xcode: ["11.4"]

steps:
- uses: actions/checkout@v2

- name: Select Xcode ${{ matrix.xcode }}
run: sudo xcode-select --switch /Applications/Xcode_${{ matrix.xcode }}.app

- name: Setup Ruby
uses: ruby/setup-ruby@v1

- uses: actions/cache@v1
with:
path: vendor/bundle
key: ${{ runner.os }}-gems-${{ hashFiles('.ruby-version') }}-${{ hashFiles('**/Gemfile.lock') }}
restore-keys: |
${{ runner.os }}-gems-${{ hashFiles('.ruby-version') }}-

- name: Bundle install
run: |
bundle config path vendor/bundle
bundle install --jobs 4 --retry 3

- name: Build docs
run: bundle exec jazzy

- name: Upload Docs
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: docs

The first job creates a GitHub release for the tag. The release uses the contents of the tag as the body for the release, which allows for markdown, so I write the body of the tag using markdown to benefit from improved rendering on the GitHub website. The Get release description step modifies the body by escaping new lines and % characters. This is required to prevent the output being truncated. See https://github.community/t/set-output-truncates-multiline-strings/16852.

Since my releases follow sematic versioning 2.0.0 if the release starts with v0. or contains a - the release is marked as pre-release.

The second job runs jazzy to build HTML docs and uploads it to a gh-pages branch, which is configured to be deployed automatically by GitHub, and also provides a badge displaying the percentage of public code that is documented.

Final Thoughts

With these workflows in place I can make a change, add some tests, push, create a pull request, merge, and tag a new release with confidence and all within a couple of hours.

Since this workflow will be receiving small tweaks over time and I may not remember to update this workflow straight away (maybe I should make a workflow for that 🤪) you should check out the Persist workflows to find my latest changes.