⚙️ GitHub Actions Quick Reference

GitHub Actions cheat sheet / survival guide

GitHub Actions: workflows for CI/CD and automation
.github/workflows/*.yml

GitHub Actions runs automated workflows in response to events in your repository: a push, a pull request, a schedule, a
button click. Use it to test and lint code, build and publish packages and container images, deploy, and automate chores
such as labeling issues. Workflows are YAML files in .github/workflows/ on your repository. Runners are the machines that
run them (GitHub hosts them, or you can host your own). This sheet is a syntax and pattern reference. The GitHub sheet has the
wider picture. Actions features and pricing change, so check docs.github.com/actions for the current details.

The vocabulary

Workflow     A YAML file that describes an automated process
Event        What starts a workflow (push, pull_request, schedule, workflow_dispatch ...)
Job          A group of steps that runs on one runner. Jobs run in PARALLEL unless you use needs:
Step         One task in a job: either "uses:" an action or "run:" a shell command. Steps run in order
Action       A reusable unit of work (for example actions/checkout@v4)
Runner       The machine that runs a job (a fresh virtual machine for each job on GitHub-hosted runners)
Artifact     Files a job saves so people or other jobs can download them
Secret       An encrypted value (a token, a password) available to workflows
Environment  A named deployment target (staging, production) with its own secrets and approval rules

A minimal workflow

name: CI                                   # shown in the Actions tab
on: [push, pull_request]                   # events that trigger it
jobs:
  test:                                    # a job id you choose
    runs-on: ubuntu-latest                 # the runner
    steps:
      - uses: actions/checkout@v4          # get the code (nothing is checked out by default)
      - run: echo "Hello from ${{ github.repository }}"
      - run: |                             # multi-line shell script
          npm ci
          npm test
Commit the file, push, and watch it under the repository's Actions tab.

Triggers (on:)

on:
  push:
    branches: [main, "release/**"]         # only these branches (glob patterns)
    branches-ignore: ["wip/**"]            # ...or exclude some (do not mix with branches for the same event)
    tags: ["v*"]                           # tag pushes such as v1.2.0
    paths: ["src/**", "package.json"]      # only when these files change
    paths-ignore: ["**.md", "docs/**"]
  pull_request:
    types: [opened, synchronize, reopened, ready_for_review]     # default types shown here
    branches: [main]                       # PRs that target main
  workflow_dispatch:                       # a manual "Run workflow" button
    inputs:
      environment:
        description: Where to deploy
        type: choice
        options: [staging, production]
        default: staging
      dry_run:
        type: boolean
        default: true
  schedule:
    - cron: "30 3 * * 1-5"                 # minute hour day month weekday, in UTC. Always quote it
  release:
    types: [published]
  workflow_call:                           # makes this workflow reusable by others (see Reusable workflows)
  workflow_run:                            # run after another workflow finishes
    workflows: ["CI"]
    types: [completed]
  repository_dispatch:                     # started by an API call from outside
    types: [deploy-request]
Other events: issues, issue_comment, pull_request_review, discussion, create, delete, fork, watch, page_build, merge_group.
Scheduled workflows can be delayed at busy times, only run on the default branch, and pause after 60 days without repository activity.
Paths filters do not apply to tag pushes. A workflow must exist on the default branch for some triggers (schedule, workflow_dispatch UI) to work.

Jobs

jobs:
  build:
    name: Build and test
    runs-on: ubuntu-latest                 # ubuntu-latest, ubuntu-24.04, windows-latest, macos-latest, or a self-hosted label
    timeout-minutes: 15                    # stop runaway jobs (default is 360)
    needs: [lint]                          # wait for these jobs (and skip if they fail)
    if: github.ref == 'refs/heads/main'    # conditions (no ${{ }} needed in if:)
    continue-on-error: false               # true = a failure here does not fail the workflow
    permissions:
      contents: read
    env:
      NODE_ENV: test
    defaults:
      run:
        shell: bash
        working-directory: ./app
    outputs:
      version: ${{ steps.ver.outputs.version }}
    steps: [...]
  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - run: echo "Deploying version ${{ needs.build.outputs.version }}"

Steps

- name: Friendly name                      # shown in the log
  id: ver                                  # so later steps can read its outputs
  uses: actions/setup-node@v4              # run an action ...
  with:                                    # ...with these inputs
    node-version: 22
- name: Run a script
  run: ./scripts/test.sh                   # ... or run a shell command
  shell: bash                              # bash, pwsh, python, sh, cmd
  working-directory: ./app
  env:
    API_URL: https://example.com
  if: success()                            # conditions: success(), failure(), always(), cancelled()
  continue-on-error: true
  timeout-minutes: 5

Matrix builds (many combinations)

strategy:
  fail-fast: false                         # do not cancel the other combinations when one fails
  max-parallel: 4
  matrix:
    os: [ubuntu-latest, windows-latest]
    node: [20, 22]
    include:
      - os: ubuntu-latest
        node: 24                           # add an extra combination
        experimental: true
    exclude:
      - os: windows-latest
        node: 20                           # skip this combination
runs-on: ${{ matrix.os }}
steps:
  - uses: actions/setup-node@v4
    with:
      node-version: ${{ matrix.node }}
Every extra matrix value multiplies the number of jobs and the minutes you use.

Contexts and expressions: ${{ ... }}
github.sha  github.ref  github.ref_name  github.event_name  github.actor  github.repository  github.run_id  github.workspace
github.event.pull_request.number      github.event.inputs.NAME  (or inputs.NAME)        github.head_ref (PR source branch)
env.NAME              secrets.NAME              vars.NAME              runner.os  runner.temp  runner.arch
matrix.KEY            needs.JOB.outputs.KEY     steps.ID.outputs.KEY   steps.ID.outcome / conclusion    job.status
Operators: == != < > && || !          Strings use single quotes:  github.ref == 'refs/heads/main'
Functions:
contains('Hello world', 'world')      startsWith(github.ref, 'refs/tags/')        endsWith(github.ref, '-rc')
format('v{0}.{1}', 1, 2)              join(matrix.os, ', ')                       toJSON(github)         fromJSON(steps.x.outputs.json)
hashFiles('**/package-lock.json')     # a hash of files, perfect for cache keys
success()   failure()   always()   cancelled()          # status checks for if:
Examples:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
if: startsWith(github.ref, 'refs/tags/v')
if: ${{ failure() }}                   # run only when an earlier step failed
if: always()                           # run even after failures or cancellation (cleanup, notifications)
if: github.actor != 'dependabot[bot]'
if: contains(github.event.pull_request.labels.*.name, 'deploy')

