Every modern software team needs a reliable way to build, test, and deploy code automatically. Manual deployments are slow, error-prone, and don’t scale. GitHub Actions solves this by embedding CI/CD directly into your repository — no external services, no complex integrations, no extra billing accounts.

In this guide, you’ll build production-grade CI/CD pipelines from scratch. We’ll cover everything from basic workflow syntax to advanced patterns like matrix builds, reusable workflows, environment protection rules, and real-world deployment examples for Cloudflare Pages and Docker-based servers.

Prerequisites

Before you begin, make sure you have:

  • A GitHub account with at least one repository
  • Git installed locally and configured
  • A text editor or IDE (VS Code recommended)
  • Basic familiarity with YAML syntax
  • An application you want to build and deploy (we’ll use Node.js examples, but the concepts apply to any language)

Understanding GitHub Actions Core Concepts

GitHub Actions is built around five key concepts. Understanding them is essential before writing your first workflow.

Workflows

A workflow is an automated process defined in a YAML file inside the .github/workflows/ directory of your repository. A repository can have multiple workflows, each handling different automation tasks — one for CI, one for deployment, one for issue labeling.

Events (Triggers)

An event is something that triggers a workflow to run. Common events include:

  • push — code is pushed to a branch
  • pull_request — a PR is opened, updated, or merged
  • schedule — a cron-based schedule
  • workflow_dispatch — manual trigger from the GitHub UI
  • release — a new release is published

Jobs

A job is a set of steps that run on the same runner. Jobs run in parallel by default, but you can configure dependencies between them using the needs keyword to create sequential execution.

Steps

A step is an individual task within a job. Each step either runs a shell command (run) or uses a pre-built action (uses). Steps execute sequentially within a job and share the same filesystem.

Runners

A runner is the server that executes your workflow. GitHub provides hosted runners with Ubuntu Linux, Windows, and macOS. You can also configure self-hosted runners on your own infrastructure for specialized requirements.

Workflow Syntax Deep Dive

File Location

All workflow files must live in .github/workflows/ and use the .yml or .yaml extension:

mkdir -p .github/workflows
touch .github/workflows/ci.yml

Triggers

Push and Pull Request

The most common triggers fire on code changes:

on:
  push:
    branches: [main, develop]
    paths:
      - "src/**"
      - "package.json"
  pull_request:
    branches: [main]
    types: [opened, synchronize, reopened]

The paths filter restricts the workflow to only run when specific files change. The types filter controls which PR activities trigger the workflow.

Schedule (Cron)

Run workflows on a schedule using POSIX cron syntax:

on:
  schedule:
    # Run at 06:00 UTC every Monday
    - cron: "0 6 * * 1"

Scheduled workflows run on the default branch. The shortest interval is every 5 minutes, but GitHub may delay execution during periods of high load.

Manual Dispatch

Allow manual triggering from the GitHub UI with optional inputs:

on:
  workflow_dispatch:
    inputs:
      environment:
        description: "Target environment"
        required: true
        default: "staging"
        type: choice
        options:
          - staging
          - production
      dry_run:
        description: "Perform a dry run"
        required: false
        type: boolean
        default: false

Access the input values in your steps with ${{ github.event.inputs.environment }}.

Tag-Based Triggers

Trigger workflows when tags are pushed, useful for release pipelines:

on:
  push:
    tags:
      - "v*.*.*"

Jobs Configuration

Jobs define what runs and where. Each job gets a fresh runner instance:

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

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

The needs keyword creates a dependency: build only runs if test succeeds.

Steps and Actions

Steps are the atomic units of a job. They can run commands or use actions:

steps:
  # Use a pre-built action
  - name: Checkout code
    uses: actions/checkout@v4

  # Run a shell command
  - name: Install dependencies
    run: npm ci

  # Multi-line command
  - name: Run tests and lint
    run: |
      npm run lint
      npm test

  # Use a specific shell
  - name: Run PowerShell script
    shell: pwsh
    run: Write-Host "Hello from PowerShell"

Expressions and Contexts

