GitHub Actions Cheatsheet
Last updated 14th June 2026

GitHub Actions Cheatsheet

What it is

GitHub Actions is GitHub's built-in CI/CD platform — YAML files in .github/workflows/ describe pipelines that trigger on pushes, pull requests, tags, schedules, and manual dispatches. Each workflow runs jobs on hosted runners (Ubuntu, macOS, Windows) or self-hosted ones, with first-class access to your repository, your release artifacts, and the GitHub API.

This sheet covers the syntax you reach for when GitHub Actions is part of a real deploy pipeline — the trigger patterns that fire on the right events, the caching strategies that turn a 5-minute npm install into a 10-second restore, the OIDC patterns that authenticate to AWS/GCP without long-lived secrets, and the matrix builds + reusable workflows that keep complex pipelines maintainable.

Quick reference

Workflow file structure

A workflow lives at .github/workflows/<name>.yml. The minimum viable file:

name: CI
on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm test

Three properties of this structure that matter throughout the rest of the sheet:

  • on lists the events that trigger the workflow.
  • jobs are units of parallelism — each runs on its own runner unless you wire dependencies with needs:.
  • steps within a job run sequentially on the same runner. They share the working directory.

Triggers (on:)

on: push                                            # any push to any branch
on:
  push:
    branches: [main, 'release/**']                  # branch filter (glob supported)
    tags: ['v*']                                    # tag filter
    paths: ['src/**', '!**/*.md']                   # path filter (exclamation = exclude)
  pull_request:
    types: [opened, synchronize, reopened]          # PR lifecycle events
    branches: [main]
  schedule:
    - cron: '0 6 * * *'                             # daily at 06:00 UTC
  workflow_dispatch:                                # manual run from UI/CLI
    inputs:
      environment:
        type: choice
        options: [staging, production]
        required: true
  workflow_call:                                    # callable from other workflows
  release:
    types: [published]
  issues:
    types: [opened]

workflow_dispatch is the trigger you actually want for production deploys — it requires a human to click the button (or call gh workflow run), so a force-push can't accidentally ship to prod. The Cron and crontab cheatsheet has the cron syntax reference if schedule: doesn't fire when you expected.

Permissions (least privilege)

# At workflow or job level
permissions:
  contents: read                                    # default for all unspecified scopes
  pull-requests: write                              # write to PRs (comments, labels)
  issues: write
  id-token: write                                   # required for OIDC to cloud providers
  packages: write                                   # push to GitHub Packages / GHCR
  actions: read
  deployments: write
  pages: write

# Or grant nothing by default
permissions: {}

# Or grant everything (dangerous; only for trusted org-owned workflows)
permissions: write-all

Set permissions: explicitly at workflow level. The default GITHUB_TOKEN permissions changed in 2023 — older workflows may rely on broader defaults that no longer exist, and pinning them avoids the trap.

Concurrency (cancel in-progress runs)

# Cancel any in-progress run of the same workflow on the same branch
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

# Never cancel — queue instead
concurrency:
  group: production-deploy
  cancel-in-progress: false

For PR test runs: cancel-in-progress so pushing a fix kills the run for the old commit. For production deploys: never cancel — let each deploy finish in order.

Environments (deploy gates + secrets)

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment:
      name: production                              # references repo Settings → Environments
      url: https://app.example.com
    steps:
      - run: ./deploy.sh

Per-environment secrets (e.g. production vs staging AWS keys) live in GitHub Settings → Environments. Adding required reviewers to the production environment turns every deploy into a one-approval gate.

Matrix builds

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false                              # don't cancel siblings on one failure
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
        node: ['18', '20', '22']
        include:
          - os: ubuntu-latest
            node: '22'
            coverage: true                          # extra var on one cell
        exclude:
          - os: windows-latest
            node: '18'                              # skip this combination
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
      - run: npm ci
      - run: npm test

fail-fast: false is the right default for a test matrix — when Node 20 breaks but Node 22 is fine, you want both signals, not one.

Caching

- uses: actions/cache@v4
  with:
    path: |
      ~/.npm
      node_modules
    key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-node-

# Most language-specific setup actions have built-in cache support
- uses: actions/setup-node@v4
  with:
    node-version: '20'
    cache: 'npm'                                    # auto-keyed off package-lock.json
