Search…
CI/CD Pipelines · Part 3

GitHub Actions in depth

In this series (10 parts)
  1. What CI/CD actually means
  2. Pipeline anatomy and design
  3. GitHub Actions in depth
  4. GitLab CI/CD in depth
  5. Jenkins fundamentals
  6. Testing in CI pipelines
  7. Artifact management
  8. Pipeline security and supply chain
  9. Progressive delivery
  10. Self-hosted runners and pipeline scaling

GitHub Actions runs CI/CD pipelines directly in your GitHub repository. No external service to configure. No webhooks to set up. Push a YAML file to .github/workflows/ and the pipeline runs on the next trigger. The tight integration with GitHub’s pull requests, environments, and permissions model makes it a natural choice for teams already on GitHub.

Workflow file structure

A workflow is a YAML file in .github/workflows/. Each file defines one workflow. A repository can have multiple workflows.

name: CI Pipeline

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

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npm run build

The top-level keys:

  • name: displayed in the GitHub UI.
  • on: the event triggers.
  • permissions: token permissions for the workflow.
  • jobs: the actual work.

Events

Events determine when a workflow runs. The on key accepts dozens of event types.

Code events:

  • push: code pushed to a branch.
  • pull_request: PR opened, updated, or synchronized.
  • pull_request_target: like pull_request but runs in the context of the base branch. Used for workflows that need write permissions on fork PRs.

Scheduling:

  • schedule: cron expressions. Runs on GitHub’s schedule, not guaranteed to the second.

Manual:

  • workflow_dispatch: adds a “Run workflow” button in the GitHub UI. Supports input parameters.

Cross-workflow:

  • workflow_run: triggers after another workflow completes. Useful for chaining a deploy workflow after a build workflow.

Filtering events:

Events can be filtered by branch, path, or tag:

