---
title: "GitHub Actions"
description: "The workflow YAML you write over and over. Triggers, jobs and steps, secrets, matrix builds, caching, artifacts, and reusable workflows in one reference."
author: "Mohammad Abu Mattar"
canonical: https://mkabumattar.com/cheatsheets/github-actions
---

# GitHub Actions

GitHub Actions runs your CI/CD directly from YAML files in `.github/workflows/`, and most of the job is knowing which key does what. A workflow reacts to events, splits into jobs that run on runners, and each job is an ordered list of steps that either call a reusable action or run a shell command.

This cheatsheet walks the parts you touch on every pipeline: triggers that decide when things run, the job and step structure, `env` and secrets across their three scopes, matrix builds for testing many versions at once, caching and artifacts to stay fast, and reusable workflows and composite actions to stop copy-pasting the same YAML into every repo.

## Key Features

- **Trigger precisely**: filter `push` and `pull_request` by branch and path, plus `schedule` and `workflow_dispatch`.
- **Structure jobs**: `needs` for ordering, `if` for conditions, `runs-on` for the machine.
- **Handle secrets safely**: three `env` scopes, masked `secrets`, and a least-privilege `GITHUB_TOKEN`.
- **Scale and reuse**: matrix builds, dependency caching, artifacts, reusable workflows, and composite actions.

## Workflow Triggers

The on key decides when a workflow runs. Most workflows use one or two of these, but knowing the full set saves you from cron hacks and manual reruns.

### Event Triggers

Fire on repository activity like pushes and pull requests, and filter down to the branches, tags, and paths you actually care about.

**Keywords:** on, push, pull_request, paths, branches

#### Trigger on push and pull request with filters

```yaml
on:
  # Run on pushes to main, but only when source or workflows change
  push:
    branches: [main]
    paths:
      - 'src/**'
      - '.github/workflows/**'
  # Run on PRs targeting main, ignoring docs-only changes
  pull_request:
    branches: [main]
    paths-ignore:
      - '**.md'
```

paths and paths-ignore skip runs that can't matter, which keeps queues short and saves runner minutes on docs commits.

- Use branches-ignore or paths-ignore for the inverse of branches/paths. Don't set both the positive and negative form for the same key.
- Tag pushes need a tags filter (for example, tags:['v*']). A branches filter alone never matches a tag push.

#### Scheduled and manual triggers

```yaml
on:
  # Cron runs in UTC. This is 02:00 UTC every day.
  schedule:
    - cron: '0 2 * * *'
  # A "Run workflow" button in the Actions tab, with inputs
  workflow_dispatch:
    inputs:
      environment:
        description: 'Target environment'
        type: choice
        options: [staging, production]
        default: staging
```

workflow_dispatch gives you a manual run button plus typed inputs, and schedule runs on cron without any external trigger.

- Scheduled workflows only run from the default branch, and GitHub can delay or skip them under heavy load. Don't rely on exact timing.
- Reach an input at runtime with ${{ inputs.environment }} (or github.event.inputs.environment on older syntax).

**Best practices:**

- Scope triggers with paths and branches from day one. An unfiltered on:push burns runner minutes on every commit, including typo fixes.

**Common errors:**

- **A workflow file exists but never runs**: The file must live in .github/workflows/ on the branch being pushed, and the on: events must match. A workflow only on a feature branch won't run for pushes to main.

### workflow_call and Reuse

workflow_call turns a workflow into something other workflows can invoke, which is the foundation of reusable pipelines.

**Keywords:** workflow_call, reusable, inputs, outputs

#### Expose a workflow as callable

```yaml
on:
  workflow_call:
    inputs:
      node-version:
        required: false
        type: string
        default: '20'
    secrets:
      npm-token:
        required: true
```

A workflow with on:workflow_call declares typed inputs and named secrets, so callers pass exactly what it needs and nothing more.

- Reusable-workflow inputs are typed (string, number, boolean). This is stricter than workflow_dispatch, which historically treated everything as a string.

## Jobs and Steps

Jobs run on runners and can depend on each other. Steps run in order inside a job, either calling an action with uses or running a shell command with run.

### Job Structure

runs-on picks the machine, needs builds a dependency graph, and if gates whether a job runs at all.

**Keywords:** jobs, runs-on, needs, if, environment

#### Chain jobs with needs and conditions

```yaml
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - run: echo "building"

  deploy:
    # Wait for build, and only deploy from main
    needs: build
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - run: echo "deploying"
```

needs makes deploy wait for build to succeed, and the if guard stops deploys from running on pull requests or feature branches.

- By default a needed job must succeed. Use if:always() or if:needs.build.result == 'success' to control behaviour when an upstream job fails.
- [object Object]

#### Run a job across multiple OSes

```yaml
jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
    steps:
      - uses: actions/checkout@v4
      - run: echo "testing on ${{ matrix.os }}"
```

Setting runs-on to a matrix value fans the same job out across every listed OS in parallel.

