Testland
Browse all skills & agents

github-actions-test-jobs

Configures GitHub Actions test workflows - `.github/workflows/test.yml` with matrix builds (OS × runtime, with per-OS quirks - path separators, line endings, shells - and per-language runtime matrices in references/os-matrix.md), JUnit XML artifact upload, retry/sharding, services (PostgreSQL, Redis), per-trigger filtering (pull_request, push, schedule, workflow_dispatch). Use when the project hosts on GitHub and the team wants idiomatic GitHub Actions patterns for test workflows, or needs continuous cross-platform OS / runtime coverage.

Install with skills.sh (any agent)

npx skills add testland/qa --skill github-actions-test-jobs
View source

github-actions-test-jobs

Overview

Test workflows are YAML files in .github/workflows/ that run jobs on trigger events (gha (opens in new window)). This skill sets up the idiomatic patterns - matrix builds, sharding, service containers, JUnit reporting, trigger filtering, concurrency, and secrets. Start with the minimal pattern in Step 1.

When to use

  • Project on GitHub.
  • Need to set up test CI for a new repo.
  • Existing GitHub Actions workflows need standardization.

How to use

  1. Add .github/workflows/test.yml triggered on pull_request and push to main; check out the repo and set up the runtime (Step 1).
  2. Install dependencies and run the suite (npm ci then npm test).
  3. Add a build matrix over OS and runtime versions with fail-fast: false for multi-target signal (Step 2), and shard large suites across parallel jobs (Step 3).
  4. Wire any service containers the tests need and emit JUnit XML, uploading it with if: always() - references/services-and-reporting.md.
  5. Filter triggers by path and add schedule / workflow_dispatch (Step 7); add a concurrency group so superseded pushes cancel (Step 8).
  6. Move tokens into secrets.* and reference them from step env (Step 9).
  7. Verify the workflow runs (see Verify below), then review the anti-patterns table before merging.

Step 1 - Basic test workflow

# .github/workflows/test.yml
name: test

on:
  pull_request:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v4
        with: { node-version: '22' }
      - run: npm ci
      - run: npm test

The minimal pattern: trigger on PR + push-to-main, install deps, run tests.

Step 2 - Matrix builds

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

fail-fast: false ensures one matrix failure doesn't cancel others. Matrix size is OS × Node = 3 × 2 = 6 jobs.

Runner pinning, per-language runtime matrices (Node / Python / Java / .NET), OS-specific quirks (path separators, line endings, case sensitivity, shells), per-OS conditional steps and tests, and the tiered cost-management cadence are in references/os-matrix.md.

Step 3 - Sharding for parallel execution

For large suites:

jobs:
  test:
    strategy:
      matrix:
        shard: [1, 2, 3, 4]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - run: npx jest --shard=${{ matrix.shard }}/4

4 parallel jobs, each running 1/4 of the test suite. Faster than serial execution; cost-equivalent (same total CPU-time).

Service containers and reporting

Wiring the service containers the tests depend on (PostgreSQL, Redis) and publishing JUnit XML as artifacts plus PR-check summaries are in references/services-and-reporting.md.

Step 6 - Retry policy

GitHub Actions doesn't ship native test-retry; use the framework's retry mechanism (e.g., Playwright's retries config) or a wrapper action:

- uses: nick-fields/retry@v3
  with:
    timeout_minutes: 10
    max_attempts: 2
    command: npm test

Use sparingly - retries hide flake. Prefer flaky-test-quarantine (in the qa-flake-triage plugin).

Step 7 - Per-trigger filtering

on:
  pull_request:
    paths:
      - 'src/**'
      - 'tests/**'
      - 'package.json'

  push:
    branches: [main]
    paths-ignore:
      - 'docs/**'
      - '*.md'

  schedule:
    - cron: '0 4 * * *'   # daily 4am UTC

  workflow_dispatch:        # manual trigger
    inputs:
      target_browser:
        description: 'Browser to test'
        type: choice
        options: [chrome, firefox, safari]
        default: chrome

Path filters skip workflows when only docs change - saves CI budget.

Step 8 - Concurrency control

concurrency:
  group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
  cancel-in-progress: true

When a PR receives multiple pushes, the older runs cancel - saves CI cost on superseded commits.

Step 9 - Secrets + env

env:
  CI: true

steps:
  - run: npm test
    env:
      NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
      DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}

Secrets configured in repo settings; never committed.

Verify before merge

Run the workflow once before merging - push the branch or trigger it manually via workflow_dispatch (or dry-run locally with act).

Verify: the matrix expands into the expected job count (e.g. OS × Node = 3 × 2 = 6 jobs) and each job uploads its JUnit artifact. If a job fails on a missing secret, confirm NPM_TOKEN / TEST_DATABASE_URL exist under repo Settings -> Secrets and variables -> Actions, add any that are missing, and re-run the job.

Worked example

