CI/CD Pipelines — Test, Build & Deploy with Actions
28 min
From Workflow Basics to Production Pipelines
Lesson 9 covered the foundational concepts: what triggers are, how jobs and steps work, and how to write your first workflow. This lesson builds complete production-grade pipelines — the kind that run in real engineering teams at scale.
A production CI/CD pipeline does several things that a basic workflow does not:
Tests against multiple runtime versions and operating systems simultaneously
Caches dependencies intelligently to keep builds fast as the codebase grows
Stores build artifacts and passes them between jobs
Deploys to multiple environments (preview, staging, production) with proper gates
Handles secrets and environment-specific configuration cleanly
Runs only the steps that are relevant for the specific trigger
Is maintainable — avoids copy-pasting YAML through reusable workflows and composite actions
Is secure — does not give workflows more access than they need
Each of these is covered in this lesson with working, production-realistic examples.
Complete CI/CD Pipeline for a Node.js Application
Here is a complete pipeline for a Node.js web application — test it, build it, and deploy it to Vercel:
Matrix builds run the same job across multiple combinations of variables — operating systems, language versions, or any other dimension. They are essential for ensuring your project works in all the environments your users have.
With 3 Node versions and 3 OSes, this generates 9 parallel jobs. The matrix is fully combinatorial by default.
Including and Excluding Combinations
yaml
strategy: matrix: node-version: [18, 20, 22] os: [ubuntu-latest, windows-latest] include: # Add an extra combination not in the matrix - os: macos-latest node-version: 20 exclude: # Skip a specific combination - os: windows-latest node-version: 18
fail-fast
yaml
strategy: fail-fast: false # Continue other matrix jobs even if one fails matrix: node-version: [18, 20, 22]
By default, fail-fast: true cancels all in-progress matrix jobs when any one fails. This is efficient when any failure means the whole build is broken. Set fail-fast: false when you want to see all results even if some fail — useful for compatibility testing.
Dependency caching is one of the highest-leverage optimizations for CI pipelines. Without caching, a typical Node.js project with hundreds of dependencies spends 30-60 seconds on npm ci per run. With caching, that drops to 2-5 seconds.
The Cache Mechanism
actions/cache saves and restores directories between runs. It uses a cache key to determine whether to restore an existing cache or create a new one.
key: The primary cache key. If an exact match exists, the cache is restored. hashFiles generates a hash of the lockfile — if the lockfile changes, the key changes, and the cache is invalidated.
restore-keys: Fallback keys, tried in order if the primary key misses. Using a prefix like ubuntu-npm- allows restoring a partial cache from a previous run even when the lockfile has changed. This "stale" cache is faster than no cache — npm ci only needs to download changed packages.
Built-in Caching in Setup Actions
actions/setup-node@v4, actions/setup-python@v5, actions/setup-java@v4, and others have built-in cache support that handles the key logic automatically:
yaml
- uses: actions/setup-node@v4 with: node-version: 20 cache: npm # or 'yarn' or 'pnpm'# No need for a separate actions/cache step for node_modules
Artifacts persist data between jobs or beyond the workflow run, available for download from the GitHub UI.
Uploading Artifacts
yaml
- uses: actions/upload-artifact@v4 with: name: test-results # Artifact name (must be unique in the workflow run) path: | test-results/ coverage/ retention-days: 30 # Default 90 days, max 400 days (or 1 day on free tier) if-no-files-found: error # error, warn, or ignore (default: warn) compression-level: 9 # 0-9, higher = smaller artifact, slower upload
Downloading Artifacts
yaml
# Download in a subsequent job- uses: actions/download-artifact@v4 with: name: test-results path: test-results/ # Where to place the downloaded files# Download all artifacts from the current run- uses: actions/download-artifact@v4 with: path: all-artifacts/ # Each artifact goes into a subdirectory
Sharing Artifacts Between Workflow Runs
Artifacts from a previous workflow run can be downloaded in a subsequent run using the GitHub API. This is the basis for "publish once, deploy many" patterns where a build artifact is created in CI and then deployed by separate workflows.
Environment Variables and Secrets in Workflows
Repository Variables (Non-Secret Configuration)
For non-secret configuration that varies between environments (API endpoints, feature flags, resource names), use repository variables — distinct from secrets and visible in plaintext:
Settings → Secrets and variables → Actions → Variables tab
jobs: deploy: environment: production # Uses production's secrets and variables steps: - run: ./deploy.sh env: DATABASE_URL: ${{ secrets.DATABASE_URL }} # production's DB URL API_KEY: ${{ secrets.API_KEY }} # production's API key APP_VERSION: ${{ vars.APP_VERSION }} # from variables
Dynamic Environment Variables with GITHUB_ENV
Set environment variables that persist across subsequent steps in the same job:
yaml
steps: - name: Set release version run: echo "RELEASE_VERSION=$(cat VERSION)" >> $GITHUB_ENV - name: Use the version run: echo "Building version $RELEASE_VERSION" - name: Tag the Docker image run: docker tag myapp:latest myapp:$RELEASE_VERSION
Conditional Steps
The if: expression controls whether a step or job runs.
Common Conditions
yaml
# Only run on pushes to mainif: github.ref == 'refs/heads/main' && github.event_name == 'push'# Only run on pull requestsif: github.event_name == 'pull_request'# Only run if the previous step failedif: failure()# Only run if the previous step succeeded (implicit default, but useful for clarity)if: success()# Run even if previous steps failed (useful for cleanup or notifications)if: always()# Only run if the job was cancelledif: cancelled()# Check for a specific branch or tagif: startsWith(github.ref, 'refs/tags/v')# Check if a file changed (requires actions/changed-files or similar)if: contains(github.event.head_commit.message, '[deploy]')# Only run for a specific actorif: github.actor == 'dependabot[bot]'
A composite action packages a sequence of steps into a reusable action. Unlike reusable workflows (which run as separate jobs), composite actions run inline within the calling job — they share the same runner and environment.
Creating a Composite Action
Create actions/setup-and-test/action.yml in your repository:
yaml
name: Setup and Testdescription: Install dependencies and run the test suiteinputs: node-version: description: Node.js version to use required: false default: '20' test-command: description: The test command to run required: false default: 'npm test'outputs: test-result: description: Whether tests passed value: ${{ steps.test.outcome }}runs: using: composite steps: - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: ${{ inputs.node-version }} cache: npm - name: Install dependencies shell: bash run: npm ci - name: Run tests id: test shell: bash run: ${{ inputs.test-command }}
Using the Composite Action
yaml
jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup and test uses: ./.github/actions/setup-and-test # Local composite action with: node-version: '20' test-command: 'npm run test:ci' - name: Setup and test (Node 22) uses: ./.github/actions/setup-and-test with: node-version: '22'
When to Use Composite Actions vs Reusable Workflows
| | Composite Action | Reusable Workflow |
|--|--|--|
| Runs on | Same job's runner | Its own job (own runner) |
| Can define jobs | No — steps only | Yes |
| Output to calling job | Yes, directly | Via job outputs |
| Best for | Extracting repeated steps | Extracting repeated jobs |
Security: Pinning Action Versions to SHAs
When you use uses: actions/checkout@v4, you are trusting that the v4 tag in the actions/checkout repository hasn't been changed to point to malicious code. Tags are mutable — an attacker who compromises an action author's account could move the tag to a malicious commit.
The secure practice is to pin actions to a full commit SHA:
yaml
# Vulnerable — tag can be moved to malicious code- uses: actions/checkout@v4# Secure — specific immutable commit- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
The # v4.2.2 comment documents what version this SHA corresponds to. Tools like dependabot (with package-ecosystem: github-actions) can automatically update these SHA pins when new versions are released.
With this configuration, Dependabot opens PRs to update pinned action SHAs when new versions of the actions are released.
GITHUB_TOKEN Permissions
The principle of least privilege applies to workflow tokens. By default, GITHUB_TOKEN has read access to the repository. Explicitly grant only the permissions the workflow needs:
This approach means you never store AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY in GitHub Secrets. The OIDC token exchange happens automatically and produces credentials that expire when the workflow finishes.