- uses: actions/setup-python@v5
  with:
    python-version: '3.12'
    cache: 'pip'
- uses: actions/setup-go@v5
  with:
    go-version: '1.22'
    cache: true
- uses: ruby/setup-ruby@v1
  with:
    ruby-version: '3.3'
    bundler-cache: true

The cache key MUST include the hash of the lockfile (package-lock.json, requirements.txt, Gemfile.lock). Keying off package.json alone misses dep changes that don't touch the manifest; keying off the branch name keeps serving stale caches forever. The same logic from the Composer cheatsheet's caching section applies here.

Secrets and contexts

steps:
  - name: Use a secret
    env:
      DB_URL: ${{ secrets.DATABASE_URL }}           # repo or org secret
      AWS_KEY: ${{ secrets.AWS_ACCESS_KEY_ID }}
    run: ./deploy.sh

  - name: Use vars (non-secret config)
    env:
      APP_ENV: ${{ vars.APP_ENV }}                  # repo or org variable
    run: echo "deploying to $APP_ENV"

  # Common context values
  - run: |
      echo "SHA:    ${{ github.sha }}"
      echo "Ref:    ${{ github.ref }}"
      echo "Actor:  ${{ github.actor }}"
      echo "Event:  ${{ github.event_name }}"
      echo "Run:    ${{ github.run_id }}/${{ github.run_attempt }}"

Secrets are masked in logs automatically. Echoing a secret to stdout still hides the value behind *** — but don't rely on that as security. Treat the secret as compromised the moment it touches echo.

Outputs between steps and jobs

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      image_tag: ${{ steps.tag.outputs.tag }}
    steps:
      - id: tag
        run: echo "tag=build-${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT"

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - run: echo "deploying ${{ needs.build.outputs.image_tag }}"

$GITHUB_OUTPUT is the file-based replacement for the deprecated ::set-output:: syntax — write key=value lines to it, and they surface as steps.<id>.outputs.<key>.

For environment variables that propagate between steps (not jobs), use $GITHUB_ENV:

- run: echo "RELEASE_TAG=v$(date +%Y%m%d)" >> "$GITHUB_ENV"
- run: echo "tagging release $RELEASE_TAG"

Artifacts and uploads

- uses: actions/upload-artifact@v4
  with:
    name: build-output
    path: dist/
    retention-days: 7                               # default is 90; pick something sensible

# In a later job:
- uses: actions/download-artifact@v4
  with:
    name: build-output
    path: dist/

Artifacts persist beyond the workflow run — useful for handing the built bundle from a build job to a deploy job without rebuilding. They're stored in GitHub and count against your storage quota, so trim retention aggressively.

Containers and services

jobs:
  test:
    runs-on: ubuntu-latest
    container: node:20-alpine                       # run all steps INSIDE this container
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: postgres
        ports: ['5432:5432']
        options: >-
          --health-cmd "pg_isready -U postgres"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    steps:
      - uses: actions/checkout@v4
      - run: psql -h postgres -U postgres -c 'SELECT 1'

The services block is the cleanest way to test against real databases — Postgres, MySQL, Redis, RabbitMQ — without mocking. The --health-* options gate steps on the service being ready before they run.

Conditionals

jobs:
  deploy:
    runs-on: ubuntu-latest
    # Skip on PRs from forks
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
      - name: Deploy to staging
        if: github.ref == 'refs/heads/develop'
        run: ./deploy-staging.sh
      - name: Deploy to production
        if: startsWith(github.ref, 'refs/tags/v')
        run: ./deploy-production.sh
      - name: Notify on failure
        if: failure()
        run: ./notify.sh

Common condition functions: success() (default for every step), failure(), cancelled(), always().

Reusable workflows (workflow_call)

# .github/workflows/deploy-reusable.yml
on:
  workflow_call:
    inputs:
      environment:
        type: string
        required: true
    secrets:
      DEPLOY_KEY:
        required: true

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    steps:
      - uses: actions/checkout@v4
      - run: ./deploy.sh
        env:
          KEY: ${{ secrets.DEPLOY_KEY }}
# .github/workflows/deploy-staging.yml — caller
on:
  push:
    branches: [main]

jobs:
  deploy:
    uses: ./.github/workflows/deploy-reusable.yml
    with:
      environment: staging
    secrets:
      DEPLOY_KEY: ${{ secrets.STAGING_DEPLOY_KEY }}

