GitHub Actions CI Tuning
Audit and improve GitHub Actions workflows. Focus on correctness first (jobs that error rather than fail), then speed (cache, parallelism), then reliability (flakiness, artifact guards).
Workflow
- Inventory — list all
.github/workflows/*.ymlfiles and their trigger events, jobs, and rough durations (gh run list --workflow <file> --limit 5). - Audit against checklist — run through each section below and flag every gap.
- Prioritise — group findings by impact: correctness bugs > cache misses > parallelism gains > cosmetic.
- Propose changes — draft minimal diffs; do not refactor unrelated parts.
- Verify — after applying, confirm via
gh run listthat the next run is green and faster.
Checklist
Package Manager Cache
A cache miss means re-downloading hundreds of packages on every run. This is the single highest-ROI fix in most repos.
pnpm
yaml- uses: pnpm/action-setup@v6 with: version: latest # or pin to a specific version - uses: actions/setup-node@v7 with: node-version-file: .nvmrc # or node-version: '24' (current LTS) cache: 'pnpm' # ← must be present; omitting it silently skips caching
Common mistake: calling corepack enable instead of pnpm/action-setup. corepack enable does NOT set up the pnpm store cache — actions/setup-node cache: 'pnpm' requires pnpm/action-setup to have run first.
npm
yaml- uses: actions/setup-node@v7 with: node-version-file: .nvmrc cache: 'npm'
yarn (classic / berry)
yaml- uses: actions/setup-node@v7 with: node-version-file: .nvmrc cache: 'yarn'
Verify cache is working: look for Cache restored successfully in setup-node logs. If absent, cache: key is likely missing or the lockfile path is wrong.
Dependency Install
- Always use
--frozen-lockfile(pnpm) /--ci(npm) /--immutable(yarn berry) in CI. Prevents lockfile drift from silently changing the installed tree. - Never use
npm installorpnpm installwithout the frozen flag in CI.
Parallel Jobs vs. Steps
- Independent jobs (lint, typecheck, test) should be separate jobs so they run in parallel, not sequential steps.
- Sequential steps are fine for setup within a single job (install → build → test).
- If a repo has lint + typecheck + unit test all in one job, split them.
yamljobs: lint: runs-on: ubuntu-latest steps: [checkout, setup, install, run lint] typecheck: runs-on: ubuntu-latest steps: [checkout, setup, install, run typecheck] test: runs-on: ubuntu-latest steps: [checkout, setup, install, run test]
Test Sharding (Playwright / Vitest)
Use sharding to cut long test suites. Each shard runs a subset of tests in parallel.
Playwright example — 4 shards:
yamljobs: e2e: strategy: fail-fast: false matrix: shard: [1, 2, 3, 4] steps: - run: pnpm exec playwright test --shard=${{ matrix.shard }}/4 env: CI: true - uses: actions/upload-artifact@v7 if: ${{ !cancelled() }} # ← upload even on test failure with: name: blob-report-${{ matrix.shard }} path: blob-report/ retention-days: 1
Pitfalls:
fail-fast: falseis required — otherwise one failing shard cancels the rest before they upload blob reports.if: ${{ !cancelled() }}on the upload step ensures blob reports are uploaded even when tests fail. Without this, the merge-reports job gets no artifacts.
Artifact Guard in merge-reports
When shards can fail before uploading blob reports, the all-blob-reports directory may not exist. Guard the merge command:
yaml- name: Merge reports run: | if [ -d all-blob-reports ] && [ "$(ls -A all-blob-reports 2>/dev/null)" ]; then pnpm exec playwright merge-reports --reporter=html ./all-blob-reports else echo "No blob reports found, skipping merge" mkdir -p playwright-report echo "<html><body><p>No test results available</p></body></html>" > playwright-report/index.html fi
Without this guard, the merge-reports job errors with Error: Directory does not exist: ./all-blob-reports even when the overall workflow should gracefully report "no results."
Playwright Docker Image
For VRT (Visual Regression Testing), snapshots must be generated in the same environment as CI (Linux + specific fonts). Use the official Playwright Docker image instead of a plain node:* image:
mcr.microsoft.com/playwright:v1.59.1-noble # pin to the same version as @playwright/test
Why not node:24?
node:24requiresplaywright install --with-deps chromiumwhich triggersapt-getand can hang for hours in Docker Desktop on macOS (known issue with apt in QEMU-emulated environments).- The
mcr.microsoft.com/playwrightimage ships Chromium and all system dependencies pre-installed — no apt-get needed, starts in seconds.
Version pinning: keep the image version in sync with @playwright/test in package.json. Mismatch causes browser-not-found errors.
Concurrency Control
Cancel in-progress runs for the same branch/PR to avoid queue buildup:
yamlconcurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true
Exception: do NOT cancel release/deploy workflows on main. Scope it to PR branches only:
yamlconcurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
Scheduled Workflow Reliability
- Scheduled workflows on GitHub Actions can silently stop firing if the repo has no activity for 60 days.
- Add a
workflow_dispatch:trigger alongsideschedule:so it can be manually re-triggered without a code change. - For critical scheduled jobs (e.g., nightly E2E), add a failure notification step (GitHub issue creation, Slack, etc.) so silent failures are visible.
yamlon: schedule: - cron: '0 0 * * *' workflow_dispatch: # ← always add this
Action Version Pinning
- Pin third-party actions to a full commit SHA, not a tag. Tags can be moved.
- Use a comment with the human-readable version for readability:
yamluses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- Dependabot or Renovate can keep SHA pins up to date automatically. Check that
.github/dependabot.ymlincludes thegithub-actionsecosystem.
Node.js 20 deprecation warning: GitHub started issuing "Node.js 20 actions are deprecated" warnings in 2025/2026. This refers to the action's own runtime (runs.using: node20), not the project's Node version. Fix by upgrading to the first major version that uses node24:
| action | first node24 major | current major (2026-09) |
|---|---|---|
actions/checkout | v6.0.0 | v7 |
actions/setup-node | v6.0.0 | v7 |
actions/cache | v5.0.0 | v6 |
actions/upload-artifact | v7.0.0 | v7 |
actions/download-artifact | v8.0.0 | v8 |
aws-actions/configure-aws-credentials | v6.0.0 | v6 |
Latest pinned SHAs (verified 2026-09):
yamluses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c # v6.2.4
Note: SHAs drift — always verify with gh release view --repo <owner>/<action> before pinning.
Environment Variables
CI: trueshould be set at the job or workflow level, not only in individual run steps. Many tools (Playwright, Vite, etc.) change behavior based on this flag.- Secrets should use
${{ secrets.NAME }}— never hardcode tokens. NODE_OPTIONS: --max-old-space-size=4096is sometimes needed for large builds in constrained runners (default heap is ~1.5 GB for a 7 GB runner).
Quick Audit Commands
bash# List recent run durations for a workflow gh run list --workflow e2e.yml --limit 10 --json databaseId,displayTitle,createdAt,updatedAt,conclusion \ | jq '.[] | {title: .displayTitle, duration: (.updatedAt | fromdate) - (.createdAt | fromdate), conclusion: .conclusion}' # Find jobs that always time out gh run list --workflow e2e.yml --status failure --limit 20 --json databaseId \ | jq -r '.[].databaseId' \ | xargs -I{} gh run view {} --json jobs \ | jq '.jobs[] | select(.conclusion == "timed_out") | .name' # Check cache usage in a run gh run view <run_id> --log | grep -i "cache"
Common Anti-Patterns
| Anti-pattern | Problem | Fix |
|---|---|---|
corepack enable only (no pnpm/action-setup) | pnpm store is not cached | Add pnpm/action-setup@v6 before setup-node |
pnpm install without --frozen-lockfile | Lockfile can silently drift | Use --frozen-lockfile always in CI |
fail-fast: true on test matrix | Shards cancel before uploading artifacts | Set fail-fast: false |
Upload artifact without if: ${{ !cancelled() }} | Blob reports lost on test failure | Add the if condition |
playwright merge-reports without directory guard | Job errors when no shards uploaded | Guard with [ -d all-blob-reports ] check |
node:* image for Playwright VRT | apt-get hangs in Docker Desktop on macOS | Use mcr.microsoft.com/playwright image |
Scheduled workflow without workflow_dispatch | Can't manually re-trigger | Always add workflow_dispatch: |
| Action pinned to tag not SHA | Tag can be moved (supply-chain risk) | Pin to full commit SHA |

