Tech Guides
01

Quick Reference

The essential YAML anatomy of a GitHub Actions workflow. Every workflow lives in .github/workflows/ and runs on GitHub-hosted or self-hosted runners.

Minimal Workflow

name: CI

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

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

Trigger Events (on)

Event When it fires Common filters
push Commits pushed to matching branches/tags branches, tags, paths
pull_request PR opened, synchronized, reopened branches, types, paths
schedule Cron-based schedule (UTC) cron: '0 6 * * 1'
workflow_dispatch Manual trigger via UI or API inputs for parameters
release GitHub release created/published types: [published]
workflow_call Called by another workflow inputs, secrets
repository_dispatch External webhook event types for custom events

Job & Step Structure

jobs:
  job-id:
    name: Display Name
    runs-on: ubuntu-latest
    needs: [other-job]            # dependency ordering
    if: github.ref == 'refs/heads/main'
    timeout-minutes: 30
    permissions:
      contents: read
      packages: write
    env:
      NODE_ENV: production
    steps:
      - uses: actions/checkout@v4  # use a marketplace action
      - name: Install deps
        run: npm ci                # run a shell command
      - name: Build
        run: npm run build
        env:
          API_KEY: ${{ secrets.API_KEY }}
      - name: Upload artifact
        uses: actions/upload-artifact@v4
        with:
          name: build-output
          path: dist/

Path & Branch Filtering

on:
  push:
    branches:
      - main
      - 'release/**'
    paths:
      - 'src/**'
      - 'package.json'
    paths-ignore:
      - '**.md'
      - 'docs/**'
    tags:
      - 'v*'
Tip: Use paths filtering to skip CI when only docs or config files change. This saves runner minutes and speeds up feedback loops.
02

Runners

Runners are the machines that execute your workflow jobs. GitHub hosts runners for you, or you can bring your own.

GitHub-Hosted Runners

Label OS CPUs RAM Disk
ubuntu-latest Ubuntu 24.04 4 16 GB 14 GB SSD
ubuntu-22.04 Ubuntu 22.04 4 16 GB 14 GB SSD
macos-latest macOS 14 (Sonoma) 3 (M1) 7 GB 14 GB SSD
macos-13 macOS 13 (Ventura) 4 (Intel) 14 GB 14 GB SSD
windows-latest Windows Server 2022 4 16 GB 14 GB SSD
Cost: macOS runners cost 10x Linux minutes. Windows runners cost 2x. Use Linux unless you specifically need the platform.

Larger Runners

GitHub offers larger runners (Teams and Enterprise plans) with more resources for demanding builds.

jobs:
  heavy-build:
    runs-on: ubuntu-latest-16-cores   # 16 vCPUs, 64 GB RAM
    # Other options: 8-cores, 32-cores, 64-cores
    # Also: macos-latest-xlarge (M1, 12 cores)
    # Also: windows-latest-8-cores

Self-Hosted Runners

# Register a self-hosted runner with custom labels
jobs:
  deploy:
    runs-on: [self-hosted, linux, x64, gpu]
    steps:
      - uses: actions/checkout@v4
      - name: Run GPU tests
        run: python test_cuda.py

Self-hosted runners give you full control over hardware, software, and network access. They connect to GitHub via outbound HTTPS and poll for jobs.

Security: Never use self-hosted runners on public repos. Forked PRs can run arbitrary code on your machine. Use GitHub-hosted runners for open-source projects.
03

Core Concepts

Events, contexts, expressions, and secrets form the programmable backbone of every Actions workflow.

Contexts

Contexts are objects containing information about the workflow run, runner, job, and more. Access them with the ${{ }} expression syntax.

Context Contains Example
github Event payload, repo, ref, actor ${{ github.ref_name }}
env Environment variables ${{ env.NODE_ENV }}
secrets Encrypted secrets ${{ secrets.API_KEY }}
vars Configuration variables ${{ vars.DEPLOY_URL }}
job Current job status, outputs ${{ job.status }}
steps Step outputs and outcomes ${{ steps.build.outputs.version }}
runner Runner OS, arch, temp dir ${{ runner.os }}
needs Outputs from dependent jobs ${{ needs.build.outputs.tag }}
matrix Current matrix values ${{ matrix.node-version }}

Expressions & Functions

