MervCodes

Tech Reviews From A Programmer

GitHub Actions Advanced: Matrix Builds, Caching, Secrets

1 min read

GitHub Actions makes it easy to get a basic CI pipeline running, but the difference between a workflow that works and one that's fast, secure, and maintainable comes down to three advanced features: matrix builds, caching, and secrets management. This guide walks through each with practical, copy-paste-ready examples and the gotchas that trip people up in production.

Matrix Builds: Test Everything in Parallel

A matrix build lets you run the same job across multiple combinations of operating systems, language versions, and configuration values — all in parallel. Instead of writing five near-identical jobs, you declare the axes and let GitHub Actions expand them.

name: CI
on: [push, pull_request]

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
        node: [18, 20, 22]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
      - run: npm ci
      - run: npm test

This single job expands into nine parallel runs (3 operating systems × 3 Node versions). A few details worth internalizing:

  • fail-fast: false keeps every combination running even after one fails. The default (true) cancels the whole matrix on the first failure, which is great for fast feedback but bad when you want a complete picture of what's broken.
  • max-parallel caps how many matrix jobs run at once. Useful when you're hitting concurrency limits or want to be polite to a rate-limited external service.

Including and Excluding Combinations

Real matrices are rarely a clean Cartesian product. Use include to add one-off combinations and exclude to prune ones that don't make sense.

    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest]
        node: [18, 20, 22]
        exclude:
          - os: windows-latest
            node: 18
        include:
          - os: ubuntu-latest
            node: 22
            experimental: true

The include block can also add new keys to existing combinations. Pair that with continue-on-error: ${{ matrix.experimental }} to let bleeding-edge versions fail without breaking your build.

Dynamic Matrices

For advanced setups, you can generate the matrix at runtime from a previous job's output — handy when the set of packages or services to test changes over time.

jobs:
  setup:
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.gen.outputs.matrix }}
    steps:
      - id: gen
        run: echo "matrix=$(./scripts/build-matrix.sh)" >> "$GITHUB_OUTPUT"
  test:
    needs: setup
    strategy:
      matrix: ${{ fromJSON(needs.setup.outputs.matrix) }}
    runs-on: ubuntu-latest
    steps:
      - run: echo "Testing ${{ matrix.package }}"

The script just needs to emit a compact JSON object like {"package":["api","web","worker"]}.

Caching: Stop Reinstalling the Same Dependencies

Every workflow run starts on a clean runner, so without caching you re-download and rebuild your entire dependency tree each time. The actions/cache action persists directories between runs keyed on a hash of your lockfile.

      - uses: actions/cache@v4
        with:
          path: ~/.npm
          key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-npm-

The mechanics that matter:

  • key is the exact cache identifier. When the lockfile changes, the hash changes, and you get a fresh cache (a cache miss).
  • restore-keys are fallback prefixes. On a miss, GitHub finds the most recent cache whose key starts with one of these prefixes, so you restore a slightly stale cache and only install the diff instead of everything.
  • Always include runner.os in the key. Caches are not portable across operating systems, and a Linux cache restored on Windows will cause confusing failures.

Use the Built-in Setup Cache First

Before reaching for actions/cache directly, check whether your setup-* action already supports caching. Most do, and it's a one-line change:

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

This handles the path, key, and restore-keys for you. Use the lower-level actions/cache only for things the setup action doesn't cover — build outputs, compiled binaries, Docker layers, or tool-specific directories like ~/.cache/pip or the Go build cache.

Caching Gotchas

  • Caches are immutable. Once a key is written, it can't be overwritten. If you need to bust a cache, change the key (bumping a v1v2 prefix is a common trick).
  • Branch scoping. A cache created on a feature branch is readable from that branch and its descendants, but the default branch's caches are available to all branches. PRs can read the base branch's cache but write to their own scope.
  • Eviction. GitHub evicts caches not accessed in 7 days, and each repo has a 10 GB limit. When you exceed it, the least-recently-used caches are deleted. Don't cache things that are cheap to regenerate.
  • Cache vs. artifacts. Caching is for speeding up future runs of dependencies. Artifacts are for passing build outputs between jobs in the same run or downloading results. Don't confuse them.

