CI Pipeline: Test & Build with GitHub Actions

Learn to build a professional Continuous Integration pipeline with GitHub Actions. Run tests across multiple versions, cache dependencies for speed, and save build artifacts—all the tools you need for modern CI/CD.

Matrix Strategy Dependency Caching Build Artifacts
What is a Continuous Integration Pipeline?

Continuous Integration (CI) is the practice of automatically building and testing code every time a developer pushes changes to the repository. Instead of waiting until the end of a project to discover bugs, CI catches issues immediately—often within minutes of the code being written. This gives developers rapid feedback and prevents broken code from accumulating.

A typical CI pipeline includes several stages: checking out the code, installing dependencies, running linters to enforce code style, executing unit tests to verify functionality, building the application, and sometimes running integration tests. The pipeline should run quickly (ideally under 10 minutes) so developers can stay in flow. When all checks pass, the code is considered "green" and safe to merge.

Teams that practice Continuous Integration ship code faster, have fewer bugs, and spend less time on manual testing. It's a foundational practice of modern software development.
The Complete CI Workflow

Let's build a comprehensive CI pipeline that handles everything from testing to building. This workflow will trigger on every push and pull request to the main branch. It uses a matrix strategy to test across multiple Node.js versions and operating systems, caches dependencies to speed up runs, and saves build artifacts for later use.

name: CI Pipeline

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    name: Test on ${{ matrix.os }} with Node ${{ matrix.node }}
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
        node: [18, 20, 22]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
          cache: 'npm'
      - name: Install dependencies
        run: npm ci
      - name: Run linter
        run: npm run lint
      - name: Run tests
        run: npm test

  build:
    name: Build Application
    runs-on: ubuntu-latest
    needs: test
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - name: Install dependencies
        run: npm ci
      - name: Build
        run: npm run build
      - name: Upload build artifacts
        uses: actions/upload-artifact@v4
        with:
          name: build-output
          path: dist/
Matrix Strategy: Test Across Multiple Configurations

The matrix strategy is one of the most powerful features in GitHub Actions. It allows you to run the same job across multiple combinations of operating systems, language versions, or any other variables. In our workflow, the test job runs on three operating systems (Ubuntu, Windows, macOS) and three Node.js versions (18, 20, 22)—that's nine parallel jobs!

Why is this valuable? Your code might work perfectly on Node.js 20 but break on Node.js 18. It might run fine on Linux but fail on Windows due to file path differences. By testing across a matrix, you catch these issues before they affect your users. The matrix runs all combinations in parallel, so the total time is the duration of the longest job, not the sum of all jobs.

You can customize the matrix for your specific needs. For a Python project, you might test across Python versions 3.9 through 3.12. For a mobile app, you might test across iOS and Android. The matrix is defined in the strategy.matrix section, and you can reference the current matrix values using ${{ matrix.variable }} throughout your job.

Matrix builds are perfect for open source libraries and applications that need to support multiple environments. They ensure your code works everywhere it claims to work.
Caching Dependencies for Speed

Installing dependencies is often the slowest part of a CI pipeline. Without caching, every run would download and install all packages from scratch—wasting time and bandwidth. GitHub Actions provides built-in caching that can dramatically speed up your workflows.

In our workflow, the actions/setup-node action includes built-in caching for npm, yarn, and pnpm. When we specify cache: 'npm', it automatically saves and restores the node_modules folder between runs. The first run on a branch downloads dependencies, but subsequent runs restore from the cache, often cutting installation time from minutes to seconds.

For other ecosystems, you can use the actions/cache action directly. You specify a key (like a hash of your lockfile) and the path to cache. When the key matches a previous cache, GitHub restores it instantly. This works for any language or tool that stores local caches—Python pip, Ruby gems, Go modules, and more.

# Manual caching example (if setup-node doesn't cover your needs)
- name: Cache dependencies
  uses: actions/cache@v4
  with:
    path: ~/.npm
    key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-node-
Good caching can reduce CI run times from minutes to seconds. It's one of the highest-impact optimizations you can make.
Saving Build Artifacts

After testing passes, a CI pipeline often builds the application. This might produce compiled binaries, bundled JavaScript, Docker images, or any other distributable output. Artifacts allow you to save these build outputs so they can be used later—perhaps for manual testing, for deployment in a subsequent job, or as a downloadable asset.

In our workflow, the build job runs after tests pass (using the needs: test dependency). It builds the application and then uses the actions/upload-artifact action to save the dist/ folder. The artifact is stored with the workflow run and can be downloaded from the GitHub UI. You can also pass artifacts between jobs in the same workflow—one job builds, another job deploys the same built artifacts.

