Skip to main content

Shorter GitHub Actions runs

·4 mins·

This is just a short note about something that was not possible to do as cleanly before.

If you worked with GitHub Actions for a meaningful period of time, and especially for testing Rust code, you will know that Windows runners are really slow. They are easily the bottleneck in many workflows and until recently there was no good way around it without turning the whole workflow into a mess. Thankfully, GitHub Actions recently introduced support for Yaml anchors in workflow files, which allows to reuse parts of the workflow instead of copy-pasting them.

For context, I had a job definition that looked something like this:

  cargo-test:
    strategy:
      matrix:
        os:
          - ubuntu-24.04
          - ubuntu-24.04-arm
          - macos-15
          - windows-2025
        miri:
          - true
          - false
        type:
          - together
          - features
          - guest-feature
        exclude:
          - os: macos-15
            type: guest-feature
          - os: windows-2025
            type: guest-feature
    runs-on: ${{ matrix.os }}
    steps:
    # Many steps here

It is testing code on several operating systems, with/without Miri and already split into several parts to parallelize the tests, but that was still kind of slow.

The slowest permutation out of the above are together type that compiles all tests with default features together and runs them all. Other variants test certain things more selectively and are significantly shorter. On Windows specifically, it was usually taking over 17 minutes, but it is relatively slow on other operating systems as well.

So we can’t run CI faster than 17 minutes, but the whole workflow actually took 20 to 21 minutes in practice. This is because there are limits on the number of free runners given, and it just so happened that the slow job on Windows was starting after some of the faster runs were complete (completely undeterministic process though).

The way to fix it would be to make sure these slower jobs start first, and then faster jobs start later in whatever order they like since they are likely to complete long before the slowest job anyway. GitHub Actions supports job dependencies, so I came up with this small helper job:

  cargo-test-slow-head-start:
    runs-on: ubuntu-24.04
    steps:
      - name: Artificial delay
        run: sleep 5

And then wanted to add it to dependencies for non-Windows jobs to give the Windows job a head start:

    runs-on: ${{ matrix.os }}
    needs: ${{ contains(matrix.os, 'windows') && fromJSON('[]') || 'cargo-test-slow-head-start' }}

And… that didn’t work.

This would have been a clean solution, but unfortunately, it is not supported yet. Since I was not looking forward to duplicating the large steps section into a separate Windows-specific job, I shelved the idea for a few months.

Solution
#

But last week GitHub Actions introduced support for Yaml anchors, so I could annotate anything in the workflow file and reference it later without copying. Here is how it looks now:

  cargo-test:
    strategy:
      matrix:
        os:
          - ubuntu-24.04
          - ubuntu-24.04-arm
          - macos-15
          - windows-2025
        miri:
          - true
          - false
        type:
          # `together` variant is running in a separate job, see `cargo-test-slow`
          # - together
          - features
          - guest-feature
        exclude:
          - os: macos-15
            type: guest-feature
          - os: windows-2025
            type: guest-feature

    runs-on: ${{ matrix.os }}
    # Gives the slow cargo test jobs a head start
    needs: cargo-test-slow-head-start
    env:
      command: ${{ matrix.miri == true && 'miri nextest run' || 'nextest run' }}

    steps: &test-steps
    # Many steps here

  cargo-test-slow:
    strategy:
      matrix:
        os:
          - ubuntu-24.04
          - ubuntu-24.04-arm
          - macos-15
          - windows-2025
        miri:
          - true
          - false
        type:
          - together
          # - features
          # - guest-feature
        exclude:
          - os: macos-15
            type: guest-feature
          - os: windows-2025
            type: guest-feature

    runs-on: ${{ matrix.os }}
    env:
      command: ${{ matrix.miri == true && 'miri nextest run' || 'nextest run' }}

    steps: *test-steps

I copied the matrix definition with everything except together in the original job and just together in the new cargo-test-slow job, while reusing the same exact steps, which can be maintained like before. Note &test-steps anchor and *test-steps reference to it.

Now my slower test jobs start 5+ seconds earlier, which is short enough to not delay the rest of the CI too much and long enough to give the slow job a head start in scheduling.

Results
#

The results are awesome! With Windows together job taking 17m10s, the whole CI run took 17m20s. So basically I get the whole CI run for the time of the slowest one:

GitHub Actions CI matrix showing job times

I hope you find this trick useful, PR 397 is where this was done and where you can find the final diff with the changes described here.

Bonus content
#

You might be wondering: how the heck did I get that full-screen CI visualization? Well, since my feature request wasn’t implemented yet, I added a small user style to my browser to fix the annoyance and take advantage of the screen space in my possession. With it, the visualization will occupy the full vertical space available in the browser window instead of being limited to the miserable 600 pixels.