Secrets: Handle Credentials Safely

Secrets are encrypted environment values you inject into workflows without committing them to the repo. Define them at the repository, environment, or organization level, then reference them through the secrets context.

      - name: Deploy
        env:
          API_TOKEN: ${{ secrets.API_TOKEN }}
        run: ./deploy.sh

GitHub automatically masks secret values in logs, replacing them with ***. But masking is a safety net, not a guarantee — it only catches exact string matches.

Secrets Best Practices

  • Never echo or print secrets. Even with masking, a base64-encoded or partially transformed secret can leak. Avoid set -x in scripts that touch credentials.
  • Pass secrets as env, not as command-line arguments. Process arguments can show up in process listings and logs; environment variables are safer.
  • Use environments for deployment gates. Define a production environment with required reviewers and scoped secrets. A job that targets it can't run until an approver signs off.
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - run: ./deploy.sh
        env:
          DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
  • Prefer OIDC over long-lived secrets. For cloud deployments (AWS, GCP, Azure), use OpenID Connect to exchange a short-lived GitHub token for temporary cloud credentials. There's no static secret to rotate or leak.
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/gha-deploy
          aws-region: us-east-1
  • Guard against fork PRs. The pull_request event from forks does not have access to secrets, by design — this prevents a malicious PR from stealing credentials. If you need secrets in PR checks, understand the security tradeoffs of pull_request_target before using it, and never check out untrusted code with secrets in scope.
  • Scope and rotate. Grant the narrowest permissions possible (permissions: block at the job level), and rotate any secret you suspect was exposed immediately.

Putting It All Together

These three features compound. A well-built workflow runs a caching-accelerated test matrix across versions, then gates a secrets-scoped deploy behind a protected environment:

jobs:
  test:
    strategy:
      fail-fast: false
      matrix:
        node: [18, 20, 22]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
          cache: 'npm'
      - run: npm ci
      - run: npm test

  deploy:
    needs: test
    if: github.ref == 'refs/heads/main'
    environment: production
    runs-on: ubuntu-latest
    permissions:
      id-token: write
    steps:
      - uses: actions/checkout@v4
      - run: ./deploy.sh

The needs: test dependency ensures deploy only runs after every matrix combination passes, and the if condition restricts deploys to the main branch.

FAQ

Why is my cache never hitting? The most common cause is a key that changes every run — for example, including a timestamp or commit SHA in the key. Keys should be derived from lockfile hashes so they only change when dependencies change. Also verify you included runner.os and that you're not silently exceeding the 10 GB repo limit and getting evicted.

Can I share a cache across the matrix? Yes, if the cached content is identical across combinations, the same key restores the same cache. But if different Node or OS versions produce different dependency trees, include those values in the key (${{ matrix.os }}-${{ matrix.node }}-...) to avoid cross-contamination.

How many matrix jobs can I run at once? A single workflow run supports up to 256 jobs in a matrix. Actual parallelism is bounded by your plan's concurrency limits and runner availability, so very wide matrices may queue. Use max-parallel to throttle deliberately.

What's the difference between a secret and an environment variable? Plain environment variables (set under env: or repository variables) are stored in plaintext and visible in logs. Secrets are encrypted at rest and masked in logs. Use secrets for anything sensitive; use variables for non-sensitive config like region names or feature flags.

Do secrets work in pull requests from forks? No. Workflows triggered by pull_request from a fork run without access to secrets to prevent credential theft. Use OIDC, the pull_request_target event (carefully), or a separate post-merge workflow for steps that genuinely need secrets.

Should I use OIDC or stored cloud secrets? Prefer OIDC whenever your cloud provider supports it. Short-lived tokens eliminate the risk of a leaked long-lived key and remove the rotation burden entirely. Reserve static secrets for third-party services that don't offer federated identity.

How do I force a cache refresh without changing dependencies? Bump a version prefix in your cache key (e.g., v1-npm-...v2-npm-...). Since caches are immutable and keyed by string, the new key guarantees a fresh write on the next run.

Sources

Related Articles