Reusable workflows are how you keep deploy-staging.yml, deploy-production.yml, and deploy-eu.yml from copy-pasting 200 lines each.

Composite actions (reuse step blocks)

# .github/actions/install-deps/action.yml
name: 'Install Deps'
description: 'npm install with cache + audit'
runs:
  using: 'composite'
  steps:
    - uses: actions/setup-node@v4
      with:
        node-version: '20'
        cache: 'npm'
    - run: npm ci
      shell: bash
    - run: npm audit --audit-level high
      shell: bash
# In a workflow
- uses: ./.github/actions/install-deps

Composite actions encapsulate sequences of steps that show up in many workflows — npm install + audit + lint, Docker build + scan + push, etc.

Self-hosted runners

jobs:
  build:
    runs-on: [self-hosted, linux, x64, gpu]         # match labels you assigned to runners
    steps:
      - run: nvidia-smi

Self-hosted runners are required for: GPU workloads, deploy targets behind a VPN, code that needs access to internal infrastructure, and any minute-budget-constrained shop running >50k CI minutes/month.


Deployment workflows (the moat)

The reference above is portable — every GitHub Actions tutorial has those snippets. The patterns below are the ones that matter once a workflow stops being CI and becomes a production deployer.

1. The minimal trustworthy deploy pipeline

A deploy workflow should: gate on a successful test run, pin to a specific environment with required reviewers, build and tag an immutable artifact, and roll back on failure. The minimum viable shape:

name: Deploy
on:
  push:
    tags: ['v*']                                    # deploy on every vX.Y.Z tag
  workflow_dispatch:
    inputs:
      tag:
        description: 'tag to deploy (e.g. v1.4.2)'
        required: true

concurrency:
  group: production-deploy
  cancel-in-progress: false                         # NEVER cancel a deploy in progress

permissions:
  contents: read
  id-token: write                                   # for OIDC

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm test

  deploy:
    needs: test
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://app.example.com
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ inputs.tag || github.ref }}
      - name: Configure AWS via OIDC
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::111111111111:role/GitHubActionsDeploy
          aws-region: us-east-1
      - name: Build & push image
        run: |
          SHA="${GITHUB_SHA::8}"
          docker build -t myapp:$SHA .
          aws ecr get-login-password | docker login --password-stdin "$ECR_REGISTRY"
          docker tag myapp:$SHA "$ECR_REGISTRY/myapp:$SHA"
          docker push "$ECR_REGISTRY/myapp:$SHA"
      - name: Update ECS
        run: ./scripts/deploy-ecs.sh "$SHA"
      - name: Smoke test
        run: ./scripts/smoke-test.sh https://app.example.com
      - name: Rollback on failure
        if: failure()
        run: ./scripts/rollback-ecs.sh