GitHub Actions provides expressions for dynamic values and conditional logic:

steps:
  - name: Print event info
    run: |
      echo "Event: ${{ github.event_name }}"
      echo "Branch: ${{ github.ref_name }}"
      echo "SHA: ${{ github.sha }}"
      echo "Actor: ${{ github.actor }}"
      echo "Repository: ${{ github.repository }}"

  - name: Only on main branch
    if: github.ref == 'refs/heads/main'
    run: echo "This is the main branch"

  - name: Only on pull requests
    if: github.event_name == 'pull_request'
    run: echo "PR #${{ github.event.pull_request.number }}"

Your First Workflow: Node.js CI

Let’s build a complete CI workflow for a Node.js project. Create .github/workflows/ci.yml:

name: CI Pipeline

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

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

jobs:
  lint:
    name: Lint
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Run ESLint
        run: npm run lint

  test:
    name: Test
    runs-on: ubuntu-latest
    needs: lint
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

      - name: Upload coverage
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage/
          retention-days: 7

  build:
    name: Build
    runs-on: ubuntu-latest
    needs: test
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Build
        run: npm run build

      - name: Upload build artifacts
        uses: actions/upload-artifact@v4
        with:
          name: build-output
          path: dist/
          retention-days: 3

The concurrency block cancels in-progress runs when a new push arrives on the same branch, saving runner minutes.

Using Actions from the Marketplace

The GitHub Marketplace contains thousands of pre-built actions. Here are essential ones you’ll use regularly:

ActionPurpose
actions/checkout@v4Clone your repository
actions/setup-node@v4Install Node.js
actions/setup-python@v5Install Python
actions/cache@v4Cache dependencies
actions/upload-artifact@v4Store build artifacts
actions/download-artifact@v4Retrieve artifacts in another job
docker/build-push-action@v5Build and push Docker images
cloudflare/wrangler-action@v3Deploy to Cloudflare

To use a marketplace action, reference it with uses and pass configuration through with:

- name: Setup Python
  uses: actions/setup-python@v5
  with:
    python-version: "3.12"
    cache: "pip"

Caching Dependencies

Caching dramatically reduces workflow run time by reusing downloaded dependencies across runs. Use actions/cache or the built-in cache support in setup actions.

Using Setup Action Cache

The simplest approach — setup actions handle caching automatically:

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

Explicit Cache Control

For fine-grained control, use actions/cache directly:

- name: Cache node_modules
  id: cache-deps
  uses: actions/cache@v4
  with:
    path: node_modules
    key: deps-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
    restore-keys: |
      deps-${{ runner.os }}-

- name: Install dependencies
  if: steps.cache-deps.outputs.cache-hit != 'true'
  run: npm ci

The key uses a hash of your lockfile to create a unique cache entry. When the lockfile changes, a new cache is created. The restore-keys provide fallback cache keys for partial matches.

Caching for Other Languages

# Python with pip
- uses: actions/cache@v4
  with:
    path: ~/.cache/pip
    key: pip-${{ runner.os }}-${{ hashFiles('requirements.txt') }}

# Go modules
- uses: actions/cache@v4
  with:
    path: ~/go/pkg/mod
    key: go-${{ runner.os }}-${{ hashFiles('go.sum') }}

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

Matrix Builds

Matrix builds let you test across multiple versions, operating systems, or configurations in parallel. This is critical for libraries and tools that must work across different environments.

Basic Matrix

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

      - name: Setup Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: "npm"

      - run: npm ci
      - run: npm test

This creates 9 parallel jobs (3 OS × 3 Node versions). Setting fail-fast: false ensures all combinations run even if one fails.

Matrix with Include and Exclude

Customize specific matrix combinations:

strategy:
  matrix:
    os: [ubuntu-latest, windows-latest]
    node-version: [18, 20]
    exclude:
      # Skip Node 18 on Windows
      - os: windows-latest
        node-version: 18
    include:
      # Add an extra combination with experimental flag
      - os: ubuntu-latest
        node-version: 22
        experimental: true

Dynamic Matrix

Generate the matrix dynamically from a previous job:

jobs:
  determine-matrix:
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.set-matrix.outputs.matrix }}
    steps:
      - id: set-matrix
        run: |
          echo 'matrix={"version":["18","20","22"]}' >> $GITHUB_OUTPUT

  test:
    needs: determine-matrix
    runs-on: ubuntu-latest
    strategy:
      matrix: ${{ fromJson(needs.determine-matrix.outputs.matrix) }}
    steps:
      - run: echo "Testing version ${{ matrix.version }}"

Secrets and Environment Variables

Repository Secrets

Store sensitive values as encrypted secrets in Settings → Secrets and variables → Actions:

# These are added through the GitHub UI or CLI
gh secret set DEPLOY_TOKEN --body "your-token-here"
gh secret set AWS_ACCESS_KEY_ID --body "AKIA..."

Reference secrets in workflows:

steps:
  - name: Deploy
    env:
      DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
    run: ./deploy.sh

  - name: Push Docker image
    uses: docker/login-action@v3
    with:
      username: ${{ secrets.DOCKER_USERNAME }}
      password: ${{ secrets.DOCKER_PASSWORD }}

Important: Secrets are not available in workflows triggered by pull requests from forks. This is a security measure to prevent unauthorized access.

Environment Variables

Define variables at the workflow, job, or step level:

env:
  NODE_ENV: production
  CI: true

jobs:
  build:
    runs-on: ubuntu-latest
    env:
      BUILD_DIR: dist
    steps:
      - name: Build
        env:
          API_URL: https://api.example.com
        run: |
          echo "Node env: $NODE_ENV"
          echo "Build dir: $BUILD_DIR"
          echo "API: $API_URL"

Setting Dynamic Variables

Pass values between steps using $GITHUB_OUTPUT and $GITHUB_ENV:

steps:
  - name: Set version
    id: version
    run: |
      VERSION=$(node -p "require('./package.json').version")
      echo "version=$VERSION" >> $GITHUB_OUTPUT

  - name: Set global env
    run: echo "RELEASE_TAG=v${{ steps.version.outputs.version }}" >> $GITHUB_ENV

  - name: Use values
    run: |
      echo "Version: ${{ steps.version.outputs.version }}"
      echo "Tag: $RELEASE_TAG"

Artifacts

Artifacts let you persist data from a workflow run and share files between jobs.

Uploading Artifacts

- name: Build application
  run: npm run build

- name: Upload build
  uses: actions/upload-artifact@v4
  with:
    name: webapp-build
    path: |
      dist/
      !dist/**/*.map
    retention-days: 5
    if-no-files-found: error

Downloading Artifacts in Another Job

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

    - name: Deploy
      run: ./deploy.sh dist/

Downloading All Artifacts

- name: Download all artifacts
  uses: actions/download-artifact@v4
  with:
    path: all-artifacts/
    merge-multiple: true

Environments and Protection Rules

GitHub Environments let you define deployment targets with protection rules, secrets, and approval gates.

Creating Environments

Navigate to Settings → Environments and create environments like staging and production. Configure:

  • Required reviewers — specific team members must approve before the deployment proceeds
  • Wait timer — add a delay before deployment starts
  • Branch restrictions — only allow deployments from specific branches

Using Environments in Workflows

jobs:
  deploy-staging:
    runs-on: ubuntu-latest
    environment:
      name: staging
      url: https://staging.example.com
    steps:
      - uses: actions/checkout@v4
      - run: ./deploy.sh staging
        env:
          DEPLOY_KEY: ${{ secrets.STAGING_DEPLOY_KEY }}

  deploy-production:
    runs-on: ubuntu-latest
    needs: deploy-staging
    environment:
      name: production
      url: https://example.com
    steps:
      - uses: actions/checkout@v4
      - run: ./deploy.sh production
        env:
          DEPLOY_KEY: ${{ secrets.PRODUCTION_DEPLOY_KEY }}

Environment-specific secrets override repository-level secrets with the same name, allowing different credentials per environment.

Deployment Examples

Deploy to Cloudflare Pages