Environment variables and files (passing data between steps)

Built-in variables: CI=true, GITHUB_SHA, GITHUB_REF, GITHUB_REF_NAME, GITHUB_REPOSITORY, GITHUB_RUN_ID, GITHUB_ACTOR, GITHUB_WORKSPACE, RUNNER_OS, RUNNER_TEMP
Set an output (read it later with steps.ID.outputs.NAME):
- id: ver
  run: echo "version=$(cat VERSION)" >> "$GITHUB_OUTPUT"
- run: echo "Building ${{ steps.ver.outputs.version }}"
Set an environment variable for later steps:      echo "MY_VAR=value" >> "$GITHUB_ENV"
Add a folder to PATH for later steps:            echo "$HOME/.local/bin" >> "$GITHUB_PATH"
Write a Markdown summary on the run page:        echo "### Tests passed :rocket:" >> "$GITHUB_STEP_SUMMARY"
Multi-line value:
  {
    echo "notes<<EOF"
    cat notes.txt
    echo "EOF"
  } >> "$GITHUB_OUTPUT"
The old ::set-output and ::set-env commands are deprecated and removed. Use the files above.
Log commands (print to stdout):
echo "::notice::Deployed to staging"           echo "::warning file=app.js,line=10::Deprecated call"
echo "::error file=app.js,line=12::Something failed"       echo "::group::Install"  ...  echo "::endgroup::"
echo "::add-mask::$MY_VALUE"                    # hide a value that is not a registered secret in logs
echo "::debug::visible only with debug logging"

Secrets, variables and tokens

Settings > Secrets and variables > Actions:   Secrets (encrypted, masked in logs)  |  Variables (plain text configuration)
Scopes: repository, environment (with approval rules), and organization (share with chosen repos)
env:
  API_KEY: ${{ secrets.API_KEY }}          # pass a secret to a step as an environment variable (safer than putting it on a command line)
  REGION: ${{ vars.AWS_REGION }}