# Conditionals
if: github.event_name == 'push'
if: contains(github.event.head_commit.message, '[skip ci]')
if: startsWith(github.ref, 'refs/tags/v')
if: always()       # run even if previous steps fail
if: failure()      # run only if previous steps failed
if: cancelled()    # run only if workflow was cancelled

# String functions
${{ format('Hello {0}', github.actor) }}
${{ join(github.event.issue.labels.*.name, ', ') }}

# JSON functions
${{ toJSON(github.event) }}
${{ fromJSON(steps.metadata.outputs.json) }}

# Comparison operators
if: github.event.pull_request.draft == false
if: github.run_attempt > 1

Secrets & Variables

# Secrets: encrypted, masked in logs
# Set in: Settings > Secrets and variables > Actions

# Repository secrets
${{ secrets.DEPLOY_TOKEN }}

# Organization secrets (shared across repos)
${{ secrets.ORG_NPM_TOKEN }}

# Environment secrets (scoped to environment)
${{ secrets.PROD_DATABASE_URL }}

# GITHUB_TOKEN: auto-generated per workflow run
${{ secrets.GITHUB_TOKEN }}
# or equivalently:
${{ github.token }}
GITHUB_TOKEN permissions: Defaults to read-only for forked PRs. Configure with the permissions key at the workflow or job level. Always follow least-privilege.

Step Outputs

steps:
  - name: Get version
    id: version
    run: echo "tag=$(git describe --tags)" >> $GITHUB_OUTPUT

  - name: Use version
    run: echo "Deploying ${{ steps.version.outputs.tag }}"

# Job outputs (for cross-job communication)
jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      version: ${{ steps.version.outputs.tag }}
    steps:
      - id: version
        run: echo "tag=v1.2.3" >> $GITHUB_OUTPUT

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - run: echo "Deploying ${{ needs.build.outputs.version }}"
04

Common Workflows

Battle-tested patterns for the most common CI/CD tasks. Copy, adapt, ship.

Test on Every Push

name: Test
on: [push, pull_request]

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 lint

Build & Deploy to GitHub Pages

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

permissions:
  contents: read
  pages: write
  id-token: write

concurrency:
  group: pages
  cancel-in-progress: false

jobs:
  build:
    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-pages-artifact@v3
        with:
          path: ./dist

  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    steps:
      - id: deployment
        uses: actions/deploy-pages@v4

Docker Build & Push

name: Docker
on:
  push:
    tags: ['v*']

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

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - uses: docker/metadata-action@v5
        id: meta
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            type=sha

      - uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}

Release with Changelog

name: Release
on:
  push:
    tags: ['v*']

permissions:
  contents: write

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Generate changelog
        id: changelog
        run: |
          PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
          if [ -n "$PREV_TAG" ]; then
            CHANGES=$(git log ${PREV_TAG}..HEAD --pretty=format:"- %s (%h)")
          else
            CHANGES=$(git log --pretty=format:"- %s (%h)")
          fi
          echo "changes<> $GITHUB_OUTPUT
          echo "$CHANGES" >> $GITHUB_OUTPUT
          echo "EOF" >> $GITHUB_OUTPUT

      - uses: softprops/action-gh-release@v2
        with:
          body: ${{ steps.changelog.outputs.changes }}
          generate_release_notes: true
05

Matrix & Reusable Workflows

Run jobs across multiple configurations in parallel, and extract shared logic into callable workflows.

Matrix Strategy

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false          # don't cancel siblings on failure
      max-parallel: 4           # limit concurrent jobs
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
        node: [18, 20, 22]
        exclude:
          - os: macos-latest
            node: 18
        include:
          - os: ubuntu-latest
            node: 22
            experimental: true
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
      - run: npm ci
      - run: npm test
    continue-on-error: ${{ matrix.experimental == true }}

Dynamic Matrix

