GitHub Actions #4 Matrix, Cache, and Artifacts: Combination Runs and Saving Time and Money

5 min read

If your tests pass on Python 3.12 but break on 3.13, the only way to know is to run them on both. You could copy the job definition and change just the version, but once you add operating systems to the mix, two copies become six, then nine, and maintenance collapses. The first topic of this post, the matrix, solves this with a single job definition. Then we go in the opposite direction: caching, which cuts the time and cost of all those extra runs, and artifacts — the answer to the “passing files between jobs” question we postponed in #2.

Matrix: expanding one job into combinations #

Declare axes under strategy.matrix, and the job runs in parallel, once per combination.

.github/workflows/test.yml
jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest]
        python: ["3.12", "3.13"]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python }}
      - run: pip install -r requirements.txt
      - run: pytest

2×2 produces four jobs, and inside each job you use matrix.os and matrix.python to read that combination’s values. Three options refine the combinations.

  • exclude: removes specific combinations. Adjustments like “skip 3.12 on macOS.”
  • include: adds combinations or attaches extra variables to specific ones. Exception handling like “enable coverage measurement only on the Ubuntu 3.13 combination.”
  • fail-fast: the default, true, cancels the remaining combinations as soon as one fails. If you want to see every combination that fails, switch it to false. False is useful for tests where you need to compare failure causes across combinations; keep true if cost comes first.

Also remember that a matrix multiplies your runner usage by the number of combinations. As we saw in #1, macOS runners bill at a higher multiplier, so on a private repository, asking whether the macOS axis is really necessary is where cost management starts.

Cache: stop downloading everything from scratch #

Runners are clean machines every time, so dependency installation starts from zero on every run. actions/cache preserves the directories you specify between runs, cutting that waste.

.github/workflows/test.yml
      - uses: actions/cache@v4
        with:
          path: ~/.cache/pip
          key: pip-${{ runner.os }}-${{ hashFiles('requirements.txt') }}
          restore-keys: |
            pip-${{ runner.os }}-

The heart of it is the key design. hashFiles('requirements.txt') puts a hash of the dependency file into the key, so when dependencies change, the key changes and the cache invalidates naturally. restore-keys is a fallback that matches by prefix when no exact key exists, so even right after a dependency change, most packages are restored from the previous cache and only the changed parts are downloaded fresh.

In practice, an even simpler route comes first: the built-in cache of the setup family of actions.

.github/workflows/test.yml
      - uses: actions/setup-python@v5
        with:
          python-version: "3.13"
          cache: "pip"

That single line, cache: "pip", does the same job as the cache action configuration above. setup-node, setup-go, and the rest offer the same option, so use the built-in option as the default for language dependency caches, and reach for actions/cache directly only for paths the built-ins don’t cover (intermediate build outputs and the like). A cache has a total size limit per repository and is automatically evicted when unused for a while — it is temporary storage. Stick to the principle that everything must work without it, and it should only make things faster, and there are no side effects.

Artifacts: passing files between jobs and keeping outputs #

In #2 we said that jobs pass strings via outputs but files don’t carry over. The channel for files is artifacts.

.github/workflows/build-deploy.yml
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: ./build.sh -o dist/
      - uses: actions/upload-artifact@v4
        with:
          name: app-dist
          path: dist/

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: app-dist
          path: dist/
      - run: ./deploy.sh dist/

The build job uploads its output and the deploy job downloads it — the basic skeleton of “build once, deploy exactly that.” Artifacts can also be downloaded by a human from the run page, so they double as a storage box for test reports and log bundles. You can set the retention period with retention-days, and since the default retention is long and can become a source of storage billing, explicitly setting a short period for short-lived things like build outputs is a good cost habit. The distinction from cache is clear: cache is “an accelerator you can live without,” artifacts are “what the run produced.”

Recap #

What we covered in this post.

  • A matrix expands one job definition into parallel runs, one per combination. exclude and include refine the combinations, and fail-fast is a trade-off between cost and information
  • A matrix multiplies runner usage. The macOS axis has a high billing multiplier and is the first candidate for cost review
  • Put hashFiles in the cache key so dependency changes invalidate it naturally, and use restore-keys to get partial restores
  • For language dependencies, the setup actions’ built-in cache option comes first; use actions/cache for paths without built-in coverage
  • Passing files and keeping outputs is what artifacts are for. Cache is an accelerator, artifacts are outputs, and specifying retention-days is a cost habit

In the next post (#5 Reuse), we turn the step bundles repeated across repositories and workflows into components: composite actions and reusable workflows.

X