**Best practices:**

- Pin runner labels (ubuntu-24.04) instead of ubuntu-latest when you need reproducible builds. latest moves when GitHub updates images.

**Common errors:**

- **Job depends on unknown job**: The value in needs must match another job's key exactly. Check for a typo, and remember needs references the job id, not its name.

### Steps, uses, and run

Every step either reuses a published action (uses) or runs shell commands (run). with passes inputs, env sets variables.

**Keywords:** steps, uses, run, with, shell

#### Combine actions and shell steps

```yaml
steps:
  # An action from the marketplace, pinned to a major version
  - uses: actions/checkout@v4

  - uses: actions/setup-node@v4
    with:
      node-version: '20'
      cache: 'npm'

  # A multi-line shell command with a step id and env
  - name: Build
    id: build
    env:
      NODE_ENV: production
    run: |
      npm ci
      npm run build
```

uses pulls in a reusable action and with feeds it inputs, while run executes shell directly. The | starts a multi-line script block.

- Give a step an id when a later step needs its outputs via ${{ steps.build.outputs.name }}.
- Default shell is bash on Linux/macOS and pwsh on Windows. Override per step with shell:bash for consistency.

**Common errors:**

- **Can't find action.yml, action.yaml or Dockerfile**: The uses reference is wrong. Marketplace actions look like owner/repo@ref. A local action needs a path like ./.github/actions/my-action.

## Variables and Secrets

env holds plain configuration at three scopes, while secrets and the GITHUB_TOKEN handle anything sensitive without ever printing it in logs.

### Environment Variables

Set env at the workflow, job, or step level. The narrowest scope wins when the same name is defined twice.

**Keywords:** env, GITHUB_ENV, variables, scope

#### env at three levels and dynamic values

```yaml
env:
  APP_NAME: my-app        # available to every job and step

jobs:
  build:
    runs-on: ubuntu-latest
    env:
      LOG_LEVEL: debug    # available to every step in this job
    steps:
      - name: One-off value
        env:
          STAGE: build     # only this step
        run: echo "$APP_NAME $LOG_LEVEL $STAGE"

      - name: Export to later steps
        # Writing to $GITHUB_ENV persists a var to the next steps
        run: echo "VERSION=1.2.3" >> "$GITHUB_ENV"
```

A step-level env wins over job-level, which wins over workflow-level. To pass a computed value forward, append it to the $GITHUB_ENV file.

- Read repository or environment configuration variables (non-secret) with ${{ vars.NAME }}, distinct from ${{ secrets.NAME }}.

### Secrets and GITHUB_TOKEN

Secrets are encrypted, masked in logs, and injected only where you reference them. Every run also gets a scoped GITHUB_TOKEN for free.

**Keywords:** secrets, GITHUB_TOKEN, permissions, OIDC

#### Use secrets and pass them to a reusable workflow

```yaml
jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - env:
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
        run: npm publish

  call-shared:
    uses: ./.github/workflows/deploy.yml
    # Forward a named secret to the reusable workflow
    secrets:
      npm-token: ${{ secrets.NPM_TOKEN }}
    # Or hand over everything the caller has:
    # secrets: inherit
```

Reference a secret with ${{ secrets.NAME }}, and forward secrets to a called workflow explicitly or with secrets:inherit.

- GitHub masks secret values in logs automatically, but a secret you echo after transforming (base64, for example) can leak. Don't print them.
- Prefer OIDC (id-token:write plus a cloud role) over long-lived cloud keys stored as secrets.

#### Least-privilege GITHUB_TOKEN

```yaml
# Default to read-only for the whole workflow
permissions:
  contents: read

jobs:
  release:
    runs-on: ubuntu-latest
    # Grant just what this job needs
    permissions:
      contents: write
      packages: write
    steps:
      - run: gh release create v1.0.0
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
```

Set a restrictive top-level permissions block, then widen it per job. The auto-generated GITHUB_TOKEN inherits exactly those scopes.

- Secrets are not passed to workflows triggered by pull_request from a fork, which is a deliberate safeguard against untrusted PRs.

**Best practices:**

- Start every workflow with permissions:contents:read and add scopes only where a job needs them. The default token is broad otherwise.

**Common errors:**

- **Resource not accessible by integration**: The GITHUB_TOKEN lacks a scope. Add the needed permission (for example, contents:write or pull-requests:write) at the workflow or job level.

## Matrix Builds

A matrix runs the same job across a grid of parameters (versions, OSes) in parallel, with include and exclude to fine-tune the combinations.

### strategy.matrix

List the axes and GitHub generates one job per combination. include adds extra entries, exclude removes specific ones.

**Keywords:** matrix, strategy, include, exclude, fail-fast

#### Multi-axis matrix with include and exclude