gh secret set API_KEY                      gh secret set API_KEY --env production        gh variable set REGION --body "eu-west-1"
Secrets are NOT passed to workflows triggered by pull requests from forks.
Reusable workflows can receive them with "secrets: inherit" or by name.
The built-in token: ${{ secrets.GITHUB_TOKEN }} (also github.token). It is created for each run and expires when it ends.
Set what it may do:
permissions:
  contents: read                           # actions, checks, contents, deployments, id-token, issues, packages,
  pull-requests: write                     # pages, pull-requests, security-events, statuses: read | write | none
Once you set any permission, all unlisted ones become none. Start with "contents: read" at the top and grant more per job.
Use the token with the gh CLI in a step:
- run: gh pr comment ${{ github.event.pull_request.number }} --body "Tests passed"
  env:
    GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Actions taken with GITHUB_TOKEN do not trigger other workflows (this prevents loops). If you need that, use a GitHub App token or a PAT.

Caching and artifacts

Language setup actions can cache for you:
- uses: actions/setup-node@v4
  with: { node-version: 22, cache: npm }              # also: cache: pip (setup-python), gradle, maven (setup-java), and setup-go caches by default
Manual cache:
- uses: actions/cache@v4
  with:
    path: ~/.cache/pip
    key: pip-${{ runner.os }}-${{ hashFiles('**/requirements.txt') }}
    restore-keys: |
      pip-${{ runner.os }}-
Artifacts (save output, share between jobs):
- uses: actions/upload-artifact@v4
  with:
    name: build-output
    path: dist/
    retention-days: 7
    if-no-files-found: error
- uses: actions/download-artifact@v4          # in another job (with "needs")
  with:
    name: build-output
Do not put secrets in artifacts or caches. Anyone who can read the run can download them.

Service containers and containers

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: test
        ports: ["5432:5432"]
        options: >-
          --health-cmd "pg_isready -U postgres"
          --health-interval 5s --health-timeout 5s --health-retries 10
    steps:
      - uses: actions/checkout@v4
      - run: ./run-integration-tests.sh          # connect to localhost:5432
    # container: node:22       (optional) run the whole job inside a container image

Common language setups

- uses: actions/setup-python@v5
  with: { python-version: "3.12", cache: pip }
- run: pip install -r requirements.txt && pytest
- uses: actions/setup-go@v5
  with: { go-version: "stable" }
- run: go build ./... && go test ./...
- uses: actions/setup-java@v4
  with: { distribution: temurin, java-version: 21, cache: maven }
- run: mvn -B verify
Quote python-version values ("3.10" not 3.10, or YAML turns it into 3.1).

Build and push a container image (GitHub Container Registry)

name: Docker
on:
  push:
    tags: ["v*"]
permissions:
  contents: read
  packages: write                          # needed to push to ghcr.io
jobs:
  image:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ github.repository }}
      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

Release when a tag is pushed

on:
  push:
    tags: ["v*"]
permissions:
  contents: write                          # creating a release needs write
jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: make dist
      - run: gh release create "$GITHUB_REF_NAME" dist/* --generate-notes
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Deploy a static site to GitHub Pages

on:
  push:
    branches: [main]
permissions:
  contents: read
  pages: write
  id-token: write
concurrency:
  group: pages
  cancel-in-progress: false
jobs:
  deploy:
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/configure-pages@v5
      - uses: actions/upload-pages-artifact@v3
        with:
          path: .                          # the folder to publish
      - id: deployment
        uses: actions/deploy-pages@v4
Set Settings > Pages > Source to "GitHub Actions" first.

Deploy to the cloud without stored keys (OIDC)

permissions:
  id-token: write                          # lets the job request a short-lived identity token
  contents: read
steps:
  - uses: aws-actions/configure-aws-credentials@v4
    with:
      role-to-assume: arn:aws:iam::123456789012:role/github-deploy
      aws-region: us-east-1
  - run: aws s3 sync ./site s3://my-bucket --delete
Similar actions exist for Azure (azure/login) and Google Cloud (google-github-actions/auth).
Create a cloud role that trusts your repository (and branch or environment), so no long-lived secret is stored in GitHub.

Environments, approvals and concurrency

jobs:
  deploy:
    environment: production                # secrets and required reviewers come from Settings > Environments
    concurrency:
      group: deploy-production
      cancel-in-progress: false            # queue deployments instead of cancelling one in progress
Use "concurrency" with cancel-in-progress: true on pull request workflows, so a new push cancels the older, now pointless run:
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true
Environments can require a person to approve, wait a timer, and limit which branches may deploy.

Reusable workflows and composite actions

Reusable workflow (called by other workflows), .github/workflows/test.yml:
on:
  workflow_call:
    inputs:
      node-version:
        type: string
        default: "22"
    secrets:
      npm-token:
        required: false
    outputs:
      result:
        value: ${{ jobs.test.outputs.result }}
jobs:
  test:
    runs-on: ubuntu-latest
    outputs:
      result: ${{ steps.r.outputs.result }}
    steps: [...]
Caller:
jobs:
  ci:
    uses: ./.github/workflows/test.yml            # or: my-org/shared-workflows/.github/workflows/test.yml@v1
    with:
      node-version: "20"
    secrets: inherit
Composite action (a reusable set of steps), .github/actions/setup-app/action.yml:
name: Setup app
description: Install dependencies
inputs:
  node-version:
    default: "22"
runs:
  using: composite
  steps:
    - uses: actions/setup-node@v4
      with: { node-version: "${{ inputs.node-version }}" }
    - run: npm ci
      shell: bash                                  # composite steps that use run: must set a shell
Use it:  - uses: ./.github/actions/setup-app
Other action types: JavaScript actions and Docker container actions (see the docs for "Creating actions").

Monorepo and path-based workflows

on:
  pull_request:
    paths:
      - "services/api/**"
      - ".github/workflows/api.yml"           # include the workflow file itself
Or detect changes inside a job with a paths filter action, then use job outputs and "if:" to run only what changed.
Required status checks and path filters: a skipped workflow shows as "pending" and can block merges. Use a small always-run "gate" job when needed.

Runners

GitHub-hosted     ubuntu-latest (ubuntu-24.04), ubuntu-22.04, windows-latest, macos-latest (Apple silicon), and larger paid runners
                  A fresh VM every job, cleaned up automatically. Common tools (docker, git, node, python, gh, jq) are preinstalled
Self-hosted       Your own machine or VM. Install the runner from Settings > Actions > Runners. Use labels in runs-on: [self-hosted, linux, x64]
                  Good for special hardware and private networks. You maintain it, and jobs run with access to that machine
Do NOT use self-hosted runners on public repositories. Anyone opening a pull request could run code on your machine.
Free minutes: public repositories are free on standard runners. Private repositories get a monthly allowance by plan, then pay per minute
(Windows costs about twice and macOS about ten times as much as Linux). Check your plan's current limits.

Debugging

- Re-run a job with debug logging: the run page > Re-run jobs > tick "Enable debug logging"
- Or set the secrets/variables ACTIONS_STEP_DEBUG=true and ACTIONS_RUNNER_DEBUG=true
- Print what you have:  echo "${{ toJSON(github) }}"   (do not do this with secrets or on public logs of sensitive repos)
- Show the environment:  env | sort      pwd      ls -la      (careful: env output can include values you set)
- Check syntax before pushing: actionlint (a linter for workflows)   actionlint .github/workflows/ci.yml
- Run locally in Docker: act (github.com/nektos/act)    act -l   act pull_request   act -j test
- From the terminal:  gh run list   gh run view RUN_ID --log-failed   gh run watch   gh run rerun RUN_ID --failed   gh workflow run ci.yml
- A "tmate" step gives you a live SSH session into a runner. Powerful, but it exposes the runner: never use it with secrets, and remove it afterwards
- Read the first error in the log. Later errors often follow from it

Security checklist

[ ] permissions: contents: read at the top of every workflow. Grant more only on jobs that need it
[ ] Pin third-party actions to a full commit SHA (uses: owner/action@0123abcd...). Let Dependabot update them
[ ] Never put untrusted text directly in run:. Issue and PR titles, branch names and comments can contain shell code
      Bad:   run: echo "${{ github.event.issue.title }}"
      Good:  env:
               TITLE: ${{ github.event.issue.title }}
             run: echo "$TITLE"
[ ] Be very careful with pull_request_target and workflow_run: they run in the base repository context and can expose secrets
      Never check out and run code from an untrusted fork in those workflows
[ ] Do not print secrets. Never pass them as command-line arguments (they appear in process lists). Use env:
[ ] Use OIDC for cloud logins instead of long-lived keys, and scope the cloud role tightly
[ ] Use environments with required reviewers for production deployments
[ ] Require approval for workflows from first-time contributors (Settings > Actions > General)
[ ] Restrict which actions may run (Settings > Actions > General > Allow select actions)
[ ] Do not use self-hosted runners for public repositories
[ ] Review third-party actions before you use them. An action runs with your workflow's permissions
[ ] Keep secrets out of artifacts and caches, and set short retention-days
[ ] Protect the main branch and .github/workflows/ with CODEOWNERS and required reviews. A workflow change is a code change

Practical patterns

# Only run when something relevant changed
on:
  push:
    paths-ignore: ["**.md", "docs/**"]

# Cancel an old run when you push again
concurrency: { group: "ci-${{ github.ref }}", cancel-in-progress: true }

# A nightly job with a manual button too
on:
  schedule: [{ cron: "0 2 * * *" }]
  workflow_dispatch:

# Run a step only on the main branch
- if: github.ref == 'refs/heads/main' && github.event_name == 'push'
  run: ./deploy.sh

# Always upload logs, even on failure
- if: always()
  uses: actions/upload-artifact@v4
  with: { name: logs, path: "*.log" }

# Comment on a pull request from a workflow
- run: gh pr comment "$PR" --body "Preview: https://example.com/pr-$PR"
  env:
    GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    PR: ${{ github.event.pull_request.number }}

# Fail the build on lint errors but keep going to show all results
- run: npm run lint
  continue-on-error: ${{ github.event_name == 'pull_request' }}

# Skip CI for a commit by adding this to the commit message:  [skip ci]   (or [ci skip], [no ci])

# Run a script from the repo
- run: bash scripts/build.sh

# Multi-OS shell differences: set the shell for Windows jobs
- run: echo hello
  shell: bash

# Label a pull request from paths (use the official actions/labeler action with .github/labeler.yml)

Common problems

Workflow does not appear or does not run       The file must be in .github/workflows/ with a .yml or .yaml extension. Check the "on:" filters (branches, paths) and that YAML is valid
"Invalid workflow file" / YAML error           Indentation (spaces only), a missing colon, or an unquoted special value. Run actionlint. Quote strings like "3.10" and cron expressions
Job skipped                                    Its "needs:" job failed or was skipped, or the "if:" was false. Check the condition, and use always() where needed
Secret is empty                                Wrong name or scope, the run comes from a fork, or the environment is not set on the job
"Resource not accessible by integration"       GITHUB_TOKEN lacks a permission. Add it under permissions:
"Permission denied" pushing from a workflow    Grant contents: write, and check branch protection rules
Checkout is missing files or history           actions/checkout only fetches one commit by default. Add fetch-depth: 0 for full history and tags
Cache never hits                               The key changes each time, or it was saved on another branch. Check hashFiles paths, and use restore-keys
Cannot download an artifact                    Different run or job, wrong name, expired retention, or the v3 and v4 artifact actions mixed together
Windows job fails on shell syntax              Default shell there is PowerShell. Set shell: bash, or write PowerShell
Job cancelled unexpectedly                     concurrency with cancel-in-progress, a timeout-minutes limit, or a manual cancel
Scheduled workflow never runs                  It must be on the default branch, cron is UTC, and it pauses after 60 days without activity
A workflow started by GITHUB_TOKEN does not trigger others   By design. Use a GitHub App token or a personal access token if you need chaining
Deprecated set-output warning                  Switch to  echo "name=value" >> "$GITHUB_OUTPUT"
Node 16 or node 20 deprecation warning         Update the action to its latest major version
Out of minutes                                 Check Settings > Billing. Reduce matrix size, cache dependencies, cancel duplicate runs and add timeouts

Tips

    Start small: one workflow that checks out the code and runs your tests. Add jobs, a matrix and deployments as you need them.

    Set permissions: contents: read at the top of every workflow, and raise it only on the jobs that need more.

    Pin third-party actions to a commit SHA and keep them updated with Dependabot.

    Never put untrusted text (issue titles, PR titles, branch names) straight into a run: step. Pass it through env: instead.

    Use cache and concurrency to cut minutes: reuse dependencies, and cancel older runs when a newer push arrives.

    Add timeout-minutes to every job so a hang cannot burn your allowance.

    Use OIDC for cloud deployments and environments with required approval for production. Avoid long-lived keys in secrets.

    Keep workflows readable: name every step, use reusable workflows and composite actions for repeated parts.

    Lint with actionlint and test with act or a scratch branch before you rely on a workflow.

    Read the first error in a failed log, re-run with debug logging when stuck, and check docs.github.com/actions for current syntax.