StackPractices
intermediate By Mathias Paulenko

GitHub Actions Workflow Examples: CI/CD, Matrix & Secrets

How to build and deploy with GitHub Actions using workflows, matrices, caching, secrets, environments, and reusable workflows.

Topics: devops

Overview

Teams that still verify code by hand — running tests locally, building artifacts on a developer machine, deploying over SSH — ship slowly and break things. GitHub Actions turns every Git event into an automated, reproducible workflow running in isolated environments, so the pipeline lives next to the code it tests.

Because your pipeline is defined as YAML inside the repository, it gets version control, peer review, and auditability for free. A green checkmark on a pull request means the code compiles, tests pass, and style rules hold before anyone merges.

This recipe collects the workflow patterns you actually copy into .github/workflows/: a Node.js CI pipeline, build matrices, scheduled jobs, secrets and environments, dependency caching, and reusable workflows. For the full conceptual walkthrough, see the CI/CD with GitHub Actions guide.

When to Use

Use this recipe when:

  • Catching regressions on every pull request, before they reach production — see Unit Testing for test automation.
  • Publishing Docker images off release tags so deployments stay immutable — see Docker Basics for image building.
  • Deploying to staging or production with gated approvals. See Environment Variables for managing deployment config.
  • Linting and formatting checks before merge. See Pre-Commit Hooks for local code quality.
  • Kicking off recurring work — dependency audits, nightly backups — on a UTC cron schedule.
  • Scanning dependencies for vulnerabilities on every commit.

Skip GitHub Actions when you need GPU runners or exotic build agents, when your code lives outside GitHub (use GitLab CI or Jenkins instead), or when a single hosted-minute cost per build exceeds what a self-hosted runner would cost at your commit volume.

Solution

Basic Workflow (Node.js CI)

This workflow runs on every push and pull request targeting main. It pulls the code, prepares Node.js, installs the dependencies, then walks through the whole validation pipeline.

# .github/workflows/ci.yml
name: CI

# Trigger: run on push and PR to main branch
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      # Check out the repository code
      - uses: actions/checkout@v4

      # Set up Node.js with built-in npm caching
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      # Install dependencies using lock file for reproducibility
      - run: npm ci

      # Run linting to catch style issues early
      - run: npm run lint

      # Execute the test suite
      - run: npm run test

      # Build the production artifact
      - run: npm run build

Build Matrix

A matrix strategy runs the same job across combinations of OS and runtime versions. It catches platform-specific bugs and confirms compatibility without duplicating workflow code. See GitHub Actions Matrix Strategy for include/exclude combos and dynamic matrices.

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

Scheduled Workflows (Cron)

The schedule trigger runs workflows on a UTC cron schedule — useful for dependency audits, nightly builds, or cleanup jobs. Add workflow_dispatch alongside it so you can still run the job manually.

# .github/workflows/nightly-audit.yml
name: Nightly Audit

on:
  schedule:
    - cron: '0 3 * * *'   # Every day at 03:00 UTC
  workflow_dispatch:       # Allow manual runs from the Actions tab

jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm audit --audit-level=high

Deploy with Secrets

This job only runs after the test job succeeds and only on the main branch. Secrets are injected at runtime from the repository settings and masked in logs.

jobs:
  deploy:
    runs-on: ubuntu-latest
    needs: test              # wait for test job to pass
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
      - name: Deploy to production
        run: |
          echo "Deploying with token"
          curl -X POST https://api.example.com/release \
            -H "Authorization: Bearer ${{ secrets.DEPLOY_TOKEN }}"

secrets and vars behave differently — pick the right one for each value:

ContextEncrypted at restMasked in logsUse for
secrets.*YesYesAPI tokens, deploy credentials, signing keys
vars.*NoNoFeature flags, environment URLs, non-sensitive config

Caching Dependencies

Caching stores downloaded dependencies between workflow runs. The cache key includes the OS and a hash of the lock file, so it invalidates automatically when dependencies change.

steps:
  - uses: actions/checkout@v4
  - uses: actions/cache@v4
    with:
      path: |
        ~/.npm
        node_modules
      key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
  - run: npm ci

Reusable Workflow

When several repositories need the same pipeline logic, extract it into a reusable workflow. Callers pass inputs and receive outputs, keeping the shared logic in one place. See Reusable Workflows in GitHub Actions for secrets: inherit, output passing, and versioning strategies.

# .github/workflows/reusable-lint.yml
name: Reusable Lint

on:
  workflow_call:
    inputs:
      node-version:
        required: true
        type: string

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
      - run: npm ci
      - run: npm run lint

Call it from another repository:

jobs:
  call-lint:
    uses: org/shared-workflows/.github/workflows/reusable-lint.yml@main
    with:
      node-version: '20'

Explanation

How the Execution Model Works