Artifacts are perfect for preserving test reports, coverage data, screenshots from UI tests, or any other files you want to keep after the workflow finishes. They're automatically deleted after 90 days, but you can download them at any time during that period.

Artifacts are especially useful for debugging test failures. You can save logs, screenshots, or even the built application to examine after the fact.
Job Dependencies with Needs

In our workflow, the build job runs after the test job completes. This is controlled by the needs: test declaration. Without this, both jobs would run in parallel—which would be wasteful if tests fail and the build would be unnecessary anyway.

Job dependencies let you create pipelines that only proceed when earlier stages succeed. You can chain multiple jobs: test → build → deploy. If any job fails, downstream jobs are automatically skipped. This saves resources and ensures you don't deploy broken code.

You can also use needs to wait for multiple jobs. For example, you might have tests running on three operating systems and want to wait for all of them before building. You can specify needs: [test-ubuntu, test-windows, test-macos] to require all to pass.

Real-World Example: Python Project

Here's how the same CI pipeline pattern applies to a Python project with pytest and flake8:

name: Python CI
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ['3.9', '3.10', '3.11', '3.12']
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt
          pip install pytest pytest-cov flake8
      - name: Lint with flake8
        run: flake8 . --count --max-complexity=10
      - name: Test with pytest
        run: pytest --cov=. --cov-report=xml
      - name: Upload coverage
        uses: actions/upload-artifact@v4
        with:
          name: coverage-${{ matrix.python-version }}
          path: coverage.xml
CI Pipeline Best Practices

Keep it fast. A CI pipeline should ideally complete in under 10 minutes. If it's slower, developers will context-switch while waiting, losing productivity. Use caching aggressively, parallelize with matrix strategies, and consider splitting slow tests into separate jobs.

Fail fast. Run the fastest checks first (like linting) so that if they fail, you don't waste time on slower tests. This gives developers rapid feedback about simple issues.

Make failures visible. Configure your CI to report status on pull requests. GitHub shows the check status directly in the PR interface. Use commit statuses to enforce that all checks must pass before merging.

Keep secrets secure. Never hardcode API keys or credentials. Use GitHub Secrets to store sensitive values and reference them with ${{ secrets.MY_SECRET }}. Secrets are masked in logs and never exposed.

Test what matters. Focus tests on business logic and critical paths. Over-testing can make the pipeline too slow; under-testing misses bugs. Find the right balance for your project.

The best CI pipelines are treated as first-class code. Review and improve them just like you review application code.
Frequently Asked Questions
How many matrix combinations should I use?
Test the combinations that actually matter to your users. For a JavaScript library, testing the latest LTS Node versions plus the current version is usually sufficient. For a cross-platform tool, testing Ubuntu, Windows, and macOS makes sense. Too many combinations can be expensive and slow—find the balance.
What's the difference between npm ci and npm install?
npm ci is designed for CI environments. It installs dependencies exactly from the lockfile, fails if the lockfile is out of sync, and is faster than npm install. Always use npm ci in CI pipelines for consistency and speed.
How do I handle flaky tests in CI?
Flaky tests (tests that sometimes fail for no reason) are a major source of CI frustration. You can retry failed tests using the continue-on-error flag, but it's better to identify and fix flaky tests. Tools like --rerun-flaky in some test frameworks can help.
Can I run CI jobs on a schedule?
Yes! Use the schedule event with cron syntax. For example, schedule: - cron: '0 2 * * *' runs daily at 2 AM. This is useful for nightly test runs, dependency updates, or security scans.
How do I stop CI from running on certain files?
Use the paths and paths-ignore filters. For example, if you only want CI to run when code files change, ignore markdown files: on: push: paths-ignore: ['**.md']. This saves resources on documentation-only changes.
What's the maximum artifact size?
GitHub Actions artifacts have a maximum size of 10 GB per artifact and 100 GB per workflow run. For most build outputs, this is plenty. If you need larger storage, consider using external artifact storage like AWS S3.
Can I run CI on my own hardware?
Yes! Self-hosted runners give you complete control over the execution environment. You can use your own servers, cloud VMs, or even your local machine. This is useful for projects with special hardware requirements or large caching needs.
How do I enforce that CI must pass before merging?
Use branch protection rules. In your repository settings, go to Branches, add a rule for your main branch, and check "Require status checks to pass before merging." Then select your CI workflow from the list of checks. This prevents merging until all checks pass.
Previous: Your First Action Next: CD Deployments

A solid CI pipeline is the foundation of reliable software delivery. Test early, test often, and ship with confidence.