This workflow builds a static site and deploys it to Cloudflare Pages:

name: Deploy to Cloudflare Pages

on:
  push:
    branches: [main]

jobs:
  deploy:
    name: Deploy
    runs-on: ubuntu-latest
    environment:
      name: production
      url: ${{ steps.deploy.outputs.deployment-url }}
    permissions:
      contents: read
      deployments: write
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Build site
        run: npm run build

      - name: Deploy to Cloudflare Pages
        id: deploy
        uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          command: pages deploy dist/ --project-name=my-project

Add your Cloudflare API token and account ID as repository secrets.

Deploy Docker to a Server via SSH

This workflow builds a Docker image, pushes it to a registry, and deploys to a remote server over SSH:

name: Deploy Docker via SSH

on:
  push:
    tags:
      - "v*.*.*"

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

jobs:
  build-and-push:
    name: Build & Push Image
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    outputs:
      image-tag: ${{ steps.meta.outputs.tags }}
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Log in to Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=semver,pattern={{version}}
            type=sha

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

  deploy:
    name: Deploy to Server
    needs: build-and-push
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            docker pull ${{ needs.build-and-push.outputs.image-tag }}
            docker compose -f /opt/app/docker-compose.yml up -d --no-deps app
            docker image prune -f

This pattern separates the build (which runs on GitHub’s infrastructure) from the deployment (which connects to your server), keeping your CI pipeline fast and your server doing minimal work.

Reusable Workflows

Reusable workflows eliminate duplication across repositories. Define a workflow once and call it from other workflows.

Creating a Reusable Workflow

Create .github/workflows/reusable-deploy.yml:

name: Reusable Deploy

on:
  workflow_call:
    inputs:
      environment:
        required: true
        type: string
      node-version:
        required: false
        type: number
        default: 20
    secrets:
      deploy-token:
        required: true

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
          cache: "npm"

      - run: npm ci
      - run: npm run build

      - name: Deploy
        env:
          DEPLOY_TOKEN: ${{ secrets.deploy-token }}
        run: ./scripts/deploy.sh ${{ inputs.environment }}

Calling a Reusable Workflow

name: Production Deploy

on:
  push:
    branches: [main]

jobs:
  deploy-staging:
    uses: ./.github/workflows/reusable-deploy.yml
    with:
      environment: staging
    secrets:
      deploy-token: ${{ secrets.STAGING_TOKEN }}

  deploy-production:
    needs: deploy-staging
    uses: ./.github/workflows/reusable-deploy.yml
    with:
      environment: production
    secrets:
      deploy-token: ${{ secrets.PRODUCTION_TOKEN }}

You can also reference reusable workflows from other repositories:

jobs:
  deploy:
    uses: my-org/shared-workflows/.github/workflows/deploy.yml@main
    with:
      environment: production
    secrets: inherit

Self-Hosted Runners

GitHub-hosted runners work for most workloads, but self-hosted runners are necessary when you need:

  • Custom hardware — GPU access, ARM architecture, high-memory machines
  • Network access — connecting to internal services behind a firewall
  • Persistent tools — specialized software that’s expensive to install on every run
  • Cost control — avoiding minute charges for heavy workloads

Setting Up a Self-Hosted Runner

Navigate to Settings → Actions → Runners → New self-hosted runner and follow the installation instructions:

# Download the runner package
mkdir actions-runner && cd actions-runner
curl -o actions-runner-linux-x64.tar.gz -L \
  https://github.com/actions/runner/releases/download/v2.321.0/actions-runner-linux-x64-2.321.0.tar.gz
tar xzf actions-runner-linux-x64.tar.gz

# Configure the runner
./config.sh --url https://github.com/YOUR-ORG/YOUR-REPO \
  --token YOUR_REGISTRATION_TOKEN \
  --labels gpu,high-memory

# Install and start as a service
sudo ./svc.sh install
sudo ./svc.sh start

Using Self-Hosted Runners

jobs:
  gpu-training:
    runs-on: [self-hosted, gpu]
    steps:
      - uses: actions/checkout@v4
      - run: python train_model.py

