← Back to Blog

GitHub Actions: A Complete Guide to CI/CD Workflows

March 12, 2026 12 min read By CodeTidy Team

GitHub Actions is the CI/CD platform built into every GitHub repository. It lets you automate builds, tests, deployments, and virtually any workflow — triggered by pushes, pull requests, schedules, or manual dispatch. Since its public release in 2019, it has become the default CI/CD choice for open-source projects and a major player in enterprise DevOps. This guide covers everything you need to write production-quality workflows.

How GitHub Actions Works

At its core, GitHub Actions runs workflows — YAML files stored in the .github/workflows/ directory of your repository. When a trigger event occurs (like a push to main), GitHub spins up a virtual machine, clones your code, and executes the steps you defined. Here's the hierarchy:

Workflow (.yml file)
  └── Job (runs on a VM)
        └── Step (individual command or action)
              └── Action (reusable unit, e.g. actions/checkout@v4)

A workflow can have multiple jobs. By default, jobs run in parallel. Steps within a job run sequentially. Each job gets a fresh virtual machine — they don't share filesystems unless you use artifacts or caching.

Your First Workflow

Create .github/workflows/ci.yml in your repository:

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

This workflow runs on every push to main and every pull request targeting main. It checks out the code, installs Node.js 20 with npm caching, installs dependencies, and runs tests. Let's break down each section.

Triggers: The on Key

The on key defines when your workflow runs. GitHub Actions supports dozens of event triggers:

TriggerWhen It FiresCommon Use
pushCode pushed to branchCI builds, deploys
pull_requestPR opened, updated, or synchronizedPR checks, previews
workflow_dispatchManual button in GitHub UIOn-demand deploys, maintenance
scheduleCron schedule (UTC)Nightly builds, cleanup
releaseRelease publishedNPM publish, Docker push
workflow_callCalled by another workflowReusable workflows

You can filter triggers by branch, path, or tag:

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

The paths filter is powerful for monorepos — you can run a workflow only when relevant files change, saving CI minutes.

Scheduled Workflows

Use cron expressions to run workflows on a schedule:

on:
  schedule:
    - cron: '0 6 * * 1-5'  # 6 AM UTC, Monday through Friday

Important caveats: scheduled workflows only run on the default branch (usually main), they use UTC timezone, and during high-load periods, GitHub may delay or skip scheduled runs. Don't rely on them for time-critical operations.

Jobs and Runners

Each job runs on a runner — a virtual machine hosted by GitHub. The runs-on key specifies the operating system:

RunnerOSSpecs
ubuntu-latestUbuntu 24.044 vCPUs, 16 GB RAM, 14 GB SSD
ubuntu-22.04Ubuntu 22.044 vCPUs, 16 GB RAM, 14 GB SSD
windows-latestWindows Server 20224 vCPUs, 16 GB RAM, 14 GB SSD
macos-latestmacOS 14 (Sonoma)3 or 4 vCPUs, 7 or 14 GB RAM

Linux runners are fastest to provision and cheapest (free for public repos, $0.008/minute for private). macOS runners cost 10× more. Use Linux unless you specifically need macOS or Windows for testing.

Job Dependencies

Use needs to create dependencies between jobs:

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

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

The deploy job only runs after test succeeds, and only on the main branch.

Steps: uses vs run

Each step either runs a shell command (run) or uses a published action (uses):

steps:
  # Use a published action from the marketplace
  - uses: actions/checkout@v4

  # Use an action with configuration
  - uses: actions/setup-node@v4
    with:
      node-version: 20

  # Run a shell command
  - run: echo "Hello from CI"

  # Run a multi-line script
  - name: Build and test
    run: |
      npm ci
      npm run build
      npm test

Always pin actions to a specific version (@v4) or commit SHA (@a5ac7e51b41094c92402da3b24376905380afc29) rather than using @main. This prevents supply chain attacks where a compromised action could steal your secrets.

Matrix Builds

Matrix strategies run the same job with different configurations — perfect for testing across multiple Node.js versions, operating systems, or Python versions:

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
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
      - run: npm ci
      - run: npm test

This creates 9 jobs (3 OS × 3 Node versions). The fail-fast: false setting ensures all combinations run even if one fails — useful for seeing the full compatibility picture.

Secrets and Environment Variables

Never hardcode credentials in workflow files. Use GitHub's encrypted secrets:

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