A workflow is a YAML file triggered by an event (push, pull_request, schedule, workflow_dispatch, or an external repository_dispatch call). GitHub queues each job in the workflow and assigns it to a runner — a hosted VM (ubuntu-latest, windows-latest, macos-latest) or a machine you operate (self-hosted). Inside a job, steps run sequentially in the same VM, sharing the filesystem and environment.

Jobs run in parallel by default. needs turns the graph into a sequence: a job waits for its dependencies to succeed before starting. Add if conditions on top to express branches — for example, deploy only when the ref is main:

Workflow execution flow: a push or pull request to main triggers lint and test jobs in parallel; the deploy job waits on both via needs and only runs on main, gated by a production environment approval before releasing.

Contexts and Expressions

Everything inside ${{ }} is an expression evaluated against a context. The ones you use most: github (event payload, ref, SHA), secrets (encrypted values), vars (plain configuration), matrix (current matrix combination), inputs (caller-supplied values in reusable and dispatch workflows), and env / runner for environment and machine data. Expressions work in run commands, if conditions, and with inputs.

Permissions and GITHUB_TOKEN

Every workflow runs with a GITHUB_TOKEN whose default permissions come from repository settings. Leave the workflow at permissions: read-all and grant contents: write or packages: write only to the jobs that need them — a compromised third-party action can only do what the token allows. For cloud deployments, prefer OIDC federation over long-lived secrets: the runner exchanges an ephemeral JWT for a cloud role, so there’s no AWS_ACCESS_KEY_ID sitting in Secrets.

Concurrency and Billing

A concurrency group cancels the older run when a newer push lands on the same ref, which stops stale deployments from racing newer ones:

concurrency:
  group: deploy-${{ github.ref }}
  cancel-in-progress: true

Hosted runner minutes are billed per OS: Linux minutes count 1x, Windows 2x, and macOS 10x. A 3-OS × 3-runtime matrix produces 9 jobs per commit, so matrix size has a direct price tag on private repositories — see GitHub Actions billing.

Variants

Reusable Workflow with Matrix Strategy

# .github/workflows/test-matrix.yml
name: Test Matrix
on:
  workflow_call:
    inputs:
      node-version:
        required: false
        type: string
        default: "20"

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false  # Don't cancel other jobs on failure
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
        node: [18, 20, 22]
        exclude:
          - os: macos-latest
            node: 18  # Skip old Node on macOS
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
          cache: 'npm'
      - run: npm ci
      - run: npm test -- --coverage
      - uses: codecov/codecov-action@v4
        if: matrix.os == 'ubuntu-latest' && matrix.node == 20

Environment Protection Rules

# .github/workflows/deploy.yml
name: Deploy
on:
  push:
    tags: ['v*']

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://myapp.example.com
    # Required reviewers must approve before deployment
    # Configured in repo Settings > Environments
    steps:
      - uses: actions/checkout@v4
      - name: Deploy to production
        run: |
          echo "Deploying ${{ github.ref_name }} to production"
          ./deploy.sh --env prod --version ${{ github.ref_name }}
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

Composite Action

# .github/actions/setup-project/action.yml
name: Setup Project
description: Install dependencies and cache them
inputs:
  node-version:
    required: false
    default: '20'

runs:
  using: composite
  steps:
    - uses: actions/setup-node@v4
      with:
        node-version: ${{ inputs.node-version }}
        cache: 'npm'
    - name: Install dependencies
      shell: bash
      run: npm ci
    - name: Build
      shell: bash
      run: npm run build
# Usage in a workflow
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ./.github/actions/setup-project
        with:
          node-version: '22'
      - run: npm test

Conditional Jobs and Steps

jobs:
  lint:
    runs-on: ubuntu-latest
    if: github.event_name == 'pull_request'
    steps:
      - uses: actions/checkout@v4
      - run: npm run lint

  deploy:
    needs: [lint, test]
    if: github.ref == 'refs/heads/main' && success()
    runs-on: ubuntu-latest
    steps:
      - run: echo "Deploying only on main after lint+test pass"

Job Summary for PR Comments

$GITHUB_STEP_SUMMARY renders Markdown in the Actions run view — a quick way to surface results without a bot comment. The durations here are illustrative; in a real run you would write measured values.

- name: Generate summary
  if: always()
  run: |
    echo "## Test Results" >> $GITHUB_STEP_SUMMARY
    echo "| Suite | Status |" >> $GITHUB_STEP_SUMMARY
    echo "|-------|--------|" >> $GITHUB_STEP_SUMMARY
    echo "| Unit  | ${{ job.status }} |" >> $GITHUB_STEP_SUMMARY
    echo "| E2E   | ${{ job.status }} |" >> $GITHUB_STEP_SUMMARY