Use labels to target specific runner configurations.

Security Best Practices

Pin Actions by SHA

Instead of using mutable tags, pin actions to specific commit SHAs. This prevents supply chain attacks where a compromised action tag is pointed to malicious code:

# Avoid — tags can be moved to a compromised commit
- uses: actions/checkout@v4

# Prefer — pinned to exact commit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

Use Dependabot to keep pinned SHAs up to date:

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"

Minimal Permissions

Follow the principle of least privilege. Set restrictive default permissions and only grant what each job needs:

# Restrict default permissions for all jobs
permissions:
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      deployments: write
    steps:
      - uses: actions/checkout@v4

Protect Secrets

  • Never print secrets in logs. GitHub masks known secrets automatically, but derived values won’t be masked.
  • Use environment-level secrets for sensitive deployment credentials.
  • Rotate secrets regularly.
  • Avoid passing secrets to actions you don’t trust.
# BAD — secret could leak in error output
- run: curl -H "Authorization: Bearer ${{ secrets.API_TOKEN }}" https://api.example.com

# BETTER — use an environment variable
- run: curl -H "Authorization: Bearer $API_TOKEN" https://api.example.com
  env:
    API_TOKEN: ${{ secrets.API_TOKEN }}

Restrict Fork PR Workflows

Pull requests from forks can run modified workflow files. Restrict this to prevent malicious code execution:

on:
  pull_request_target:
    types: [labeled]

jobs:
  test:
    if: contains(github.event.pull_request.labels.*.name, 'safe-to-test')
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}

Monitoring and Debugging

Local Testing with act

act runs your GitHub Actions workflows locally using Docker. Install it and test before pushing:

# Install act
brew install act          # macOS
curl -s https://raw.githubusercontent.com/nektos/act/master/install.sh | sudo bash  # Linux

# Run all workflows
act

# Run a specific workflow
act -W .github/workflows/ci.yml

# Run a specific job
act -j test

# Simulate a push event to a specific branch
act push --eventpath event.json

# List all available jobs without running
act -l

# Use a specific runner image
act -P ubuntu-latest=catthehacker/ubuntu:act-latest

Create an .actrc file in your repository root for default settings:

-P ubuntu-latest=catthehacker/ubuntu:act-latest
--secret-file .env.secrets

Debug Logging

Enable debug logging by setting the ACTIONS_STEP_DEBUG secret to true in your repository settings, or re-run a failed workflow with debug logging from the GitHub UI.

Add your own debug output in steps:

steps:
  - name: Debug context
    run: |
      echo "::debug::Event payload: ${{ toJson(github.event) }}"
      echo "::notice::Deployment starting for ${{ github.ref_name }}"
      echo "::warning::Cache miss, full install required"

  - name: Group log output
    run: |
      echo "::group::Install Output"
      npm ci
      echo "::endgroup::"

Workflow Run Monitoring

Use the GitHub CLI to monitor workflow runs from your terminal:

# List recent workflow runs
gh run list

# Watch a running workflow in real time
gh run watch

# View a specific run
gh run view 12345678

# Download run logs
gh run view 12345678 --log

# Re-run a failed workflow
gh run rerun 12345678 --failed

Common Patterns

Conditional Steps

Run steps based on conditions like branch, event type, or previous step results:

steps:
  - name: Deploy to staging
    if: github.ref == 'refs/heads/develop'
    run: ./deploy.sh staging

  - name: Deploy to production
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    run: ./deploy.sh production

  - name: Cleanup on failure
    if: failure()
    run: ./rollback.sh

  - name: Notify always
    if: always()
    run: echo "Workflow completed with status ${{ job.status }}"

  - name: Only when files changed
    if: contains(github.event.head_commit.modified, 'docs/')
    run: npm run build-docs

Manual Approval Gate

Require manual approval before proceeding with a deployment:

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

  approve:
    needs: test
    runs-on: ubuntu-latest
    environment: production  # Requires approval if protection rules are configured
    steps:
      - run: echo "Approved for deployment"

  deploy:
    needs: approve
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: ./deploy.sh production

