A good CI workflow catches bugs before they reach the main branch. In this lesson, you will build production-quality workflows that test code across multiple environments, cache dependencies for speed, and report results clearly.
A Node.js CI Workflow
Here is a complete CI workflow for a Node.js project:
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
name: Lint & Type Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm tsc --noEmit
test:
name: Test
runs-on: ubuntu-latest
needs: lint
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm test -- --coverage
- name: Upload coverage report
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
build:
name: Build
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm build
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: build-output
path: dist/Matrix Builds
Test your code across multiple Node.js versions and operating systems simultaneously:
jobs:
test:
name: Test (Node ${{ matrix.node-version }}, ${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
matrix:
node-version: [18, 20, 22]
os: [ubuntu-latest, windows-latest]
fail-fast: false # Don't cancel other jobs if one fails
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm test
This creates 6 parallel jobs (3 Node versions x 2 operating systems). The fail-fast: false option ensures all combinations are tested even if one fails.
Dependency Caching
Caching dependencies speeds up your pipeline dramatically:
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "pnpm" # Built-in pnpm caching
- run: pnpm install --frozen-lockfileFor more control, use the cache action directly:
steps:
- name: Cache pnpm store
uses: actions/cache@v4
with:
path: |
~/.pnpm-store
node_modules
key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-The cache key is based on the lockfile hash. When dependencies change, a new cache is created.
Testing with Service Containers
If your tests need a database or cache, use service containers:
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: testpass
POSTGRES_DB: testdb
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7
ports:
- 6379:6379
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npm install
- name: Run tests
env:
DATABASE_URL: postgres://test:testpass@localhost:5432/testdb
REDIS_URL: redis://localhost:6379
run: npm testService containers start before your steps run and are automatically cleaned up when the job finishes.
Code Coverage
Track test coverage and fail the build if it drops below a threshold:
steps:
- run: pnpm test -- --coverage --coverageReporters=text --coverageReporters=lcov
- name: Check coverage threshold
run: |
COVERAGE=$(pnpm test -- --coverage --coverageReporters=json-summary \
| grep -o '"pct":[0-9.]*' | head -1 | grep -o '[0-9.]*')
echo "Coverage: $COVERAGE%"
if (( $(echo "$COVERAGE < 80" | bc -l) )); then
echo "Coverage is below 80%!"
exit 1
fiPull Request Checks
Configure your workflow to block merging if checks fail. In your repository settings:
- Go to Settings > Branches > Branch protection rules
- Add a rule for
main - Check Require status checks to pass before merging
- Select your workflow jobs (e.g., "lint", "test", "build")
Now PRs cannot be merged until all checks pass.
Status Badges
Add a badge to your README showing the workflow status:

This shows a green "passing" or red "failing" badge that updates automatically.
Workflow Optimization Tips
1. Run independent jobs in parallel:
jobs:
lint:
runs-on: ubuntu-latest
steps: [...]
test:
runs-on: ubuntu-latest
steps: [...]
build:
runs-on: ubuntu-latest
needs: [lint, test] # Only build after lint AND test pass
steps: [...]2. Skip unnecessary runs with path filters:
on:
push:
paths:
- "src/**"
- "tests/**"
- "package.json"
- "pnpm-lock.yaml"3. Cancel in-progress runs when new commits are pushed:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true4. Set timeouts to prevent stuck jobs:
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
steps: [...]A Python CI Example
CI is not limited to Node.js. Here is a Python workflow:
name: Python CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["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: |
pip install -r requirements.txt
pip install pytest pytest-cov flake8
- name: Lint with flake8
run: flake8 src/ tests/
- name: Test with pytest
run: pytest --cov=src tests/Summary
You built CI workflows that lint, test, and build code automatically. You learned matrix builds for testing across multiple environments, dependency caching for speed, service containers for integration tests, and concurrency controls for efficiency. In the next lesson, you will add deployment workflows to ship your code to staging and production.