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.