Five non-obvious decisions in this file:

  • tags: ['v*'] ties the deploy to immutable Git tags (see the Git cheatsheet's tag-based releases pattern). A force-push to main can't trigger a production deploy.
  • concurrency.cancel-in-progress: false queues deploys in order. The PR-test convention of cancel-in-progress: true would be catastrophic mid-deploy.
  • environment: production is what hooks up required-reviewer gates. Without an environment, anyone with push access can ship.
  • needs: test blocks deploy on green CI. Don't trust if: success() on a workflow-level — make the dependency explicit.
  • if: failure() rollback step runs only when an earlier step in the same job failed. Cheap insurance against a half-applied release.

2. OIDC instead of long-lived cloud credentials

Long-lived AWS keys in secrets.AWS_ACCESS_KEY_ID are a leak waiting to happen — they don't rotate, they show up in aws sts get-caller-identity logs, and a compromised runner exposes them indefinitely. OIDC replaces them with short-lived tokens that GitHub mints per workflow run.

permissions:
  id-token: write                                   # MANDATORY for OIDC
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::111111111111:role/GitHubActionsDeploy
          aws-region: us-east-1
      - run: aws sts get-caller-identity

The AWS-side setup is one-time: register GitHub as an OIDC provider in IAM, create a role with a trust policy that allows repo:org/app:ref:refs/heads/main (or repo:org/app:environment:production) to assume it. Now the runner authenticates as that role for 60 minutes — no secrets to rotate, no keys to leak.

Same pattern works for GCP and Azure. For the deeper AWS pattern, see the AWS CLI cheatsheet's multi-account section.

3. Build cache: lockfile-keyed, never branch-keyed

The single highest-ROI optimisation in any GitHub Actions workflow is correctly caching dependencies:

# Right: lockfile-keyed
- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
    restore-keys: npm-${{ runner.os }}-

# Wrong: branch-keyed (serves stale caches forever)
- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: npm-${{ runner.os }}-${{ github.ref }}

A cold npm ci on a real Node project is 60-180 seconds. A warm cache restore is 2-10 seconds. Multiplied across every CI run, every PR push, every deploy, this is one of the few optimisations that pays for itself in days.

For Docker builds, use docker/build-push-action with BuildKit cache:

- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: myapp:${{ github.sha }}
    cache-from: type=gha
    cache-to: type=gha,mode=max

The type=gha cache backend stores BuildKit layers in GitHub's cache, scoped per workflow. First build is slow; every subsequent one is layer-by-layer fast.

4. Matrix tests + a single deploy

The common shape: parallel test matrix, single deploy job that depends on the whole matrix:

jobs:
  test:
    strategy:
      matrix:
        node: ['18', '20', '22']
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
          cache: 'npm'
      - run: npm ci
      - run: npm test

  deploy:
    needs: test                                     # waits for ALL matrix cells
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - run: ./deploy.sh

needs: test blocks deploy until every matrix cell passes. A single failure on Node 18 blocks the prod deploy until you fix or drop the version.

5. Reusable deploy workflow per environment

For multi-environment apps (staging + production + EU), put the deploy logic in one reusable workflow and call it with environment-specific inputs:

# .github/workflows/deploy.yml — the reusable engine
on:
  workflow_call:
    inputs:
      environment:
        type: string
        required: true
      url:
        type: string
        required: true
      aws_role:
        type: string
        required: true

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment:
      name: ${{ inputs.environment }}
      url: ${{ inputs.url }}
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ inputs.aws_role }}
          aws-region: us-east-1
      - run: ./scripts/deploy.sh ${{ inputs.environment }}
# .github/workflows/deploy-staging.yml — caller
on:
  push:
    branches: [main]

jobs:
  deploy:
    uses: ./.github/workflows/deploy.yml
    with:
      environment: staging
      url: https://staging.example.com
      aws_role: arn:aws:iam::111111111111:role/StagingDeploy
# .github/workflows/deploy-production.yml — caller
on:
  push:
    tags: ['v*']
  workflow_dispatch:

jobs:
  deploy:
    uses: ./.github/workflows/deploy.yml
    with:
      environment: production
      url: https://app.example.com
      aws_role: arn:aws:iam::222222222222:role/ProdDeploy

The reusable workflow stays a single source of truth. Adding a new environment is a 10-line caller, not a 100-line copy.

6. The gh CLI for ad-hoc + scripted control

GitHub's gh CLI is the missing piece for treating Actions as scriptable infrastructure:

# Trigger a workflow manually
gh workflow run deploy.yml -f environment=production -f version=v1.4.2

# Watch a run live
gh run watch                                        # picks the most recent
gh run view 123456789 --log

# Rerun a failed job
gh run rerun 123456789 --failed                     # only failed jobs
gh run rerun 123456789                              # all jobs

# Cancel a stuck run
gh run cancel 123456789

# List runs for a workflow
gh run list --workflow deploy.yml --limit 20

# Download an artifact
gh run download 123456789 --name build-output

gh workflow run is the right interface for production deploys triggered from a release-management bot, a Slack /deploy slash command, or just a developer's ~/.bin/deploy-prod script.

7. Pin actions by SHA, not by tag

The supply-chain risk every Actions repo faces: a third-party action gets compromised, the attacker pushes a new commit and force-pushes the tag you reference, and your next workflow run executes their code with your secrets.

# Risky: tag is mutable
- uses: actions/checkout@v4
- uses: my-org/some-action@v1

# Safer: SHA is immutable
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11   # v4.1.1
- uses: my-org/some-action@a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0

GitHub itself recommends SHA-pinning for third-party actions. Tools like pin-github-action automate this — they take a @v1 reference, resolve it to a SHA, and rewrite the file with a comment showing the original tag.

First-party actions (actions/checkout, actions/cache) are lower-risk but the same logic applies for high-stakes deploy workflows.

8. Status badges and PR gates

