- 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@v4cover 90% of needs- Use
strategy.matrixto 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:
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:
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.
steps:
- name: Push Docker image
env:
DOCKER_PASSWORD: ${{ secrets.DOCKER_HUB_TOKEN }}
run: echo "$DOCKER_PASSWORD" | docker login -u myuser --password-stdin 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:
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:
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:
- 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:
- 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:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true Reusable workflows
Defining a reusable workflow
Save as .github/workflows/reusable-test.yml:
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
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
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
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
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
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
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:
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 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 withjobs:, sequence withsteps: - Use
needs:to chain jobs — parallel by default - Store all secrets in GitHub Settings, never in code
actions/cache@v4+hashFiles()= fast, correct cachingstrategy.matrixmultiplies 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.
What to read next
- Docker Cheat Sheet — build the images you push in Actions
- Kubernetes Cheat Sheet — deploy to clusters from your pipeline
- SSH & GPG Cheat Sheet — manage deploy keys and signed commits
Related Articles
Deepen your understanding with these curated continuations.
How to Set Up CI/CD with GitHub Actions (Complete Guide)
Learn to build CI/CD pipelines with GitHub Actions. Step-by-step guide to testing, building, and deploying apps with secure secret management.
Terraform Cheat Sheet: IaC Commands, HCL & State
Complete Terraform reference — init, plan, apply, state, modules, variables, outputs, workspaces, and OpenTofu equivalents for infrastructure as code in 2026.
mise Cheat Sheet: Unified Runtime & Tool Manager (2026)
Complete mise reference — install and switch Node, Python, Rust, Go versions, manage tools, activate via shell, Docker, and CI/CD with working commands.