Halve Your GitHub Actions Bill
How we switched from "push" to "pull_request" event triggers in our workflows to reduce the GitHub Action minutes spent on feature branches significantly.
The most "GitHub Action minute hungry" repository in our LeanIX organization by far is our Nx monorepo containing our Angular frontend applications. At the time of writing, we are deploying one larger shell application and 13 micro frontends from this repository.
In the past month 42 authors have merged ~1,500 commits from ~220 pull requests.
In a previous post we already talked about how we optimized the efficiency of our CI checks by improving the way we identify which projects are affected by the code changes of a feature branch and should therefore be checked.
Being mindful of computing resources is not just good for the company wallet, but also for the planet (GreenIX). In an effort to further reduce our GitHub Action minutes usage, we decided that we would skip CI checks for any feature branch that doesn't belong to an open pull request which is "ready for review".
This was done by a switch from an on: [push]
trigger, which was used in most of our workflows, to a combination of push
and pull_request
triggers, where push
would now only trigger for our main
branch.
By doing this, no GitHub workflows are running on our feature branches until they are associated with a Pull Request that is marked as "ready for review".
There are some pitfalls to watch out for when switching from the push
to the pull_request
event, which we will highlight in this post.
Too long; didn't read (TLDR)
You know your stuff about GitHub workflows and just want to quickly see the high-level workflow configuration. Then this code snippet is for you:
name: Runs on main branch or on open pull requests that are ready for review
on:
push:
branches:
- main
pull_request:
types: [opened, reopened, synchronize, ready_for_review]
env:
COMMIT_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
BRANCH: ${{ github.event_name == 'pull_request' && format('refs/heads/{0}', github.event.pull_request.head.ref) || github.ref }}
jobs:
some-job:
name: Continuous Integration
# This condition ensures that this job will not run on pull requests in draft state
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: ubuntu-latest
steps:
- name: Check out head commit
uses: actions/checkout@v2
with:
ref: ${{ env.BRANCH }}
# ...
If this code doesn't convey much meaning to you yet, don't worry, we'll get there!
Now let's dive deeper into this topic within the context of our monorepo.
Only running GitHub workflows for open pull requests that are ready for review
The following code block shows a shortened version of our new frontend application workflow blueprint, which is saving us a significant percentage of our GitHub Action minutes by only running on open pull requests that are not in a "draft" state.
Notice the uses
property on the affected-check
job. This tells our workflow to trigger our reusable-affected-check.yml via the workflow_call
event. Reusable workflows allow us to reduce code duplication by extract workflow code that is common among multiple workflows. You can read more about them on the GitHub documentation.
Hold on a second, I need to know how the syntax of GitHub workflows works first!
# Workflow of an individual Angular application in our monorepo
name: Diagrams
# The types of events that will trigger this workflow.
# This workflow will be triggered by a push on the "main" branch
# or by a specific list of pull_request events.
on:
push:
branches:
- main
pull_request:
types: [opened, reopened, synchronize, ready_for_review]
# The jobs to execute when this workflow is triggered
jobs:
affected-check:
name: Check if affected
uses: ./.github/workflows/reusable-affected-check.yml
with:
project: diagrams
# This job will not run if the affected-check failed or if its "isAffected" output is "false"
ci-checks:
runs-on: ubuntu-latest
needs: [affected-check]
if: needs.affected-check.outputs.isAffected == 'true'
steps:
# ...
Let's have a look at the code for our reusable affected check referenced in the workflow above. It uses our nx-affected-dependencies-action under the hood to check whether the given project has been affected by the code changes in the current branch and which of its dependencies (i.e., Nx libraries) have also been affected.
Especially pay attention to the COMMIT_SHA
and BRANCH
environment variables we are defining here.
# Reusable workflow to check if a given project is affected
name: Reusable affected check
on:
workflow_call:
inputs:
project:
required: true
type: string
outputs:
isAffected:
description: "Indicates if the provided project is affected"
value: ${{ jobs.affected.outputs.isAffected }}
affectedDeps:
description: "List of affected project dependencies concatenated with comma (including the current project if affected)"
value: ${{ jobs.affected.outputs.affectedDeps }}
env:
# ${{ github.sha }} does not point to the HEAD of the branch on a workflow
# that was triggered via the 'pull_request' event.
# Instead you need to use ${{ github.event.pull_request.head.sha }}.
# This COMMIT_SHA enables us to have one variable
# to get the HEAD commit of the branch for all our workflow triggers.
COMMIT_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
# The BRANCH environment variable enables us to have one variable
# for all our workflow triggers to get the branch reference name
# in a consistent format.
BRANCH: ${{ github.event_name == 'pull_request' && format('refs/heads/{0}', github.event.pull_request.head.ref) || github.ref }}
jobs:
affected:
name: Check if affected
# This if statement is doing the magic here! 🔮
# This job will only run if it was triggered
# by a push on master or a push on a non-draft pull request
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: ubuntu-latest
outputs:
isAffected: ${{ steps.affected.outputs.isAffected }}
affectedDeps: ${{ steps.affected.outputs.affectedDeps }}
steps:
# ...
And that's it! These two code blocks hopefully contain all the info required for people who have worked with GitHub workflows before to start using the pull_request
trigger to only run their workflows on open pull requests that are ready for review, and thereby reduce their GitHub Actions bill.
The remaining content of this article aims to provide a more detailed explanation of what is happening in such a workflow.
GitHub workflow syntax explained
In this section, we'll explain the workflow syntax used in this post. For a more complete overview of the GitHub workflow syntax, see the official documentation.
Workflows are defined in YAML. Each workflow usually starts with a name
property. This one doesn't need much explaining.
This leaves three top-level workflow properties that we need to explain:
Workflow triggers
Inside the on
configuration, we can define which GitHub events our workflow should react to. You can find the full reference of workflow-triggering events here.
Let's analyze the triggers of a standard frontend application in our monorepo:
on:
push:
branches:
- main
pull_request:
types: [opened, reopened, synchronize, ready_for_review]
In natural language this workflow trigger would be defined as the following:
Run this workflow in case...
- any commits are pushed to the branch named
main
- a pull request is opened
- a pull request is reopened
- the head branch of the pull request is updated, e.g., by a push for that branch
- a "draft" pull request is changed to "ready for review"
For the full list of available "activity types" on the pull_request
event, see this section in the official documentation. However for our use case, [opened, reopened, synchronize, ready_for_review]
is exactly what we need.
Our former - more expensive - workflow configuration was as simple as on: [push]
, which means "Run this workflow in case any commit is pushed to any branch".
Environment variables
Environment variables are available to all jobs in your workflow.
Our Reusable affected check
workflow defines two environment variables:
env:
COMMIT_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
BRANCH: ${{ github.event_name == 'pull_request' && format('refs/heads/{0}', github.event.pull_request.head.ref) || github.ref }}
- COMMIT_SHA
This environment variable will contain the head commit ID of the branch we are operating on, both forpush
orpull_request
events. For apull_request
trigger it usesgithub.event.pull_request.head.sha
to get the reference of the head commit and for thepush
trigger it usesgithub.sha
. - BRANCH
This environment variable also serves as a workaround for information that is readily available forpush
triggers, but less so forpull_request
triggers: the branch reference. We commonly need the branch reference to check out the code of our repository using the actions/checkout action.
Jobs
A workflow consists of one or more jobs. By default, they will run in parallel. It's good to keep in mind here that GitHub bills every started minute though. So when you have five parallel jobs, which each finish after 10 seconds, you still pay for 5 minutes, not 1.
Here's another example of a typical job configuration of a frontend application in our monorepo:
jobs:
affected-check:
name: Check if affected
uses: ./.github/workflows/reusable-affected-check.yml
with:
project: self-configuration
ci-checks:
runs-on: ubuntu-latest
needs: [affected-check]
if: needs.affected-check.outputs.isAffected == 'true'
steps:
- name: Check out head commit
uses: actions/checkout@v2
with:
ref: ${{ env.BRANCH }}
# ...
Using the needs
configuration on jobs, you can make them run sequentially and also access outputs from the jobs you depend on.
Most of the time, a job will define the scripts or actions to be executed inside its steps
configuration. With the uses
keyword a job can reference another reusable workflow, which needs to implement the workflow_call
event trigger.
A job will not run, if its if
condition is not true. When a job "B" depends on a job "A", which did not run because of a falsy if
condition, job "B" will also be skipped.
The icing on the cake
Right now there's no way to define a default state between "draft" and "ready for review" for new pull requests. We have to rely on people to press the right button.
There is an open discussion on the GitHub feedback repository to enable setting a default state for new pull requests: github.com/github/feedback/discussions/6943. If you'd like to see this feature land on GitHub, please upvote that discussion.
In our teams we have agreed on creating all pull requests in this monorepo in draft mode first and added the following notice to the pull_request_template.md
to keep this in mind:
Please consider creating this PR in draft mode to keep GitHub action minutes usage low – to save 🌎 & 💰 – and only marking it as "ready for review" when you need the checks to run. A draft PR can even be reviewed while in draft mode, but you'll need to notify the reviewer(s) yourself.
Congratulations, you've reached the end of this blog post! Thanks for reading this far. We hope some of it was helpful to you.
Footnotes
1. The extend of the action minute reduction achieved by the approach outlined in this article depends on multiple factors, such as the average pull request size, the number of pushes per pull request and the extend to which developers adopt the new draft-PR approach. We have been achieving about a reduction of about 30% but believe that 50% are easily within reach for many teams. ↑
2. Branch pushes originating from a workflow will never trigger another workflow run (as this could lead to infinite loops). ↑
Published by...