Add secrets in your repository settings under Settings → Secrets and variables → Actions. Secrets are masked in logs — GitHub will replace them with *** if they appear in output. For organization-wide secrets, use organization-level secrets with repository access policies.

Environment Protection Rules

For production deployments, use environments with protection rules:

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://myapp.com
    steps:
      - run: ./deploy.sh

Configure the production environment in repository settings to require manual approval, restrict to specific branches, or add wait timers. This prevents accidental deployments from feature branches.

Caching Dependencies

Caching dramatically speeds up workflows. Most setup actions have built-in caching:

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

# Python with pip caching
- uses: actions/setup-python@v5
  with:
    python-version: '3.12'
    cache: 'pip'

# Manual cache for custom paths
- uses: actions/cache@v4
  with:
    path: ~/.cache/my-tool
    key: my-tool-${{ hashFiles('config.lock') }}

Cache hits typically save 30–60 seconds per workflow. Caches are scoped to the branch — a feature branch can read caches from its base branch but not vice versa.

Artifacts: Sharing Files Between Jobs

Since each job runs on a separate VM, use artifacts to pass files between jobs:

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

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

Artifacts are also downloadable from the GitHub Actions UI — useful for build outputs, test reports, and coverage data.

Conditional Execution

Use if expressions to control when jobs or steps run:

# Only run on main branch
if: github.ref == 'refs/heads/main'

# Only run when PR is merged (not just closed)
if: github.event.pull_request.merged == true

# Run even if previous steps failed
if: always()

# Skip if commit message contains [skip ci]
if: "!contains(github.event.head_commit.message, '[skip ci]')"

# Only run for specific actor
if: github.actor == 'dependabot[bot]'

Common Workflow Patterns

CI for a Node.js Project

name: CI
on: [push, pull_request]

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

Publish to NPM on Release

name: Publish
on:
  release:
    types: [published]

jobs:
  publish:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          registry-url: 'https://registry.npmjs.org'
      - run: npm ci
      - run: npm publish --provenance --access public
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

Deploy to Cloudflare Pages

name: Deploy
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      deployments: write
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci
      - run: npm run build
      - uses: cloudflare/wrangler-action@v3
        with:
          command: pages deploy dist --project-name=my-site
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}

Debugging Failed Workflows

When a workflow fails, start with these techniques:

  1. Read the logs — click the failed step in the Actions tab to see full output. Most failures have clear error messages.
  2. Enable debug logging — add the secret ACTIONS_STEP_DEBUG with value true to get verbose output from actions.
  3. Use tmate for SSH access — the mxschmitt/action-tmate@v3 action gives you an SSH session into the runner for interactive debugging.
  4. Check runner statusgithubstatus.com shows if there are platform-wide issues.
  5. Validate your YAML — use our GitHub Actions Validator to catch syntax errors before pushing.

Performance Tips

  • Cache aggressivelynpm ci with cache takes 5 seconds instead of 30+
  • Use paths filters — don't run the full CI pipeline when only docs changed
  • Parallelize jobs — split test, lint, and build into separate jobs that run simultaneously
  • Use concurrency — cancel in-progress runs when a new push arrives to the same branch
  • Pin to specific Ubuntu versionsubuntu-22.04 instead of ubuntu-latest avoids surprise breakages when the latest label rolls forward
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

Security Best Practices

  • Pin action versions — use commit SHAs (actions/checkout@a5ac7e51...) for critical workflows instead of mutable tags
  • Minimize permissions — use the permissions key to grant only what's needed
  • Protect secrets — never echo secrets, use environment-level secrets for production
  • Review third-party actions — check the source code before trusting actions with your secrets
  • Use CODEOWNERS — require approval for changes to .github/workflows/

Free Tier Limits

GitHub Actions is free for public repositories with unlimited minutes. For private repositories on the free plan, you get 2,000 minutes per month. Linux runners consume 1× minutes, Windows 2×, and macOS 10×. A typical Node.js CI workflow takes 1–3 minutes, so 2,000 minutes covers roughly 700–2,000 CI runs per month — more than enough for most teams.

Next Steps

Start with a simple CI workflow that runs your tests on every push and pull request. Once that's working, add linting, type checking, and build verification. Then layer on deployment workflows with environment protection rules and manual approvals. Use our GitHub Actions Validator to catch YAML syntax errors before you commit, and the YAML Formatter to keep your workflow files clean and consistent.

Drop file to load