What Works

  • Pin action versions using full commit SHAs for supply-chain security. Tags like v4 are convenient, but a compromised maintainer could repoint the tag to malicious code; a SHA runs the exact code every time.
  • Run npm ci, not npm install — ci installs exactly what package-lock.json pins, so the build can’t drift between a developer’s machine and the runner.
  • Split jobs: test, build, and deploy as separate jobs with explicit dependencies (needs). Failures stay isolated, jobs run in parallel, and logs are easier to read.
  • Use concurrency groups to cancel outdated runs when new commits are pushed. This saves CI minutes and prevents stale deployments from overwriting newer ones.
  • Store secrets in GitHub Secrets, never hardcode tokens in workflow files. Secrets are encrypted at rest and masked in logs. Even private repositories can leak through fork PRs.
  • Extract shared pipeline logic into reusable workflows — callers in other repos pass inputs, and a fix in the central repo reaches every consumer at once.
  • Fail fast with strict permissions: set permissions: read-all at the workflow level and grant write only where needed — a compromised action can’t do more than the token allows.
  • Watch the Actions bill — set a spending limit and review usage monthly. A matrix across 3 OSes and 3 Node versions produces 9 jobs per commit, and macOS minutes count 10x.
  • Add a restore-keys fallback to actions/cache so a stale cache still speeds up npm ci when the lock file changes:
- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-npm-
  • Use self-hosted runners for heavy builds when the hosted queue or CPU is the bottleneck:
runs-on: self-hosted  # Your own hardware
  • Shard test suites across runners with the matrix to parallelize long suites:
strategy:
  matrix:
    shard: [1, 2, 3, 4]
steps:
  - run: npm test -- --shard=${{ matrix.shard }}/4
  • Enable Docker layer caching to speed up image builds:
- uses: docker/build-push-action@v5
  with:
    cache-from: type=gha
    cache-to: type=gha,mode=max

Common Mistakes

  • Using actions/checkout without specifying a ref, causing detached HEAD issues or checking out the wrong commit when triggered by a tag. Use ref: ${{ github.ref }} explicitly when needed.
  • Deploying on every push instead of filtering by branch or tag — unfinished feature branches end up in production and your release process is bypassed entirely.
  • Not caching dependencies, causing builds that download the internet on every run. A well-tuned cache can cut install time from minutes to seconds.
  • Reaching for ubuntu-latest when you need a specific OS for native compilation. The latest label migrates over time — pin ubuntu-22.04 and the build stays reproducible.
  • Notification steps that skip if: failure() — Slack or email alerts fire only when the pipeline is green, which is exactly when you don’t need them.
  • Environment values written into the workflow file instead of repository variables — every URL or version bump becomes a YAML edit and a commit.
  • permissions: write-all and other broad grants — a compromised third-party action can rewrite your code, create releases, or touch secrets. Least privilege isn’t optional here.

Further Reading

Frequently Asked Questions

How do I run a workflow manually?

Add workflow_dispatch: to the on: block. It then shows up under the Actions tab as a manual run, with optional inputs the workflow can read.

Can I reuse workflows across repositories?

Yes. Create a workflow with on: workflow_call in a central repository, then reference it from others using the uses: org/shared-workflows/.github/workflows/reusable.yml@main form. The shared logic stays updated in one place.

How do I debug a failing workflow?

Use the tmate action to spawn an interactive SSH session inside the runner, or add set -x in shell steps for verbose output. You can also enable step debugging by setting the ACTIONS_STEP_DEBUG secret to true.

How do I prevent workflow runs from forks?

Use if: github.event.pull_request.head.repo.full_name == github.repository to skip workflows triggered by external forks. For workflows that touch secrets or production, go further: turn off fork PR triggers in the repo settings and gate runs behind manual approval.

What is the difference between workflow_dispatch and repository_dispatch?

workflow_dispatch is the manual trigger — the Actions tab or the API. repository_dispatch only exists for external callers: a third-party service hits the API endpoint when it needs a build that no Git event can start.

How do I share secrets across environments?

Use repository-level secrets for non-sensitive values and environment-level secrets for production credentials. Reference them with ${{ secrets.SECRET_NAME }}:

steps:
  - run: deploy.sh
    env:
      API_KEY: ${{ secrets.PROD_API_KEY }}  # Environment secret
      LOG_LEVEL: ${{ vars.LOG_LEVEL }}       # Repository variable
How do I run jobs sequentially vs in parallel?

By default, jobs run in parallel. Use needs to create dependencies:

jobs:
  build:    # Runs first
    runs-on: ubuntu-latest
  test:     # Runs after build
    needs: build
    runs-on: ubuntu-latest
  deploy:   # Runs after test
    needs: test
    runs-on: ubuntu-latest
How do I pass artifacts between jobs?

Use actions/upload-artifact and actions/download-artifact:

build:
  runs-on: ubuntu-latest
  steps:
    - run: npm run build
    - uses: actions/upload-artifact@v4
      with:
        name: dist
        path: dist/

deploy:
  needs: build
  runs-on: ubuntu-latest
  steps:
    - uses: actions/download-artifact@v4
      with:
        name: dist
        path: dist/
    - run: ./deploy.sh dist/