A Node service needs PR tests on Linux + macOS, plus a nightly full run against PostgreSQL.

  1. test.yml triggers on pull_request and push to main, with schedule: cron '0 4 * * *' for the nightly run.
  2. The test job runs a matrix of os: [ubuntu-latest, macos-latest] × node: [20, 22] with fail-fast: false - 4 jobs, and no target cancels another.
  3. A separate integration job (Linux only) declares a postgres:15 service with a pg_isready healthcheck and passes DATABASE_URL into npm test.
  4. Tests emit test-results/junit.xml; actions/upload-artifact@v4 with if: always() keeps the report even when tests fail, and dorny/test-reporter@v1 renders it in the PR check summary.
  5. A concurrency group keyed on the ref cancels superseded runs, so rapid pushes don't pile up billable minutes.

Result: PR authors get fast cross-platform signal, the nightly catches DB-integration regressions, and failed runs still surface their JUnit report.

Anti-patterns

Anti-patternWhy it failsFix
fail-fast: true on matrixFirst failure cancels all; lose multi-target signal.fail-fast: false (Step 2).
No concurrency groupPRs with rapid pushes pile CI runs.Add concurrency cancel (Step 8).
if: always() everywhereSome steps shouldn't run on failure (deploy).Selective if: always() for upload steps only (services-and-reporting).
Hardcoded secrets in YAMLSecret leak; revocation needed.secrets.X references (Step 9).
Massive single workflow fileHard to navigate; merge conflicts.Split per concern (test.yml, deploy.yml, lint.yml).
Missing actions/checkout stepJob can't access repo files.First step always (Step 1).

Limitations

  • Runner cost. macOS / Windows runners 5x-10x more expensive than Linux (after free quota). Budget accordingly.
  • Per-job 6h timeout. Long jobs need splitting.
  • Service containers Linux-only. macOS / Windows runners don't support services: block.
  • JUnit reporting requires a third-party action. No native.

References

  • gha (opens in new window) - GitHub Actions workflow basics: events + jobs + steps; YAML in .github/workflows/.
  • references/os-matrix.md - OS / runtime matrix depth + per-OS quirks.
  • references/services-and-reporting.md - service container + JUnit reporting recipes.
  • gitlab-ci-test-jobs, jenkinsfile-test-stages - per-platform alternatives.
  • ci-test-job-conventions - cross-CI conventions; CircleCI patterns live in its references/circleci.md.
  • junit-xml-analysis - downstream JUnit XML parser.
  • flaky-test-quarantine - preferred over retries.

OS / runtime matrix

Reference for github-actions-test-jobs: run tests across operating systems (Linux / macOS / Windows) and runtime versions (Node 18/20/22; Python 3.10/3.11/3.12; Java 17/21; .NET 6/8) with GitHub Actions matrix syntax, and address the OS-specific quirks (path separators, line endings, file permissions, shells) that break cross-platform suites.

When to use

  • The product runs on multiple OSes (CLI tools, libraries, desktop apps).
  • The team supports multiple runtime versions.
  • A bug report says "works on Linux, broken on Windows."

For browsers specifically, see the qa-web-e2e plugin's playwright-testing (references/browser-matrix.md).

Step 1 - Define the OS matrix

# .github/workflows/os-matrix.yml
strategy:
  fail-fast: false
  matrix:
    os: [ubuntu-latest, macos-latest, windows-latest]

GitHub Actions provides:

RunnerUse
ubuntu-latestDefault; cheapest; most CI runs here.
ubuntu-22.04Pin specific Ubuntu LTS.
macos-latestmacOS; needed for iOS / Safari testing.
macos-15Pin macOS version.
windows-latestWindows; tests Windows-specific paths.
windows-2022Pin Windows version.

Step 2 - Define the runtime matrix

Per language:

# Node.js
strategy:
  matrix:
    node: [18, 20, 22]
    os: [ubuntu-latest, macos-latest, windows-latest]
# Python
strategy:
  matrix:
    python: ['3.10', '3.11', '3.12']
    os: [ubuntu-latest, macos-latest, windows-latest]
# Java
strategy:
  matrix:
    java: [17, 21]
    os: [ubuntu-latest, macos-latest, windows-latest]
# .NET
strategy:
  matrix:
    dotnet: ['6.0.x', '8.0.x']
    os: [ubuntu-latest, macos-latest, windows-latest]

The full cross-product is 3 OSes × 3 runtimes = 9 jobs. For larger matrices, use include + exclude to skip uninteresting combinations.

Step 3 - Address OS-specific quirks

Path separators

// Bad - hardcoded /
const configPath = projectRoot + '/config/app.json';

// Good - path.join
const path = require('node:path');
const configPath = path.join(projectRoot, 'config', 'app.json');

Line endings

# .gitattributes
*.sh text eol=lf
*.bat text eol=crlf
*.json text

Without .gitattributes, Windows users may commit CRLF; tests that compare output strings break.

Case sensitivity