Wire branch protection to required status checks so a PR can't merge until the workflow passes:

# README badge
[![CI](https://github.com/org/app/actions/workflows/ci.yml/badge.svg)](https://github.com/org/app/actions/workflows/ci.yml)
# Configure branch protection (or use the UI: Settings → Branches → Branch protection rules)
gh api repos/org/app/branches/main/protection \
  -X PUT \
  -F required_status_checks.strict=true \
  -F required_status_checks.contexts[]=test \
  -F enforce_admins=true \
  -F required_pull_request_reviews.required_approving_review_count=1

enforce_admins=true is the line most teams skip — without it, admins can bypass the very protections that exist to catch them on a bad day.

9. Triggering a DeployHQ deploy from GitHub Actions

If GitHub Actions handles your CI but you want a real deploy system handling the SSH/rsync/symlink mechanics, two integration patterns wire them together: the dhq CLI (full control, synchronous status) and the project webhook (zero-install, fire-and-forget).

Pattern A — dhq CLI with synchronous wait. The right choice for production where the workflow result should reflect the deploy outcome:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm test

  deploy:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://app.example.com
    steps:
      - name: Install dhq
        run: curl -sSL https://www.deployhq.com/install/cli | sh

      - name: Trigger DeployHQ deploy
        env:
          DHQ_EMAIL: ${{ secrets.DHQ_EMAIL }}
          DHQ_API_KEY: ${{ secrets.DHQ_API_KEY }}
        run: dhq deploy create -p myapp -s production --wait --json

--wait blocks the step until DeployHQ reports the deploy as complete or failed — so the GitHub Actions job result is a faithful signal of the deploy outcome (instead of just "the webhook fired"). --json gives you structured output to parse for Slack notifications, GitHub Deployment status updates, or release-note generation.

For multi-environment branching:

- name: Trigger deploy
  env:
    DHQ_EMAIL: ${{ secrets.DHQ_EMAIL }}
    DHQ_API_KEY: ${{ secrets.DHQ_API_KEY }}
  run: |
    case "${{ github.ref }}" in
      refs/heads/main)    dhq deploy create -p myapp -s production --wait ;;
      refs/heads/develop) dhq deploy create -p myapp -s staging    --wait ;;
      refs/tags/v*)       dhq deploy create -p myapp -s production --wait ;;
      *) echo "no deploy for $GITHUB_REF"; exit 0 ;;
    esac

Alternative install on a hosted runner where Homebrew is already present:

- run: brew install deployhq/tap/dhq

Pattern B — project webhook. The lowest-friction integration. Every DeployHQ project has a unique webhook URL that triggers a deploy when posted to:

- name: Trigger DeployHQ webhook
  run: |
    curl -fsS -X POST "${{ secrets.DEPLOYHQ_WEBHOOK_URL }}" \
      -H "Content-Type: application/json" \
      -d "{\"ref\":\"${GITHUB_SHA}\"}"

The webhook returns as soon as DeployHQ accepts the trigger — not when the deploy completes. Right for staging environments or background auto-deploys where synchronous feedback in the workflow isn't required.

Why split CI and deploy. GitHub Actions is excellent at the parallel-burst workload CI demands. Deploys are sequential, stateful, and want long-lived SSH keys, atomic release directories, and per-environment server inventory — exactly what DeployHQ is built for. The CLI/webhook integration lets you keep the CI minutes you've already paid for while keeping production SSH keys out of GitHub Actions' secret store entirely. The full trade-off is in DeployHQ vs GitHub Actions; the agents page covers the CLI's full command set.


Common errors and fixes

