Save money when using GitHub Actions for iOS CI/CD

Β·

5 min read

Featured on Hashnode

In this blog post, I share tips on how to use GitHub actions most efficiently and avoid unnecessary execution time and money spending.

I assume you are familiar with GitHub Actions and used them already but here is a quick recap:

  • GitHub Actions is the built-in CI/CD tooling that allows defining workflows and jobs that get triggered on certain GitHub events (e.g. push or pull_request) for your repository.

  • You need to define the type of machine that runs a job. This can be a

    • self-hosted runner (e.g. a local Mac Mini in your home) or

    • GitHub-hosted runner.

There are multiple GitHub-hosted runner types to choose from but often you will need a macOS-based runner image.

So what's the problem?

GitHub Actions on macOS can be expensive

The good news is that using GitHub Actions is free from public repositories.

But beware if you use a private repository!

For private repositories, each GitHub account receives a certain amount of free minutes and storage for use with GitHub-hosted runners, depending on the product used with the account. Any usage beyond the included amounts is controlled by spending limits.

MacOS-based runner images are expensive for GitHub and hence GitHub applies a minute multiplier.

Operating systemMinute multiplier
Linux1
macOS10

Using 1,000 macOS minutes would consume 10,000 minutes included in your account. Once you used all your budget then paying for macOS runners gets expensive because of this multiplier.

Operating systemCoresPer-minute rate (USD)
Linux2$0.008
macOS3$0.08

It's ten times more expensive to use a macOS-based runner image than a Linux-based one.

Source: About billing for GitHub Actions

Tips to save money

Use a Linux-based runner whenever possible

This is a no-brainer but still worth mentioning.

If you want to lint the pull request then you can use a Linux-based runner for that job. ubuntu-latest has Swift installed.

jobs:
  SwiftLint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v1
      - name: GitHub Action for SwiftLint
        uses: norio-nomura/action-swiftlint@3.2.1

Tools like SwiftLint or SwiftFormat do run on Linux as they don't have dependencies on Apple-specific frameworks.

FYI: in January 2023 ubuntu-latest points to the ubuntu-22.04 runner image which comes with Swift 5.7.3 pre-installed.

Run a job at the single time

I don't mean to avoid parallel jobs because it is great that you can define a matrix strategy to automatically create multiple job runs that are based on the combinations of the variables.

Here is an example of building a Swift package on iOS for different Swift versions:

  • Swift 5.7.1 that comes with Xcode 14.1

  • Swift 5.5 that comes with Xcode 13

jobs:
  build-macOS:
    name: Xcode ${{ matrix.xcode }}
    runs-on: ${{ matrix.runner-image }}
    env:
      DEVELOPER_DIR: "/Applications/Xcode_${{ matrix.xcode }}.app/Contents/Developer"
    strategy:
      fail-fast: false
      matrix:
        # https://github.com/actions/runner-images/blob/main/images/macos/macos-12-Readme.md#xcode
        # https://github.com/actions/runner-images/blob/main/images/macos/macos-11-Readme.md#xcode
        include:
          - xcode: "14.1"
            runner-image: macOS-12
            name: "macOS 12, Xcode 14.1, Swift 5.7.1"
          - xcode: "13.0"
            runner-image: macOS-11
            name: "macOS 11, Xcode 13.0, Swift 5.5.0"
    steps:
    - uses: actions/checkout@v3
    - name: Build (${{ matrix.name }})
      run: set -o pipefail && xcodebuild -project -scheme YourPackageName-Package -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 11' clean test | xcpretty

Back to the topic. I mean that you shall avoid the same job, belonging logically to the same group, being executed concurrently. Let me explain with an example:

  • I create a pull request and a workflow gets triggered.

  • You noticed that you forgot to add a file so you push it and add it to your pull request.

  • The previous workflow is still running and a new workflow gets triggered => This is an example of an unnecessary concurrent workflow/job.

For the given example it would be best if GitHub immediately cancels the previous workflow execution.

This is possible by using jobs.<job_id>.concurrency to ensure that only a single job or workflow using the same concurrency group will run simultaneously. A concurrency group can be any string or expression.

Example of using concurrency to cancel any in-progress job or run:

concurrency:
  group: ${{ github.head_ref || github.run_id }}
  cancel-in-progress: true

The popular XcodeProj from Tuist is using the feature for example.

# https://github.com/tuist/XcodeProj/blob/8.8.0/.github/workflows/xcodeproj.yml

concurrency:
  group: xcodeproj-${{ github.head_ref }}
  cancel-in-progress: true

You can find more information in GitHub's documentation Using Concurrency.

Avoid workflow execution with filters

Do you need to build your Swift source code if you only updated your README file?

No! Use the paths filter when you want to consider file path patterns.

I like to define the following workflow in .github/workflows/ci-build.yml

name: ci
on:
  push:
    paths:
      - ".github/workflows/**"
      - "**/*.swift"
  pull_request:
    paths:
      - ".github/workflows/**"
      - "**/*.swift"

Source code-related changes (as well as workflow-related changes) will trigger the workflow but files like README will not trigger the workflow.

Attention: required status checks and skipping workflows

You might have configured status checks so that a workflow has to be successful before a pull request gets merged.

But if a workflow is skipped due to path filtering (or branch filtering or commit message) then checks associated with that workflow will remain "Pending" and the pull request will be blocked from merging.

One solution would be Using conditions to control job execution but I believe GitHub has a better solution.

Let's create an additional workflow defined in .github/workflows/ci-build-skipped.yml

name: ci
on:
  push:
    paths-ignore:
      - ".github/workflows/**"
      - "**/*.swift"
  pull_request:
    paths-ignore:
      - ".github/workflows/**"
      - "**/*.swift" 
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - run: 'echo "No build required"'

By defining an additional workflow with

  • the identical workflow and job name and

  • using the paths-ignore as counterpart

we can ensure that a minimal workflow/job is executed and therefore the status check passes.

Make sure that the name key and required job name in both workflow files are the same.

More information in GitHub documentation Handling skipped but required checks.

Did you find this article valuable?

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