GitHub Actions study materials
GitHub Actions study resources

Monorepo CI: Cache Keys, Matrix Checks, and Path Filters

GitHub Actions · Put your knowledge to work in context.

All case studies

Monorepo CI: Cache Keys, Matrix Checks, and Path Filters

Your team maintains one GitHub repository with 38 packages: 22 Node.js services using pnpm and 16 Python libraries using Poetry. The ci.yml workflow triggers on every push and pull_request to main. It runs a 6-entry Node matrix (Node 18/20/22 on Ubuntu and macOS), a 4-entry Python matrix, and a Docker build. A documentation-only PR that changes docs/README.md still consumes 42 runner minutes and blocks merges for 18 minutes. The pnpm cache step uses key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ github.run_id }}; logs show 'Cache not found' on every run. Branch protection requires the check named ci, but the workflow's jobs are named node (18, ubuntu-latest), python (3.11), and so on; the required check never reports. Developers want faster feedback without weakening required checks.

1. Explain why the pnpm cache never restores and propose a corrected cache key. Include what should and should not be in the key.

Read the worked answer

github.run_id is unique for every workflow run, so the cache key changes each time and GitHub Actions can never find a prior cache. Caches are immutable: once saved under a key, a new save with the same key is skipped. The key should represent the dependency state, not the run identity: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}. Include the pnpm major version if the store layout can change, e.g. ...-pnpm9-.... Do not include github.sha, run_id, or run_number. Add restore-keys: ${{ runner.os }}-pnpm- so a lockfile change can fall back to a recent compatible cache. Run pnpm/action-setup before actions/cache and cache the path from pnpm store path. Note that PRs can restore caches from the base branch and default branch, but caches created by a PR are scoped to that PR; a fork PR may have restricted cache access. Finally, verify the lockfile glob actually matches the lockfile location; a wrong path yields an empty hash and unstable behavior.

2. The required check `ci` never reports. What are likely causes and how would you restructure the workflow so branch protection can require a stable check?

Read the worked answer

Required status checks match check-run names. A matrix job expands into one check per combination, so there is no check called ci. Branch protection can require each expanded name, but that is brittle when the matrix changes. Add a small aggregator job named ci that needs all matrix and Docker jobs. It must use if: always() so it runs even when a dependency fails, then fail if any result is not success or skipped; otherwise succeed. Example: needs: [node, python, docker], if: always(), step if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') then exit 1. Require only ci. Be careful: a skipped job due to if is not a failure, but a job cancelled by concurrency is; decide explicitly. Also ensure the aggregator job is not path-filtered away; if the whole workflow is skipped, the required check remains pending rather than succeeding.

3. Design path filtering so docs-only PRs do not run the full matrix but still satisfy required checks. Address GitHub's path filter behavior and required checks.

Read the worked answer

Workflow-level paths/paths-ignore prevents the workflow from running at all. For a required check, that is dangerous: GitHub leaves the check pending, so the PR cannot merge. Use a two-layer design. A lightweight gate job always runs on every PR, uses dorny/paths-filter or git diff to set outputs like code: true/false, and exposes the required check name ci. Heavy matrix jobs declare needs: gate and if: needs.gate.outputs.code == 'true'. The gate job must still succeed when code is false, which satisfies branch protection while skipping the expensive work. If instead you keep workflow-level filters, also add a separate always-on workflow with the required check name, but that duplicates configuration. For push events, path filters are usually safe because branch protection applies to PRs; still keep the aggregator for consistency. Test the docs-only case and a lockfile-only case, because dependency changes may need tests even when source files do not change.