Cutting Costs with GitHub Actions: Efficient CI Strategies

Cutting Costs with GitHub Actions: Efficient CI Strategies

While using GitHub Actions can be costly, it is possible to save monetary resources by developing the right strategy. In this article, you'll find a list of all the tips and tricks we rely on at Mergify to keep our CI budget under control with GitHub Actions.

Julien Danjou

GitHub Actions (GHA) provides an automation platform allowing users to build, test, and deploy their code right from GitHub repositories. It offers a robust solution to implement Continuous Integration (CI) and Continuous Deployment (CD), thus making the software development process more efficient and streamlined.

However, utilizing GitHub Actions can be costly, as it is billed by the minute. Pricing varies, depending largely on the operating system chosen and other parameters. Therefore, strategizing and optimizing GitHub Actions can significantly save monetary resources.

Most articles on the Internet explain how to make your CI go faster by parallelizing jobs or speeding up your test suite. That's great, but that focuses on a different metric (runtime) than the one we're talking about here (money).

GitHub Actions pricing base

Below you will find a list of all the tips and tricks that we rely on at Mergify to keep our CI budget under control with GitHub Actions.

1. Group Short Jobs

GHA billing is precise: each started minute is billed as full. Thus, running ten 1-second jobs will be billed as ten minutes. Therefore, grouping short jobs into a single job can save notable resources.

For instance, ten 2-3 minute jobs can cost double compared to two 10-15 minute jobs due to the rounding up to the nearest minute: you'll pay 10 times the "last minute" rather than paying it only twice.

Based on 10 pushes a day for a developer and an average price per minute of $0.01, this small alteration can result in savings of around $15/month/developer based on average usage.

That amount could cover an extra SaaS service like Mergify.

2. Cancel Previous Jobs

Frequent code pushes triggering new workflow runs are common. That's fine: you want feedback on your newly pushed code.

But do you really care about getting feedback on the old version of your pull request? This happens by default, as GitHub does not cancel the ongoing GitHub Actions jobs when you push new code.

When you have a long end-to-end test, canceling the ongoing workflow once a new one is initiated can save substantial costs. GHA's concurrency setting aids in canceling in-progress jobs for the current branch when a new commit is pushed, avoiding redundant workflow runs.

concurrency:
  group: ${{ github.ref }}
  cancel-in-progress: true
Using concurrency - GitHub Docs
Run a single job at a time.

3. Fail Your Matrix Early

When using a matrix in jobs, GitHub runs multiple tests for different OS or language versions, depending on your use case. If one fails, running the remaining can be seen as wasteful, especially when a merge is unlikely with failed tests.

Consider using jobs.<job_id>.strategy.fail-fast to stop further tests when one fails, optimizing both time and costs. This allows GitHub Actions to cancel all running matrix jobs when one of them fails, saving precious CI time and money.

4. Chain Your Jobs

Running all jobs in a workflow in parallel can be costly. Typically, your jobs list looks like:

  • linter;
  • unit tests;
  • functional tests.

All run in parallel. However there might be no point in running unit and functional tests if the linter spots a crude syntax error. Ensuring linting passes before running unit tests, and then proceeding to more comprehensive end-to-end tests, can prevent unnecessary expenditure on faulty code.

Chaining jobs using the needs keyword helps ensure that subsequent jobs are dependent on the success of the preceding ones. This approach saves money and avoids wasting resources on extensive tests if there are fundamental errors in the code.

Workflow syntax for GitHub Actions - GitHub Docs
A workflow is a configurable automated process made up of one or more jobs. You must create a YAML file to define your workflow configuration.

For example, the above scenario could be changed to:

  • linter;
  • unit tests (need linter to pass);
  • functional tests (need linter to pass).

This would ensure no time is wasted running tests if the code does not pass the linter.

5. Leverage a Merge Queue

A merge queue can delay costly checks until they are absolutely necessary, conducting minimal sanity checks like linting and unit tests initially. When it’s time to merge, the full test suite is run to ensure robustness. This occurs only before the merge and after approval, maximizing the efficiency of CI time.

What’s a Merge Queue and Why Use it?
What is a Merge Queue? And when is it useful? This article provides the answers to both questions, and explains the concept of a Merge Queue in detail. A must-have for most developers.

The typical scenario looks like the one described above, but with more delays added. Rather than running all your jobs on each pull request creation and update, you'd typically run the linter and unit tests, but delay running the costly functional tests until the pull request is ready to be merged and has been approved by humans.

When it's ready to be merged, the pull request is enqueued by a developer, and the merge queue runs the final CI tests before the merge. This means the costly tests are not run in the usual developer workflow of creating and updating the pull request.

A merge queue can also test multiple pull requests simultaneously once they enter the queue, optimizing resource utilization even further.

6. Optimize Through Caching

Caching dependencies is a straightforward way to speed up workflows, thereby reducing the runtime and associated costs. Efficient caching is crucial for optimizing the workflow duration and, subsequently, the expenses.

Caching dependencies to speed up workflows - GitHub Docs
To make your workflows faster and more efficient, you can create and use caches for dependencies and other commonly reused files.

7. Select the Proper Runner

Choosing the right runner, with the appropriate number of CPUs, is crucial. An inappropriate runner choice can lead to either overspending for excessive resources or underperforming due to insufficient resources, slowing down the process and increasing costs indirectly.

8. Run Workflows Sensibly

Triggering workflows only when necessary, utilizing on.paths, can save considerable resources. For instance, there's no need to run a JS linter if only Python code has been altered in a multi-language repository. Such careful selections ensure that workflows are not triggered unnecessarily, avoiding wasteful runs and controlling costs.

Workflow syntax for GitHub Actions - GitHub Docs
A workflow is a configurable automated process made up of one or more jobs. You must create a YAML file to define your workflow configuration.

Conclusion

GitHub Actions, while powerful, requires strategic utilization to ensure cost-effectiveness. The intricate balance between speed, quality, and expenditure necessitates thoughtful optimization of workflows. By grouping short jobs, canceling unnecessary runs, failing matrices early, chaining jobs appropriately, leveraging merge queues, optimizing caching, selecting the right runners, and running workflows sensibly, developers can significantly cut down on CI time and associated costs while maintaining high-quality development standards.

Remember, it's not just about cutting costs; it's about efficient resource management. Allocating resources wisely ensures financial savings and a more streamlined and effective development process. Balancing the trade-offs between cost, time, and quality is key to maximizing the benefits of GitHub Actions in any development environment.