GitHub Actions Cheat Sheet

Write and manage GitHub Actions workflows: triggers, jobs, steps, secrets, matrix builds and caching, plus the gh CLI commands for watching, re-running and debugging runs from the terminal.

GitHub Actions workflows live as YAML files under .github/workflows/, one file per workflow. This sheet covers the syntax for writing them, and the gh CLI commands for watching and managing the runs they produce without leaving the terminal.

Workflow file basics

# .github/workflows/deploy.yml
name: Build and deploy

on:
  push:
    branches: [main]
  workflow_dispatch:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
      - name: Run a command
        run: echo "hello"

Every workflow needs name (shown in the Actions tab), on (what triggers it), and at least one entry under jobs. Each job runs on a fresh virtual machine (runs-on) and executes its steps in order.

Common triggers

on:
  push:
    branches: [main]            # only when main is pushed to
    paths: ["src/**"]              # only if files under src/ actually changed
  pull_request:
    branches: [main]                 # runs on PRs targeting main
  schedule:
    - cron: "0 3 * * *"                 # 03:00 UTC every day - cron syntax, always UTC
  workflow_dispatch:                       # adds a manual "Run workflow" button in the UI
  release:
    types: [published]                        # when a GitHub release is published
on:
  workflow_dispatch:
    inputs:
      environment:
        description: "Target environment"
        required: true
        default: "staging"

workflow_dispatch with inputs adds a form in the Actions UI, so a manual run can take parameters instead of being hardcoded.

Jobs, steps, and dependencies between jobs

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - run: echo "running tests"

  deploy:
    needs: test                    # wait for the test job to succeed before starting
    runs-on: ubuntu-latest
    steps:
      - run: echo "deploying"
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4          # clone the repo into the runner
      - uses: actions/setup-node@v4          # install a specific Node version
        with:
          node-version: "20"
      - run: npm ci                              # install dependencies from a committed lockfile
      - run: npm test                               # run the test suite

uses: runs a prebuilt action (reusable, versioned, usually pinned to @v4 or a specific tag); run: executes a raw shell command directly on the runner.

Environment variables and secrets

env:
  NODE_ENV: production                 # available to every step in the job

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - run: echo "Deploying to $TARGET"
        env:
          TARGET: production             # step-level env var, overrides the job-level one if named the same
      - name: Deploy
        run: ./deploy.sh
        env:
          API_KEY: ${{ secrets.API_KEY }}    # pulled from the repo's configured Actions secrets, never printed in logs

Secrets are configured in Settings → Secrets and variables → Actions on GitHub - they’re encrypted at rest, masked automatically in log output, and simply unavailable to workflows triggered from a fork’s pull request by default (a deliberate security boundary, not a bug).

Conditionals with if

      - name: Only on main
        if: github.ref == 'refs/heads/main'
        run: echo "this is main"

      - name: Only run on failure
        if: failure()
        run: echo "something earlier failed"

      - name: Always run, even after a failure
        if: always()
        run: echo "cleanup"

if: failure() and if: always() are the two most common patterns for cleanup/notification steps that need to run regardless of whether earlier steps succeeded.

Matrix builds

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [18, 20, 22]      # runs this job 3 times, once per version
    steps:
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
      - run: npm test
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest]
        node-version: [18, 20]
      fail-fast: false        # let every combination finish even if one fails, instead of canceling the rest

A matrix with two axes (os × node-version above) runs once for every combination - 2×2 = 4 jobs here.

Caching dependencies

      - uses: actions/cache@v4
        with:
          path: ~/.npm                                   # what to cache
          key: npm-${{ hashFiles('package-lock.json') }}    # cache invalidates automatically when the lockfile changes
          restore-keys: |
            npm-

Caching avoids re-downloading the same dependencies on every run - restore-keys lets a run fall back to the most recent matching cache even if the exact key (lockfile hash) doesn’t match, which is usually still faster than a fully cold cache.

Artifacts

      - uses: actions/upload-artifact@v4
        with:
          name: build-output
          path: dist/
          retention-days: 7       # auto-delete after this many days instead of the default 90

      - uses: actions/download-artifact@v4
        with:
          name: build-output

Artifacts pass files between jobs in the same workflow run (e.g. a build job uploads, a deploy job downloads), or let you grab build output from the GitHub UI after a run finishes.

Managing runs with the gh CLI

gh run list                              # recent workflow runs, status at a glance
gh run list --limit 5                      # just the last 5
gh run list --workflow=deploy.yml            # only runs of one specific workflow file
gh run view <run-id>                   # summary of one run - jobs, steps, pass/fail
gh run view <run-id> --log                # full raw logs for every step
gh run watch <run-id> --exit-status         # follow a run live until it finishes; exit code reflects success/failure
gh run rerun <run-id>                  # re-run every job in a run
gh run rerun <run-id> --failed           # re-run only the jobs that failed, not the ones that already passed
gh run cancel <run-id>                     # stop a run that's still in progress
gh workflow list                   # every workflow file in the repo, and whether it's enabled
gh workflow run deploy.yml            # manually trigger a workflow that has workflow_dispatch
gh workflow disable deploy.yml          # turn a workflow off without deleting the file

Common recipes

# Watch the deploy that was just triggered by your last push, right from the terminal
gh run watch $(gh run list --limit 1 --json databaseId --jq '.[0].databaseId') --exit-status

# Only re-run the parts of a flaky run that actually failed
gh run rerun $(gh run list --limit 1 --json databaseId --jq '.[0].databaseId') --failed

# Grep a run's full logs for the actual error, instead of scrolling the web UI
gh run view <run-id> --log | grep -i error
# A minimal "deploy on push to main" workflow, end to end
name: Deploy
on:
  push:
    branches: [main]
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: ./deploy.sh
        env:
          DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}

Safety notes

  • Pin third-party actions to a specific version tag (@v4) or, for anything security-sensitive, a full commit SHA - @main/@master on someone else’s action means their next push changes what runs in your pipeline, with no review on your side.
  • Secrets are masked in logs automatically, but only as literal string matches - echoing a secret after transforming it (base64, a substring, reversed) can leak it anyway. Never deliberately print a secret even “for debugging.”
  • A workflow_dispatch-triggered workflow with no branch restriction can be run against any branch that has the file - don’t assume manual runs only ever happen against main.
  • dangerous-clean-slate-style deploy steps (wiping a target before re-uploading) have no undo - keep a backup/rollback path outside the pipeline itself for anything deploying to a server you can’t just redeploy your way out of.