MeshWorld India Logo MeshWorld.
Cheatsheet GitHub Actions CI/CD DevOps Automation Developer Tools 10 min read

GitHub Actions Cheat Sheet: Workflows, Jobs & Recipes

Cobie
By Cobie
| Updated: May 17, 2026
GitHub Actions Cheat Sheet: Workflows, Jobs & Recipes
TL;DR
  • Workflows live in .github/workflows/*.yml — every file is a separate workflow
  • on: sets the trigger, jobs: defines parallel work units, steps: are sequential tasks
  • Secrets live in repo/org Settings → Secrets, accessed via ${{ secrets.MY_KEY }}
  • actions/checkout@v4, actions/cache@v4, actions/upload-artifact@v4 cover 90% of needs
  • Use strategy.matrix to run the same job across multiple OS / language versions

Quick reference tables

Workflow triggers (on:)

| Trigger | Example | When it fires | |---|---|---| | push | on: push | Any branch push | | push with filter | branches: [main] | Push to specific branches | | pull_request | on: pull_request | PR opened, synced, or reopened | | pull_request_target | — | PR from fork (runs in base context) | | workflow_dispatch | — | Manual trigger via GitHub UI or API | | schedule | cron: '0 9 * * 1' | Cron schedule (UTC) | | workflow_call | — | Called from another workflow (reusable) | | release | types: [published] | GitHub Release created | | issue_comment | types: [created] | Comment posted on issue or PR |

runs-on values

| Value | Machine | |---|---| | ubuntu-latest | Ubuntu 24.04 | | ubuntu-22.04 | Ubuntu 22.04 (pinned) | | windows-latest | Windows Server 2022 | | macos-latest | macOS 14 (Apple Silicon) | | macos-13 | macOS 13 (Intel, use for x86_64) | | self-hosted | Your own runner |

Step types

| Type | Syntax | Use for | |---|---|---| | Shell command | run: echo "hello" | Arbitrary shell | | Multi-line shell | run: \| then indented lines | Multi-command blocks | | Action | uses: actions/checkout@v4 | Pre-built reusable steps | | Action with inputs | with: { key: value } | Parameterized actions |

Context variables

| Variable | Value | |---|---| | ${{ github.sha }} | Full commit SHA | | ${{ github.ref }} | Ref that triggered the run (e.g. refs/heads/main) | | ${{ github.ref_name }} | Short branch/tag name | | ${{ github.actor }} | Username that triggered the run | | ${{ github.repository }} | owner/repo | | ${{ github.event_name }} | push, pull_request, etc. | | ${{ github.run_number }} | Auto-incrementing run number | | ${{ runner.os }} | Linux, Windows, or macOS | | ${{ env.MY_VAR }} | Value from env: block | | ${{ secrets.MY_SECRET }} | Value from repo Secrets |

Conditional expressions

| Expression | Meaning | |---|---| | if: github.ref == 'refs/heads/main' | Only on main branch | | if: success() | Only if all previous steps passed (default) | | if: failure() | Only if a previous step failed | | if: always() | Always run, even after failure | | if: cancelled() | Only if the workflow was cancelled | | if: contains(github.ref, 'release') | Branch name contains “release” |


Workflow anatomy

The minimal complete workflow — every field explained:

yaml
name: CI                          # Shown in GitHub UI

on:
  push:
    branches: [main]              # Only trigger on main
  pull_request:                   # All PRs

jobs:
  test:                           # Job ID (any name)
    runs-on: ubuntu-latest        # Machine type
    steps:
      - uses: actions/checkout@v4 # Always first — clones your repo

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install dependencies
        run: npm ci                # ci = clean, reproducible install

      - name: Run tests
        run: npm test

Core building blocks

Environment variables

Set variables at workflow, job, or step scope:

yaml
env:
  NODE_ENV: production            # Workflow-level (all jobs)

jobs:
  build:
    env:
      API_URL: https://api.example.com   # Job-level

    steps:
      - name: Deploy
        env:
          DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}  # Step-level
        run: ./deploy.sh

Secrets

Store sensitive values in Settings → Secrets and variables → Actions. Never hard-code them.

yaml
steps:
  - name: Push Docker image
    env:
      DOCKER_PASSWORD: ${{ secrets.DOCKER_HUB_TOKEN }}
    run: echo "$DOCKER_PASSWORD" | docker login -u myuser --password-stdin
Fork PR Limitation

Secrets are NOT available in pull_request workflows triggered from forks — use pull_request_target with caution, or store non-sensitive config in vars.* (repository variables, not secrets).

Dependent jobs

Use needs: to chain jobs. A job only starts after all listed jobs succeed:

yaml
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - run: npm test

  build:
    needs: test           # Won't start until test passes
    runs-on: ubuntu-latest
    steps:
      - run: npm run build

  deploy:
    needs: [test, build]  # Waits for both
    runs-on: ubuntu-latest
    steps:
      - run: ./deploy.sh

Matrix builds

Run the same job across multiple combinations in parallel:

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

This example creates 3 × 3 = 9 parallel jobs automatically.

Caching dependencies

Caching dramatically speeds up workflows. The key determines when cache is invalidated:

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

| Language | path | key based on | |---|---|---| | Node.js (npm) | ~/.npm | package-lock.json | | Node.js (pnpm) | ~/.pnpm-store | pnpm-lock.yaml | | Python (pip) | ~/.cache/pip | requirements.txt | | Python (uv) | ~/.cache/uv | uv.lock | | Go | ~/go/pkg/mod | go.sum | | Rust | ~/.cargo/registry | Cargo.lock |

Artifacts

Upload files from one job, download them in another or keep for inspection:

yaml
- name: Upload build output
  uses: actions/upload-artifact@v4
  with:
    name: dist-files
    path: dist/
    retention-days: 7     # Auto-delete after 7 days

- name: Download in another job
  uses: actions/download-artifact@v4
  with:
    name: dist-files
    path: dist/

Concurrency control

Cancel in-progress runs when a new push arrives on the same branch:

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

Reusable workflows

Defining a reusable workflow

Save as .github/workflows/reusable-test.yml:

yaml
on:
  workflow_call:
    inputs:
      node-version:
        required: true
        type: string
    secrets:
      NPM_TOKEN:
        required: true

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

Calling a reusable workflow

yaml
jobs:
  run-tests:
    uses: ./.github/workflows/reusable-test.yml
    with:
      node-version: '20'
    secrets:
      NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

Real-world recipes

Recipe 1 — Run tests on every PR

yaml
name: Test on PR

on:
  pull_request:
    branches: [main, develop]

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

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'          # Built-in cache shorthand

      - run: npm ci
      - run: npm run lint
      - run: npm test -- --coverage

Recipe 2 — Build and push Docker image to GHCR

yaml
name: Docker Build & Push

on:
  push:
    branches: [main]

jobs:
  docker:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write           # Required for GHCR push

    steps:
      - uses: actions/checkout@v4

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}  # Auto-provided

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          push: true
          tags: ghcr.io/${{ github.repository }}:latest

Recipe 3 — Deploy to Vercel on merge to main

yaml
name: Deploy to Vercel

on:
  push:
    branches: [main]

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

      - name: Deploy
        run: npx vercel --prod --token ${{ secrets.VERCEL_TOKEN }}
        env:
          VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
          VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}

Recipe 4 — Run Python tests with uv

yaml
name: Python CI

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ['3.11', '3.12', '3.13']

    steps:
      - uses: actions/checkout@v4

      - name: Install uv
        uses: astral-sh/setup-uv@v4

      - name: Install dependencies
        run: uv sync --frozen

      - name: Run tests
        run: uv run pytest

Recipe 5 — Scheduled dependency audit

yaml
name: Weekly Dependency Audit

on:
  schedule:
    - cron: '0 9 * * 1'   # Every Monday at 09:00 UTC
  workflow_dispatch:        # Also allow manual trigger

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

Permissions

GitHub Actions uses least-privilege by default. Grant only what you need:

yaml
jobs:
  deploy:
    permissions:
      contents: read       # Read repo code
      packages: write      # Push to GHCR
      id-token: write      # OIDC for cloud auth (AWS, GCP, Azure)
      pull-requests: write # Post PR comments
      issues: write        # Create/update issues
OIDC — No Long-Lived Secrets

Use id-token: write with OIDC to authenticate to AWS/GCP/Azure without storing cloud credentials as secrets. GitHub mints a short-lived JWT per run. See aws-actions/configure-aws-credentials@v4.


Common gotchas

| Problem | Fix | |---|---| | Resource not accessible by integration | Add permissions: block to job | | Workflow not triggering | Check branch filter — branches: [main] won’t match master | | Secret shows as *** in logs but fails | Check secret name casing — they are case-sensitive | | Cache miss every time | Verify hashFiles() path matches your actual lockfile location | | uses: action not found | Check the action version tag exists (e.g. @v4 not @v4.0) | | Matrix job fails one, stops all | Add fail-fast: false under strategy: | | Step runs even after failure | Remove if: success() or add if: always() explicitly |


Summary

  • Trigger with on:, run with jobs:, sequence with steps:
  • Use needs: to chain jobs — parallel by default
  • Store all secrets in GitHub Settings, never in code
  • actions/cache@v4 + hashFiles() = fast, correct caching
  • strategy.matrix multiplies a job across OS / version combos for free
  • Reusable workflows (workflow_call) prevent copy-paste across repos
  • Grant minimum permissions — use OIDC instead of long-lived cloud keys

FAQ

What is the difference between run and uses in a step? run executes shell commands directly on the runner. uses calls a pre-built action (from GitHub Marketplace or your own repo), which can be written in JavaScript, Docker, or as a composite of shell steps.

Can two jobs share files without uploading an artifact? No. Each job runs on a fresh, isolated runner. Use upload-artifact / download-artifact to pass files between jobs, or restructure the logic into a single job.

How do I debug a failing workflow without pushing commits? Use workflow_dispatch to trigger manually and add - run: env as a step to print all environment variables. For deep debugging, use the tmate action to SSH directly into the runner.

Does GitHub Actions work with private repositories? Yes, fully. The free tier includes 2,000 minutes/month for private repos. Public repos get unlimited free minutes.

How do I pin actions to a specific commit for security? Replace @v4 with a full commit SHA — e.g. uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683. This prevents supply-chain attacks from a tag being force-pushed.