← All articles
CI/CD GitHub Actions: A Practical Guide to CI/CD Workflows 2026-02-09 · 5 min read · github-actions · ci-cd · automation

GitHub Actions: A Practical Guide to CI/CD Workflows

CI/CD 2026-02-09 · 5 min read github-actions ci-cd automation devops github workflows

GitHub Actions: A Practical Guide to CI/CD Workflows

GitHub Actions is the CI/CD platform built into GitHub. It's free for public repos and offers 2,000 minutes/month for private repos on the free plan. For most projects, it eliminates the need for external CI services like CircleCI or Travis CI.

This guide covers the workflow syntax in depth, common patterns, and the security practices that matter.

Workflow Basics

Workflows are YAML files in .github/workflows/. Each workflow is triggered by events (push, pull request, schedule, manual dispatch) and runs one or more jobs on GitHub-hosted or self-hosted runners.

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

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

jobs:
  test:
    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 test
      - run: npm run build

Key concepts:

Trigger push / PR Runner ubuntu-latest Checkout Setup Node npm test npm run build Pass Fail Deploy needs: test

Matrix Builds

Test across multiple versions, operating systems, or configurations with a single job definition:

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
        node-version: [18, 20, 22]
      fail-fast: false  # Don't cancel other jobs if one fails
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'
      - run: npm ci
      - run: npm test

This creates 9 jobs (3 OSes x 3 Node versions). Set fail-fast: false if you want all combinations to run even when one fails -- useful for understanding the full scope of failures.

Exclude specific combinations:

strategy:
  matrix:
    os: [ubuntu-latest, macos-latest, windows-latest]
    node-version: [18, 20, 22]
    exclude:
      - os: windows-latest
        node-version: 18

Caching Dependencies

Caching cuts build times dramatically. Most setup actions support caching built-in:

- uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: 'npm'  # Automatically caches ~/.npm

For more control, use the cache action directly:

- uses: actions/cache@v4
  with:
    path: |
      ~/.cargo/registry
      ~/.cargo/git
      target
    key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
    restore-keys: |
      ${{ runner.os }}-cargo-

The key determines cache hits. Use hashFiles() on lockfiles so the cache busts when dependencies change. restore-keys provides fallback -- a partial match is better than no cache.

Bun caching example:

- uses: oven-sh/setup-bun@v2
  with:
    bun-version: latest
- uses: actions/cache@v4
  with:
    path: ~/.bun/install/cache
    key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}
- run: bun install --frozen-lockfile

Artifacts

Upload build outputs, test results, or coverage reports as artifacts:

- run: npm test -- --coverage
- uses: actions/upload-artifact@v4
  with:
    name: coverage-report
    path: coverage/
    retention-days: 30

Download artifacts in a later job:

deploy:
  needs: test
  runs-on: ubuntu-latest
  steps:
    - uses: actions/download-artifact@v4
      with:
        name: coverage-report
        path: coverage/

Reusable Workflows and Composite Actions

Reusable Workflows

Extract common workflow patterns into reusable workflows that other workflows call:

# .github/workflows/reusable-test.yml
name: Reusable Test Workflow
on:
  workflow_call:
    inputs:
      node-version:
        required: true
        type: string
    secrets:
      NPM_TOKEN:
        required: false

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

Call it from another workflow:

# .github/workflows/ci.yml
jobs:
  test:
    uses: ./.github/workflows/reusable-test.yml
    with:
      node-version: '20'
    secrets: inherit  # Pass all secrets

Composite Actions

For reusable steps (not full jobs), create composite actions:

# .github/actions/setup-project/action.yml
name: 'Setup Project'
description: 'Install dependencies and build tools'
inputs:
  node-version:
    description: 'Node.js version'
    default: '20'
runs:
  using: 'composite'
  steps:
    - uses: actions/setup-node@v4
      with:
        node-version: ${{ inputs.node-version }}
        cache: 'npm'
    - run: npm ci
      shell: bash

Use it in a workflow:

steps:
  - uses: actions/checkout@v4
  - uses: ./.github/actions/setup-project
    with:
      node-version: '20'
  - run: npm test

Self-Hosted Runners

GitHub-hosted runners are convenient but limited: 2 vCPU, 7 GB RAM, and you pay for minutes on private repos. Self-hosted runners run on your own hardware.

jobs:
  build:
    runs-on: self-hosted  # or a custom label
    steps:
      - uses: actions/checkout@v4
      - run: ./build.sh

When self-hosted runners make sense:

Security warning: Never use self-hosted runners on public repos. Anyone who opens a PR can execute arbitrary code on your runner.

Security Best Practices

Permissions

Restrict the default GITHUB_TOKEN permissions to the minimum needed:

permissions:
  contents: read
  pull-requests: write  # Only if needed

jobs:
  test:
    runs-on: ubuntu-latest
    permissions:
      contents: read  # Job-level override

Set minimal defaults at the repository level: Settings > Actions > General > Workflow permissions > "Read repository contents and packages permissions."

Pin Actions by SHA

Don't use floating tags for third-party actions:

# Bad -- a compromised tag can execute arbitrary code
- uses: some-org/some-action@v2

# Good -- pinned to exact commit
- uses: some-org/some-action@a1b2c3d4e5f6789012345678901234567890abcd

For first-party actions (actions/checkout, actions/setup-node), tags are acceptable since GitHub controls them.

Secrets Management

env:
  DATABASE_URL: ${{ secrets.DATABASE_URL }}

OIDC for Cloud Deployments

Instead of storing cloud credentials as secrets, use OIDC (OpenID Connect) to get short-lived tokens:

permissions:
  id-token: write
  contents: read

steps:
  - uses: aws-actions/configure-aws-credentials@v4
    with:
      role-to-arn: arn:aws:iam::123456789012:role/github-actions
      aws-region: us-west-2
  - run: aws s3 sync ./dist s3://my-bucket

This eliminates long-lived AWS/GCP/Azure credentials from your repository secrets.

Common Patterns

Test on PR, Deploy on Merge

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

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm test

  deploy:
    needs: test
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm run build
      - run: ./deploy.sh

Path-Based Triggers

Only run workflows when relevant files change:

on:
  push:
    paths:
      - 'apps/web/**'
      - 'packages/shared/**'
      - 'package.json'
    paths-ignore:
      - '**.md'
      - 'docs/**'

Concurrency Control

Cancel redundant runs when new commits push to the same branch:

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

This ensures only the latest commit on a branch runs, saving minutes and avoiding deployment races.

The Bottom Line

GitHub Actions is powerful enough for most CI/CD needs. Start with a simple test-on-PR, deploy-on-merge workflow. Add caching early (it's the easiest performance win). Pin third-party actions by SHA and restrict token permissions. Use reusable workflows when you find yourself copying YAML across repos. And use OIDC instead of stored credentials for cloud deployments -- it's more secure and eliminates secret rotation.