Tiny Bit: Keep Github caches hot by building main

GitHub Actions falls back to the default branch cache when there is no cache available for the current branch.

Intro

A small bite about making your GitHub Actions workflows a little faster.

Whenever I created a new Pull Request, I noticed that the first build was surprisingly slow. Looking at the logs, I found a cache miss. This was slightly confusing because I had just merged a PR with exactly the same dependencies.

So what does GitHub actually do with caches?

When GitHub tries to restore a cache, it first looks for a cache belonging to the current branch. If it cannot find one, it falls back to the default branch, usually main or master.

Why does GitHub do this?

I suspect this is partly a security feature, especially for open source projects. If caches were freely shared between arbitrary builds, someone could potentially open a Pull Request and poison a cache with malicious files. Those files could then end up being used by builds from other branches.

Instead, GitHub assumes that you can trust the cache from your default branch, while caches from other branches are isolated.

This has one slightly annoying consequence: a brand-new PR starts with a cold cache.

Keep main hot

The simplest way to fix this is also the most boring one.

Build main.

Every time you merge a PR, run the build again on main. This populates the cache for the default branch, which means that the next PR can use it immediately.

You are essentially paying for one extra build in exchange for not making every new PR start from a cold cache.

Bobby: The minimum charge of a GitHub Actions job is one minute? So three jobs that take ten seconds each cost you three minutes of Actions time. It’s better to bundle short jobs and share the setup to reduce costs.

Nice fact, Bobby. Let’s aim for a build of two minutes, and get back to fixing these cold caches.

In your GitHub Actions workflow, you can make sure the build also runs when something is pushed to main:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
on:
  # Run the build when something is pushed to main.
  push:
    branches:
      - main

  # Run the build for PRs targeting main.
  pull_request:
    branches:
      - main

Now every merge keeps the main cache warm.

But there is another cache problem that is worth fixing.

Don’t throw away useful caches

Let’s take a simple GitHub Actions workflow as an example.

We pull Python dependencies using uv, then run type checks and tests.

The first version of the workflow might look something like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
name: Pulling dependencies

on: push

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Install uv
        uses: astral-sh/setup-uv@v5

      - name: Restore uv cache
        id: uv-cache
        uses: actions/cache@v4
        with:
          path: /tmp/.uv-cache
          key: uv-${{ runner.os }}-${{ hashFiles('uv.lock') }}
          restore-keys: |
            uv-${{ runner.os }}-${{ hashFiles('uv.lock') }}
            uv-${{ runner.os }}            

      - name: Install dependencies
        # Skip if cache hit
        if: steps.uv-cache.outputs.cache-hit != 'true'
        run: uv sync --all-groups

      - name: Run the type checker
        run: uv run mypy

      - name: Run tests
        run: uv run pytest

This works, but there is a small problem.

actions/cache@v4 only saves a cache when the job succeeds.

So imagine uv sync takes 30 seconds, but pytest fails.

You fix the test and push again.

The entire job runs again, including the 30-second dependency installation, because the previous run never saved its cache.

We can avoid this by separating restoring and saving the cache.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
name: Pulling dependencies

on: push

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Install uv
        uses: astral-sh/setup-uv@v5

      # Restore the cache
      - name: Restore uv cache
        id: uv-cache-restore
        uses: actions/cache/restore@v4
        with:
          path: /tmp/.uv-cache
          key: uv-${{ runner.os }}-${{ hashFiles('uv.lock') }}
          restore-keys: |
            uv-${{ runner.os }}-${{ hashFiles('uv.lock') }}
            uv-${{ runner.os }}            

      - name: Install dependencies
        run: uv sync --all-groups

      # Save the cache independently of the rest of the job.
      - uses: actions/cache/save@v4
        id: cache
        with:
          path: /tmp/.uv-cache
          key: uv-${{ runner.os }}-${{ hashFiles('uv.lock') }}

      - name: Run the type checker
        run: uv run mypy

      - name: Run tests
        run: uv run pytest

The workflow is slightly more verbose, but now uv sync can populate the cache even when mypy or pytest subsequently fails.

That makes a difference when you are iterating on a broken PR. You don’t want a failing test at the end of the workflow to make the dependency installation at the beginning useless.

Conclusion

There are two simple tricks here.

First, build main. This keeps the default branch cache warm so that new PRs don’t have to start from scratch.

Second, save useful caches independently from the rest of the build. A failing test should not throw away the perfectly good dependency download that happened five minutes earlier.

Neither of these is particularly complicated. They just make the cache behave more like what you probably expected it to do in the first place.

And that means fewer cold builds, faster feedback, and less staring at GitHub Actions waiting for uv sync to download the same packages for the fifth time.

Licensed under CC BY-NC-SA 4.0
Built with Hugo
Theme Stack designed by Jimmy