jobs:
  generate:
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.set-matrix.outputs.matrix }}
    steps:
      - uses: actions/checkout@v4
      - id: set-matrix
        run: |
          # Generate matrix from directory listing
          DIRS=$(ls -d packages/*/  | jq -R -s -c 'split("\n")[:-1]')
          echo "matrix={\"package\":$DIRS}" >> $GITHUB_OUTPUT

  test:
    needs: generate
    runs-on: ubuntu-latest
    strategy:
      matrix: ${{ fromJSON(needs.generate.outputs.matrix) }}
    steps:
      - uses: actions/checkout@v4
      - run: cd ${{ matrix.package }} && npm test

Reusable Workflows

# .github/workflows/reusable-test.yml
name: Reusable Test
on:
  workflow_call:
    inputs:
      node-version:
        required: true
        type: string
      working-directory:
        required: false
        type: string
        default: '.'
    secrets:
      npm-token:
        required: false

jobs:
  test:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: ${{ inputs.working-directory }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
          registry-url: https://npm.pkg.github.com
        env:
          NODE_AUTH_TOKEN: ${{ secrets.npm-token }}
      - run: npm ci
      - run: npm test
# .github/workflows/ci.yml — calling the reusable workflow
name: CI
on: [push, pull_request]

jobs:
  test-frontend:
    uses: ./.github/workflows/reusable-test.yml
    with:
      node-version: '20'
      working-directory: frontend
    secrets:
      npm-token: ${{ secrets.NPM_TOKEN }}

  test-backend:
    uses: ./.github/workflows/reusable-test.yml
    with:
      node-version: '20'
      working-directory: backend

Concurrency Control

# Cancel redundant runs on the same branch
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

# Protect production deploys (queue, don't cancel)
concurrency:
  group: production-deploy
  cancel-in-progress: false
06

Caching & Artifacts

Speed up workflows by caching dependencies and persisting build outputs across jobs.

Dependency Caching

# Built-in caching with setup actions
- uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: npm          # also: yarn, pnpm

# Explicit cache action for anything else
- uses: actions/cache@v4
  with:
    path: |
      ~/.cargo/registry
      ~/.cargo/git
      target
    key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
    restore-keys: |
      ${{ runner.os }}-cargo-
Package Manager Cache Path Key File
npm ~/.npm package-lock.json
yarn ~/.cache/yarn yarn.lock
pnpm ~/.pnpm-store pnpm-lock.yaml
pip ~/.cache/pip requirements.txt
Go ~/go/pkg/mod go.sum
Cargo ~/.cargo, target Cargo.lock
Cache limits: 10 GB per repository. Caches not accessed in 7 days are evicted. Branch caches fall back to the default branch cache.

Artifacts

# Upload build artifacts
- uses: actions/upload-artifact@v4
  with:
    name: build-${{ github.sha }}
    path: |
      dist/
      !dist/**/*.map
    retention-days: 5       # default: 90

# Download in another job
- uses: actions/download-artifact@v4
  with:
    name: build-${{ github.sha }}
    path: ./dist

# Download all artifacts
- uses: actions/download-artifact@v4
  # omit 'name' to download everything

Cache vs Artifacts

Feature Cache Artifacts
Purpose Speed up dependency installs Persist build outputs
Scope Across workflow runs Within a single run (or downloadable)
Lifetime 7 days (LRU eviction) Configurable (1-90 days)
Size limit 10 GB per repo Varies by plan
Downloadable No Yes (UI or API)
07

Environments & Protection

Environments add approval gates, secret scoping, and deployment tracking to your workflows.

Environment Configuration

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:
    needs: deploy-staging
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://example.com
    steps:
      - uses: actions/checkout@v4
      - run: ./deploy.sh production
        env:
          DEPLOY_KEY: ${{ secrets.PROD_DEPLOY_KEY }}

Protection Rules

Required Reviewers

Up to 6 people or teams must approve before the deployment job runs.

Wait Timer

Delay deployment by 0-43200 minutes (up to 30 days) after approval.

Branch Restrictions

Only allow deployments from specific branches or tags.

Custom Rules

Third-party deployment protection rules via GitHub Apps.

Environment Secrets & Variables

Secrets and variables scoped to an environment override repository-level values of the same name. This lets you use the same variable name across staging and production with different values.

# Same workflow, different secrets per environment
jobs:
  deploy:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        env: [staging, production]
    environment: ${{ matrix.env }}
    steps:
      - run: echo "Deploying to ${{ matrix.env }}"
      # DATABASE_URL is different for each environment
      - run: ./migrate.sh
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
Note: Environment protection rules only work on public repos (free) or private repos on GitHub Pro, Team, or Enterprise plans.
08

Marketplace Actions

The most essential actions from the GitHub Marketplace. Pin versions to a major tag or full SHA for security.

Official Actions

Action Purpose Key Options
actions/checkout@v4 Clone the repository fetch-depth, ref, submodules
actions/setup-node@v4 Install Node.js node-version, cache, registry-url
actions/setup-python@v5 Install Python python-version, cache
actions/setup-go@v5 Install Go go-version, cache
actions/cache@v4 Cache files between runs path, key, restore-keys
actions/upload-artifact@v4 Persist files from a job name, path, retention-days
actions/download-artifact@v4 Retrieve persisted files name, path
actions/upload-pages-artifact@v3 Prepare GitHub Pages deploy path
actions/deploy-pages@v4 Deploy to GitHub Pages (automatic)

Popular Community Actions

Action Purpose
docker/build-push-action@v5 Build and push Docker images (with Buildx)
docker/login-action@v3 Authenticate with container registries
docker/metadata-action@v5 Generate Docker tags and labels from Git metadata
softprops/action-gh-release@v2 Create GitHub releases with assets
peter-evans/create-pull-request@v6 Create PRs from workflow changes
slackapi/slack-github-action@v1 Send notifications to Slack
aws-actions/configure-aws-credentials@v4 Configure AWS credentials (supports OIDC)
google-github-actions/auth@v2 Authenticate to Google Cloud (supports OIDC)

Pinning for Security

# Pin to a major version tag (convenient, auto-updates)
- uses: actions/checkout@v4

# Pin to a full commit SHA (most secure)
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11

# Pin to a specific version
- uses: actions/checkout@v4.1.7

# Use Dependabot to keep action versions updated
# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: github-actions
    directory: /
    schedule:
      interval: weekly
Supply chain risk: Third-party actions run code in your workflow. Always review the source. Prefer official actions, pin to SHAs for sensitive workflows, and enable Dependabot for update alerts.
09

Debugging & Local Testing

When workflows fail, these tools and techniques help you diagnose fast without pushing commit after commit.

Enable Debug Logging

# Option 1: Repository secret (persists)
# Set ACTIONS_STEP_DEBUG = true in repo secrets

# Option 2: Re-run with debug logging
# In the Actions UI: "Re-run jobs" > check "Enable debug logging"

# Option 3: Workflow-level debug
env:
  ACTIONS_STEP_DEBUG: true
  ACTIONS_RUNNER_DEBUG: true

Debugging Techniques

Dump Contexts

Print all context data to understand what's available.

- name: Dump contexts
  env:
    GITHUB_CONTEXT: ${{ toJSON(github) }}
    JOB_CONTEXT: ${{ toJSON(job) }}
  run: |
    echo "$GITHUB_CONTEXT"
    echo "$JOB_CONTEXT"

SSH into Runner

Open an interactive SSH session to a running job.

- uses: mxschmitt/action-tmate@v3
  if: failure()
  with:
    limit-access-to-actor: true

Local Testing with act

act runs your GitHub Actions workflows locally using Docker containers. It reads your workflow YAML files and simulates the GitHub Actions environment.

# Install act
brew install act          # macOS
curl -sfL https://raw.githubusercontent.com/nektos/act/master/install.sh | sh

# Run the default push event
act

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

# Run a specific job
act -j build

# Run with a specific event
act pull_request

# List available workflows
act -l

# Pass secrets
act -s MY_SECRET=value
act --secret-file .env

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

# Dry run (show what would execute)
act -n
Limitations: act does not support all GitHub Actions features. Service containers, some marketplace actions, and caching may behave differently. It is best used for testing shell commands and basic workflow logic.

Workflow Troubleshooting Checklist

Symptom Common Cause Fix
Workflow not triggered File not in .github/workflows/ Check path and branch filters
Resource not accessible Missing permissions Add required permissions to job or workflow
Secret is empty Forked PR (secrets not passed) Use pull_request_target or manual approval
Cache miss every time Key changes on every run Use hashFiles() on lock files, not source
Job skipped unexpectedly Bad if condition or failed needs Check condition syntax, add if: always()
Slow builds No caching, large checkout Add caching, use fetch-depth: 1
Concurrent deploy conflicts No concurrency control Add concurrency group

GitHub CLI for Actions

# List recent workflow runs
gh run list

# View a specific run
gh run view 12345678

# Watch a run in real-time
gh run watch

# Re-run failed jobs
gh run rerun 12345678 --failed

# Download artifacts
gh run download 12345678

# Trigger a workflow_dispatch manually
gh workflow run deploy.yml -f environment=staging

# View workflow run logs
gh run view 12345678 --log

# List workflows
gh workflow list