// On Linux: import './Utils' fails if file is './utils'
// On macOS / Windows (default): both work

// Always match file case exactly:
import { foo } from './utils';   // matches utils.js

Shell

- name: Run script (cross-platform)
  shell: bash
  run: ./scripts/setup.sh

shell: bash works on Linux + macOS + Windows (via Git Bash on Windows runners).

Step 4 - Per-OS conditional steps

When OS-specific setup is needed:

- name: Install Linux deps
  if: runner.os == 'Linux'
  run: sudo apt-get install -y libssl-dev

- name: Install macOS deps
  if: runner.os == 'macOS'
  run: brew install openssl

- name: Install Windows deps
  if: runner.os == 'Windows'
  run: choco install openssl

Step 5 - Aggregate per-OS results

## OS / runtime matrix results - `<sha>`

| OS        | Runtime  | Tests | Pass | Fail | Time |
|-----------|----------|------:|-----:|-----:|-----:|
| Linux     | Node 22  |  142  |  142 |    0 | 2m   |
| Linux     | Node 20  |  142  |  142 |    0 | 2m   |
| Linux     | Node 18  |  142  |  140 |    2 | 2m   |  ← Node 18 incompat
| macOS     | Node 22  |  142  |  141 |    1 | 3m   |  ← macOS path issue
| macOS     | Node 20  |  142  |  141 |    1 | 3m   |
| Windows   | Node 22  |  142  |  140 |    2 | 4m   |  ← Windows path issue
| ...

Step 6 - Per-OS conditional tests

Some tests are OS-specific:

// jest.config.js
module.exports = {
  testPathIgnorePatterns: process.platform === 'win32'
    ? ['unix-only.test.js']
    : ['windows-only.test.js'],
};

Or via test framework conditionals:

test.skipIf(process.platform === 'win32')('uses fork()', () => {
  // POSIX-specific test
});

Step 7 - Cost management

Matrix size grows multiplicatively. Manage cost:

TierCadenceMatrix size
Per-PR (smoke)Per push1 × 1 = 1 job (Linux + latest runtime).
Per-merge to mainPer merge3 × 1 = 3 jobs (3 OSes + latest runtime).
NightlyCron3 × 3 = 9 jobs (full matrix).
Pre-releaseTagFull matrix + extra exotic combinations.

The "smoke matrix" per-PR keeps CI cheap; the full matrix runs less frequently.

Anti-patterns

Anti-patternWhy it failsFix
Hardcoded / path separatorsBreaks on Windows.path.join (Step 3).
fail-fast: true on the matrixOne OS fails; can't see others.fail-fast: false.
Same matrix every commitCI cost explodes; team disables.Tiered cadence (Step 7).
Per-OS code in productionIf/else by OS; high maintenance.Cross-platform abstractions in production; OS-specific code in glue layer only.
Skipping .gitattributesCRLF / LF mixing; tests fail mysteriously.Always set (Step 3).

Limitations

  • GitHub Actions runner cost. Windows + macOS runners are more expensive than Linux; matrix design tradeoff.
  • Per-OS bugs may surface only at runtime. Static analysis catches some; integration tests are the safety net.
  • macOS-specific issues often only reproduce on real macOS; Linux CI doesn't catch them.
  • Older OS versions rarely available on hosted runners; need self-hosted for legacy.

References

  • GitHub Actions runners docs at docs.github.com/en/actions/using-github-hosted-runners.
  • playwright-testing (in the qa-web-e2e plugin, references/browser-matrix.md) - sibling: browser-specific.
  • browser-matrix-strategy-reference (in the qa-web-e2e plugin, references/compatibility-budget.md) - conventions for matrix sizing.

GitHub Actions - service containers and reporting

View source (opens in new window)

GitHub Actions - service containers and reporting

Deeper recipes split out of github-actions-test-jobs SKILL.md: wiring the service containers the tests depend on, and publishing JUnit results as artifacts plus PR-check summaries.

Service containers (PostgreSQL, Redis, etc.)

jobs:
  integration:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_PASSWORD: test
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports: [5432:5432]
      redis:
        image: redis:7
        ports: [6379:6379]
    steps:
      - uses: actions/checkout@v5
      - run: npm ci
      - run: npm test
        env:
          DATABASE_URL: postgres://postgres:test@localhost:5432/test
          REDIS_URL: redis://localhost:6379

GitHub Actions provides container-based services on Linux runners. Healthcheck options ensure tests don't start before the DB is ready.

JUnit reporting + artifacts

- run: npm test -- --reporters=default --reporters=jest-junit

- uses: actions/upload-artifact@v4
  if: always()
  with:
    name: test-results
    path: test-results/

- uses: dorny/test-reporter@v1
  if: always()
  with:
    name: Test results
    path: test-results/junit.xml
    reporter: java-junit

if: always() ensures artifacts upload even on test failure. The dorny/test-reporter action surfaces results in the PR check summary.