Scheduled Maintenance Tasks

Automate recurring tasks like dependency updates, cleanup, or reporting:

name: Weekly Maintenance

on:
  schedule:
    - cron: "0 3 * * 0"  # Sunday at 03:00 UTC

jobs:
  cleanup:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Clean old artifacts
        uses: actions/github-script@v7
        with:
          script: |
            const thirtyDaysAgo = new Date();
            thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
            const artifacts = await github.rest.actions.listArtifactsForRepo({
              owner: context.repo.owner,
              repo: context.repo.repo,
            });
            for (const artifact of artifacts.data.artifacts) {
              if (new Date(artifact.created_at) < thirtyDaysAgo) {
                await github.rest.actions.deleteArtifact({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  artifact_id: artifact.id,
                });
              }
            }

  security-audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm audit --audit-level=high

Path-Based Monorepo Workflows

Run specific workflows only when relevant code changes in a monorepo:

name: Backend CI

on:
  push:
    paths:
      - "packages/backend/**"
      - "packages/shared/**"
      - "package-lock.json"

jobs:
  test-backend:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: packages/backend
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "npm"
      - run: npm ci
      - run: npm test

Status Badges

Add workflow status badges to your README:

![CI](https://github.com/YOUR-USER/YOUR-REPO/actions/workflows/ci.yml/badge.svg)
![Deploy](https://github.com/YOUR-USER/YOUR-REPO/actions/workflows/deploy.yml/badge.svg?branch=main)

Composite Actions

Create your own reusable action that combines multiple steps:

# .github/actions/setup-project/action.yml
name: "Setup Project"
description: "Install Node.js and project dependencies"
inputs:
  node-version:
    description: "Node.js version"
    required: false
    default: "20"
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: Verify installation
      shell: bash
      run: node --version && npm --version

Use it in your workflows:

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

Complete Production Pipeline

Here’s a full pipeline that ties together everything covered in this guide — linting, testing with matrix builds, building, and deploying with environment protection:

name: Production Pipeline

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

permissions:
  contents: read

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
  lint:
    name: Lint & Format
    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
      - run: npm run format:check

  test:
    name: Test (Node ${{ matrix.node-version }})
    runs-on: ubuntu-latest
    needs: lint
    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 }}
          cache: "npm"
      - run: npm ci
      - run: npm test -- --coverage
      - uses: actions/upload-artifact@v4
        if: matrix.node-version == 20
        with:
          name: coverage
          path: coverage/

  build:
    name: Build
    runs-on: ubuntu-latest
    needs: test
    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
          path: dist/

  deploy-staging:
    name: Deploy Staging
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    needs: build
    runs-on: ubuntu-latest
    environment:
      name: staging
      url: https://staging.example.com
    permissions:
      contents: read
      deployments: write
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: build
          path: dist/
      - name: Deploy to Staging
        run: ./scripts/deploy.sh staging
        env:
          DEPLOY_TOKEN: ${{ secrets.STAGING_DEPLOY_TOKEN }}

  deploy-production:
    name: Deploy Production
    needs: deploy-staging
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://example.com
    permissions:
      contents: read
      deployments: write
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: build
          path: dist/
      - name: Deploy to Production
        run: ./scripts/deploy.sh production
        env:
          DEPLOY_TOKEN: ${{ secrets.PRODUCTION_DEPLOY_TOKEN }}

Summary

GitHub Actions transforms your repository into a complete CI/CD platform. The key takeaways:

  1. Start simple — a basic test workflow on push is better than no automation at all
  2. Cache aggressively — use the built-in cache support in setup actions to save minutes
  3. Use matrix builds for libraries that must support multiple environments
  4. Protect production with environment rules, required reviewers, and branch restrictions
  5. Pin actions by SHA and enable Dependabot for supply chain security
  6. Reuse workflows across repositories to maintain consistency and reduce duplication
  7. Test locally with act before pushing to save iteration time

Every push, every pull request, every release — automated, tested, and deployed without manual intervention. That’s the power of a well-designed CI/CD pipeline.