Skip to content

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 }}
  1. COMMIT_SHA
    This environment variable will contain the head commit ID of the branch we are operating on, both for push or pull_request events. For a pull_request trigger it uses github.event.pull_request.head.sha to get the reference of the head commit and for the push trigger it uses github.sha.
  2. BRANCH
    This environment variable also serves as a workaround for information that is readily available for push triggers, but less so for pull_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...

Image of the author

Konstantin Tieber

Visit author page

Image of the author

Rene Hamburger

Visit author page