```yaml
strategy:
  # Keep other jobs running if one combo fails
  fail-fast: false
  # Cap parallel jobs so you don't exhaust runners
  max-parallel: 4
  matrix:
    node: [18, 20, 22]
    os: [ubuntu-latest, windows-latest]
    # Drop a combo that isn't supported
    exclude:
      - node: 18
        os: windows-latest
    # Add an extra one-off combo with a custom flag
    include:
      - node: 22
        os: ubuntu-latest
        experimental: true
```

The two axes create 6 jobs, exclude removes one, and include appends a tailored entry, so you end up with a precise test grid.

- fail-fast:true (the default) cancels every other matrix job the moment one fails. Set it false when you want the full result grid.
- Reference any axis value in the job with ${{ matrix.node }} or ${{ matrix.os }}.

**Common errors:**

- **The whole matrix cancels when one job fails**: That's fail-fast:true, the default. Set strategy.fail-fast:false to let the remaining combinations finish and report independently.

## Caching and Artifacts

Caching reuses dependencies between runs to save time. Artifacts pass build outputs between jobs or hand them to you after the run.

### Dependency Caching

actions/cache keys a directory by a hash of your lockfile, restoring it when the hash matches and saving a fresh copy when it doesn't.

**Keywords:** cache, actions/cache, key, restore-keys, hashFiles

#### Cache dependencies keyed by lockfile

```yaml
- uses: actions/cache@v4
  with:
    path: ~/.npm
    # Exact key: changes when the lockfile changes
    key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
    # Fallback prefixes for a partial (warm) restore
    restore-keys: |
      ${{ runner.os }}-npm-
```

A cache hit restores ~/.npm instantly. On a miss, restore-keys grabs the newest partial match and the run saves a new cache under the exact key.

- Many setup actions have caching built in (setup-node with cache:'npm'). Prefer that over a manual actions/cache step when it exists.
- Caches are scoped to a branch and its base. A PR can read the base branch cache but a branch can't read an unrelated branch's cache.

**Best practices:**

- Put a lockfile hash in the key so the cache invalidates when dependencies change. A static key serves stale packages forever.

### Build Artifacts

Upload files from one job and download them in another, or keep them attached to the run for later inspection.

**Keywords:** artifacts, upload-artifact, download-artifact, retention

#### Pass a build between jobs

```yaml
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - run: npm run build
      - uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/
          retention-days: 7

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: dist
          path: dist/
      - run: ls dist/
```

upload-artifact stores dist/ from the build job, and download-artifact pulls it into the deploy job by matching the artifact name.

- Artifacts are not caches. Use artifacts to move files between jobs or keep outputs, and use actions/cache to speed up dependency installs.
- v4 of the artifact actions is not cross-compatible with v3. Keep upload and download on the same major version.

**Common errors:**

- **Unable to find any artifacts for the associated workflow**: download-artifact ran before upload, or the name didn't match. Add needs: on the downloading job and confirm the name is identical.

## Reuse and Composition

Kill the copy-paste. Reusable workflows share a whole pipeline across repos, and composite actions bundle a sequence of steps into one uses call.

### Reusable Workflows

Call an entire workflow from another with uses, passing inputs and secrets. Great for standardizing deploys across many repos.

**Keywords:** reusable workflow, uses, inputs, secrets

#### Call a reusable workflow

```yaml
jobs:
  deploy:
    # Local reusable workflow
    uses: ./.github/workflows/deploy.yml
    with:
      node-version: '20'
    secrets:
      npm-token: ${{ secrets.NPM_TOKEN }}

  deploy-shared:
    # Reusable workflow from another repo, pinned to a tag
    uses: my-org/ci-workflows/.github/workflows/deploy.yml@v2
    secrets: inherit
```

A job that sets uses to a workflow file calls it wholesale, forwarding typed inputs and either named secrets or secrets:inherit.

- A caller can nest reusable workflows up to 4 levels deep. Beyond that GitHub refuses to run the chain.
- Pin cross-repo reusable workflows to a tag or SHA, never a moving branch, so a change upstream can't silently alter your pipeline.

### Composite Actions

Bundle several steps into a single action defined by an action.yml, then call it like any marketplace action.

**Keywords:** composite action, action.yml, runs.using, inputs

#### Define a composite action

```yaml
# .github/actions/setup/action.yml
name: 'Setup project'
description: 'Checkout, install Node, and restore deps'
inputs:
  node-version:
    default: '20'
runs:
  using: composite
  steps:
    - uses: actions/setup-node@v4
      with:
        node-version: ${{ inputs.node-version }}
    # Composite run steps MUST declare a shell
    - run: npm ci
      shell: bash
```

runs.using:composite lets one action.yml wrap multiple steps, so callers replace a repeated block with a single uses:./.github/actions/setup.

- Every run step inside a composite action must set shell explicitly. Omitting it is the most common composite-action error.

**Common errors:**

- **shell is required for a composite action step that runs a command**: Add shell:bash (or pwsh) to each run step in the composite action's action.yml. Composite steps have no default shell.