on:
  push:
    branches: [main, release/*]
    paths:
      - 'src/**'
      - 'package.json'
    tags:
      - 'v*'

Path filters prevent unnecessary pipeline runs. If you change only documentation, a path filter on src/** will skip the build entirely.

Jobs and runners

Each job runs on a runner, which is a virtual machine provisioned by GitHub (or self-hosted by your organization).

GitHub-hosted runners come in three flavors:

  • ubuntu-latest: the default. Linux, fast, cheap.
  • macos-latest: needed for iOS builds and macOS-specific testing.
  • windows-latest: needed for .NET Framework and Windows-specific testing.

Jobs run in parallel by default. Use needs to create dependencies:

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

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

  build:
    needs: [lint, test]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run build

In this setup, lint and test run in parallel. build waits for both to succeed.

Steps and actions

Steps are the commands within a job. There are two types:

Run steps execute shell commands:

- run: npm test
- run: |
    echo "Running migrations"
    npm run db:migrate
    npm run db:seed

Uses steps invoke actions, which are reusable packages of logic:

- uses: actions/checkout@v4
- uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: 'npm'

Actions come from three sources:

  1. GitHub-maintained actions like actions/checkout and actions/setup-node.
  2. Community actions from the GitHub Marketplace.
  3. Your own actions defined in the same repository or a shared repository.

Always pin actions to a specific version (@v4) or a commit SHA. Using @main means your pipeline can break when the action author pushes a breaking change.

Matrix builds

A matrix lets you run the same job across multiple configurations:

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

This creates three parallel jobs, one for each Node version. The fail-fast: false setting lets all matrix jobs finish even if one fails. This is useful for understanding the full picture of compatibility.

You can combine multiple dimensions:

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

This produces four jobs: Node 18 on Ubuntu, Node 18 on macOS, Node 20 on Ubuntu, Node 20 on macOS.

Secrets and environments

Secrets are encrypted values stored at the repository or organization level. They are available in workflows as ${{ secrets.SECRET_NAME }}.

- name: Deploy to production
  env:
    AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
    AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
  run: ./deploy.sh

Secrets are masked in logs. If a step accidentally prints a secret, GitHub replaces it with ***.

Environments add a layer of control. An environment can have:

  • Its own set of secrets (staging secrets vs production secrets).
  • Required reviewers who must approve before a job runs.
  • Wait timers that delay deployment.
  • Branch restrictions that limit which branches can deploy.
jobs:
  deploy-production:
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://app.example.com
    steps:
      - run: ./deploy.sh production

Reusable workflows

As your organization grows, duplicating pipeline logic across repositories becomes painful. Reusable workflows let you define a workflow once and call it from other workflows.

The reusable workflow lives in a shared repository:

# .github/workflows/node-ci.yml in org/shared-workflows
name: Node.js CI

on:
  workflow_call:
    inputs:
      node-version:
        required: false
        type: string
        default: '20'
    secrets:
      npm-token:
        required: false

jobs:
  ci:
    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
        env:
          NPM_TOKEN: ${{ secrets.npm-token }}
      - run: npm run lint
      - run: npm test
      - run: npm run build

Calling it from another repository:

name: CI
on: [push, pull_request]

jobs:
  ci:
    uses: org/shared-workflows/.github/workflows/node-ci.yml@v1
    with:
      node-version: '20'
    secrets:
      npm-token: ${{ secrets.NPM_TOKEN }}

Composite actions

Composite actions bundle multiple steps into a single reusable action. They are simpler than reusable workflows and work at the step level rather than the job level.

# .github/actions/setup-and-install/action.yml
name: Setup and Install
description: Checkout, setup Node, install dependencies
inputs:
  node-version:
    required: false
    default: '20'
runs:
  using: composite
  steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-node@v4
      with:
        node-version: ${{ inputs.node-version }}
        cache: 'npm'
    - run: npm ci
      shell: bash

Use composite actions for repeated step sequences. Use reusable workflows when you need to share entire job definitions.

Complete working pipeline

Here is a full workflow for a Node.js application. It lints, tests, builds, pushes a Docker image, and deploys to staging when a PR is merged to main.

name: CI/CD Pipeline

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

permissions:
  contents: read
  packages: write

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  lint:
    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 run lint

  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [18, 20]
      fail-fast: false
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'
      - run: npm ci
      - run: npm test -- --coverage
      - uses: actions/upload-artifact@v4
        if: matrix.node-version == 20
        with:
          name: coverage-report
          path: coverage/
          retention-days: 7

  build:
    needs: [lint, 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 run build
      - uses: actions/upload-artifact@v4
        with:
          name: build-output
          path: dist/
          retention-days: 1

  push-image:
    needs: build
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    outputs:
      image-tag: ${{ steps.meta.outputs.tags }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/download-artifact@v4
        with:
          name: build-output
          path: dist/
      - uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=
            type=raw,value=latest
      - uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}

  deploy-staging:
    needs: push-image
    runs-on: ubuntu-latest
    environment:
      name: staging
      url: https://staging.example.com
    steps:
      - uses: actions/checkout@v4
      - name: Deploy to staging
        env:
          DEPLOY_TOKEN: ${{ secrets.STAGING_DEPLOY_TOKEN }}
          IMAGE_TAG: ${{ needs.push-image.outputs.image-tag }}
        run: |
          echo "Deploying image to staging environment"
          ./scripts/deploy.sh staging
graph LR
  A[Push to main] --> B[lint]
  A --> C[test Node 18]
  A --> D[test Node 20]
  B --> E[build]
  C --> E
  D --> E
  E --> F[push-image]
  F --> G[deploy-staging]

The pipeline DAG for the workflow above. Lint and test run in parallel. Build waits for both. Image push and deploy happen only on main.

Cost and performance tips

GitHub Actions bills by the minute for private repositories. A few habits keep costs down:

  • Cache dependencies. The actions/setup-node action has built-in caching. Use it.
  • Skip unnecessary runs. Path filters and conditional logic prevent wasted minutes.
  • Use concurrency groups. Cancel in-progress runs when a new commit is pushed to the same branch:
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true
  • Right-size runners. Most jobs do not need a large runner. Use the default unless you have measured a bottleneck.
  • Keep artifacts small. Large artifacts increase upload and download time between jobs.

What comes next

The next articles cover GitLab CI/CD in depth and Jenkins fundamentals. If you use GitHub Actions exclusively, you can skip ahead to later articles in the series that cover testing strategies, deployment patterns, and pipeline security.

Start typing to search across all content
navigate Enter open Esc close