Error / symptom Cause Fix
Error: Resource not accessible by integration GITHUB_TOKEN lacks the scope you're using Add the required scope to permissions: at workflow or job level
Workflow doesn't trigger on push paths: filter excluded all changed files, or branch filter doesn't match Inspect git log --name-only -1 against your paths: config; paths-ignore is the common gotcha
Error: The process '/usr/bin/git' failed with exit code 128 Token lacks contents: read, or the repo is on a fork PR Set permissions.contents: read. For fork PRs, you can't access secrets — gate deploy on github.event.pull_request.head.repo.full_name == github.repository
Cache hit rate is 0% Cache key includes a value that changes every run (e.g. ${{ github.sha }}) Use hashFiles('package-lock.json') instead of branch/SHA in the key
Error: Container action is only supported on Linux A Docker-based action triggered on macOS/Windows runner Use composite actions instead, or split that job to a Linux runner
Error: This run might fail because the previous attempt was a different commit Reran a workflow attempt on a different SHA Click "Re-run all jobs" instead of "Re-run failed jobs" when the underlying SHA matters
Secrets show as *** even when used incorrectly GitHub masks every secret value in logs, but the workflow still runs with the wrong value Print hashFiles() on a path containing the secret, or compute a checksum — never echo the raw value
Job stuck in queue for hours Quota exhausted or self-hosted runner offline Check Settings → Actions → Runners. Public repos have 20-runner concurrent limit on free plans
Error: The runner failed to send the API request to the server Transient GitHub Actions infrastructure issue Re-run the failed job. If recurring, status.github.com will show it
OIDC token retrieval failed permissions.id-token: write missing, or trust policy mismatched Add the permission; verify the trust policy's sub matches repo:org/app:ref:refs/heads/main
npm ci slower than cold install Cache is hitting but node_modules isn't being restored — you cached ~/.npm but npm ci rebuilds Either cache node_modules directly (with the right key), or rely on actions/setup-node's built-in cache: 'npm'
bash: command not found for an installed tool The install step modified $PATH, but the new step's shell didn't pick it up Append to $GITHUB_PATH: echo "/opt/mytool/bin" >> "$GITHUB_PATH"
Artifact upload fails: Argument list too long Glob expanded to thousands of files Tar before uploading: tar -czf out.tar.gz dist/; upload the tarball
Error: cannot find module 'X' in Action Action's node_modules wasn't committed Action authors must commit node_modules/ for Node actions. Use a maintained alternative
Deploy succeeded but production didn't change Deploy ran in staging environment instead of production Check environment: value in the deploy job and the if: gate (typical fail: if: github.ref == 'refs/heads/master' when the branch was renamed to main)
Workflow file edits don't take effect YAML syntax error parses as no triggers gh workflow view <name> shows the parsed config; actionlint catches most YAML+expression bugs locally
Error: Process completed with exit code 1 and nothing else set -e in a run: script masked the actual error Add set -euxo pipefail at the top of the run block; rerun

Companion: GitHub Actions or DeployHQ?

GitHub Actions is excellent at CI — running your test matrix, building artifacts, validating PRs. It works as a deployer too, but the trade-off becomes clearer the more environments and servers you operate:

  • CI is bursty and parallel. GitHub Actions' free minute pool, hosted runners, and matrix builds are tuned for this.
  • Deploys are serial and stateful. They need SSH access to long-lived servers, atomic release directories, predictable rollback, and (in agency contexts) per-client server credentials managed by humans, not committed YAML.

DeployHQ specialises in the deploy side: project-scoped SSH keys per server, zero-downtime atomic releases, one-click rollback, per-client server inventory, and a build pipeline that runs the same build container on every deploy — without exposing your production SSH keys to GitHub Actions secrets.

The common pattern: GitHub Actions runs CI (tests, lint, type-check), then triggers DeployHQ via webhook for the deploy. You keep the CI minutes you've already paid for, you keep deploy credentials out of GitHub Actions' secret store, and DeployHQ handles the SSH/rsync/symlink-swap mechanics that GitHub Actions deploys re-invent badly.

The full comparison — strengths, gaps, and the integration pattern — is in DeployHQ vs GitHub Actions. The build pipelines page covers how the build-then-deploy split works in practice. Setup walkthroughs for the GitHub side are in deploy from GitHub to your server.

Start a free DeployHQ trial to wire GitHub Actions CI to a real deploy pipeline.


  • Git cheatsheet — for the tag-based release pattern every production deploy workflow keys off.
  • Docker cheatsheet — for the multi-stage builds and docker/build-push-action cache strategies behind every container deploy.
  • AWS CLI cheatsheet — for the multi-account OIDC pattern and ECS/CloudFormation steps Actions deploy workflows call.
  • Bash scripting cheatsheet — for the set -euxo pipefail wrappers that turn run: blocks into reliable scripts.
  • Cron and crontab cheatsheet — for the schedule: trigger's cron syntax.
  • Cheatsheets hub — every DeployHQ cheatsheet in one place.

Need help? Email support@deployhq.com or follow @deployhq on X.