MeshWorld India LogoMeshWorld.
CheatsheetGitHub ActionsCI/CDDevOpsAutomationDeveloper Tools10 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:)

TriggerExampleWhen it fires
pushon: pushAny branch push
push with filterbranches: [main]Push to specific branches
pull_requeston: pull_requestPR opened, synced, or reopened
pull_request_targetPR from fork (runs in base context)
workflow_dispatchManual trigger via GitHub UI or API
schedulecron: '0 9 * * 1'Cron schedule (UTC)
workflow_callCalled from another workflow (reusable)
releasetypes: [published]GitHub Release created
issue_commenttypes: [created]Comment posted on issue or PR

runs-on values

ValueMachine
ubuntu-latestUbuntu 24.04
ubuntu-22.04Ubuntu 22.04 (pinned)
windows-latestWindows Server 2022
macos-latestmacOS 14 (Apple Silicon)
macos-13macOS 13 (Intel, use for x86_64)
self-hostedYour own runner

Step types

TypeSyntaxUse for
Shell commandrun: echo "hello"Arbitrary shell
Multi-line shellrun: | then indented linesMulti-command blocks
Actionuses: actions/checkout@v4Pre-built reusable steps
Action with inputswith: { key: value }Parameterized actions

Context variables

VariableValue
${{ 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

ExpressionMeaning
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-
Languagepathkey based on
Node.js (npm)~/.npmpackage-lock.json
Node.js (pnpm)~/.pnpm-storepnpm-lock.yaml
Python (pip)~/.cache/piprequirements.txt
Python (uv)~/.cache/uvuv.lock
Go~/go/pkg/modgo.sum
Rust~/.cargo/registryCargo.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

ProblemFix
Resource not accessible by integrationAdd permissions: block to job
Workflow not triggeringCheck branch filter — branches: [main] won’t match master
Secret shows as *** in logs but failsCheck secret name casing — they are case-sensitive
Cache miss every timeVerify hashFiles() path matches your actual lockfile location
uses: action not foundCheck the action version tag exists (e.g. @v4 not @v4.0)
Matrix job fails one, stops allAdd fail-fast: false under strategy:
Step runs even after failureRemove 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.


Share_This Twitter / X
Cobie
Written By

Cobie

DevOps & Site Reliability Engineer. Expert in container orchestration, CI/CD pipeline optimization, and helping teams ship software safely with clean Git workflows.

Enjoyed this article?

Support MeshWorld and help us create more technical content