## 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:

```yaml
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:`)

```yaml
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](https://www.deployhq.com/cheatsheets/cron) has the cron syntax reference if `schedule:` doesn't fire when you expected.

### Permissions (least privilege)

```yaml
# 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)

```yaml
# 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)

```yaml
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

```yaml
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

```yaml
- 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](https://www.deployhq.com/cheatsheets/composer) applies here.

### Secrets and contexts

```yaml
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

```yaml
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`:

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

### Artifacts and uploads

```yaml
- 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

```yaml
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

```yaml
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`)

```yaml
# .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 }}
```

```yaml
# .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)

```yaml
# .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
```

```yaml
# 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

```yaml
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:

```yaml
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](https://www.deployhq.com/cheatsheets/git)). 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.

```yaml
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](https://github.com/google-github-actions/auth) and [Azure](https://github.com/Azure/login). For the deeper AWS pattern, see the [AWS CLI cheatsheet's multi-account section](https://www.deployhq.com/cheatsheets/aws-cli).

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

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

```yaml
# 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:

```yaml
- 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:

```yaml
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:

```yaml
# .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 }}
```

```yaml
# .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
```

```yaml
# .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`](https://cli.github.com/) CLI is the missing piece for treating Actions as scriptable infrastructure:

```bash
# 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.

```yaml
# 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`](https://github.com/mheap/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:

```yaml
# README badge
[![CI](https://github.com/org/app/actions/workflows/ci.yml/badge.svg)](https://github.com/org/app/actions/workflows/ci.yml)
```

```bash
# 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:

```yaml
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:

```yaml
- 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:

```yaml
- 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:

```yaml
- 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](https://www.deployhq.com/compare/deployhq-vs-github-actions); the [agents page](https://www.deployhq.com/agents) 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](https://www.deployhq.com/features/zero-downtime-deployments), [one-click rollback](https://www.deployhq.com/features/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](https://www.deployhq.com/compare/deployhq-vs-github-actions). The [build pipelines](https://www.deployhq.com/features/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](https://www.deployhq.com/deploy-from-github).

[Start a free DeployHQ trial](https://www.deployhq.com/signup) to wire GitHub Actions CI to a real deploy pipeline.

---

## Related cheatsheets

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

---

Need help? Email [support@deployhq.com](mailto:support@deployhq.com) or follow [@deployhq on X](https://x.com/deployhq).
