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.
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.
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/
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.
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-
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.
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.
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
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.
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.continue-on-error flag, but it's better to identify and fix flaky tests. Tools like --rerun-flaky in some test frameworks can help.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.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.A solid CI pipeline is the foundation of reliable software delivery. Test early, test often, and ship with confidence.