github-actions.md 17 KB

GitHub Actions Reference

Table of Contents


Workflow File Anatomy

Every workflow lives in .github/workflows/*.yml. A complete annotated example:

# .github/workflows/ci.yml
name: CI Pipeline                     # Name shown in Actions tab

# ── Triggers ──────────────────────────────────────────────
on:
  push:
    branches: [main, 'release/**']
    paths-ignore: ['docs/**', '*.md']
  pull_request:
    branches: [main]
    types: [opened, synchronize, reopened]
  schedule:
    - cron: '0 6 * * 1'              # Weekly Monday 6am UTC
  workflow_dispatch:                   # Manual trigger
    inputs:
      environment:
        description: 'Deploy target'
        required: true
        default: 'staging'
        type: choice
        options: [staging, production]

# ── Token Permissions (least privilege) ───────────────────
permissions:
  contents: read
  pull-requests: write
  checks: write

# ── Concurrency ──────────────────────────────────────────
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

# ── Workflow Environment ─────────────────────────────────
env:
  CI: true
  NODE_ENV: test

# ── Jobs ─────────────────────────────────────────────────
jobs:
  lint:
    name: Lint & Format
    runs-on: ubuntu-latest
    timeout-minutes: 10               # Prevent hung jobs
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version-file: '.nvmrc'
          cache: npm
      - run: npm ci
      - run: npm run lint
      - run: npm run format:check

  test:
    name: Test (${{ matrix.node-version }})
    runs-on: ubuntu-latest
    timeout-minutes: 15
    needs: lint                       # Run after lint passes
    strategy:
      fail-fast: false
      matrix:
        node-version: [18, 20, 22]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: npm
      - run: npm ci
      - run: npm test -- --coverage
      - uses: actions/upload-artifact@v4
        if: always()                  # Upload even on failure
        with:
          name: coverage-${{ matrix.node-version }}
          path: coverage/
          retention-days: 7

  deploy:
    name: Deploy
    runs-on: ubuntu-latest
    needs: test
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    environment: production           # Requires approval
    steps:
      - uses: actions/checkout@v4
      - run: ./deploy.sh
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

Job Dependencies and Conditionals

Job Dependencies with needs

jobs:
  build:
    runs-on: ubuntu-latest
    steps: [...]

  test:
    needs: build                      # Waits for build
    runs-on: ubuntu-latest
    steps: [...]

  deploy:
    needs: [build, test]              # Waits for both
    runs-on: ubuntu-latest
    steps: [...]

Conditional Execution with if

jobs:
  deploy:
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest

  notify:
    needs: deploy
    if: always()                      # Run even if deploy fails
    runs-on: ubuntu-latest

  release:
    if: startsWith(github.ref, 'refs/tags/v')
    runs-on: ubuntu-latest

steps:
  - run: echo "Only on failure"
    if: failure()

  - run: echo "Only on success"
    if: success()

  - run: echo "Always run (cleanup)"
    if: always()

  - run: echo "Skip on forks"
    if: github.repository == 'owner/repo'

  - run: echo "Only for specific actor"
    if: github.actor == 'dependabot[bot]'

  - run: echo "Check PR label"
    if: contains(github.event.pull_request.labels.*.name, 'deploy')

Accessing Outputs from needs

jobs:
  check:
    runs-on: ubuntu-latest
    outputs:
      should-deploy: ${{ steps.decision.outputs.deploy }}
    steps:
      - id: decision
        run: |
          if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
            echo "deploy=true" >> "$GITHUB_OUTPUT"
          else
            echo "deploy=false" >> "$GITHUB_OUTPUT"
          fi

  deploy:
    needs: check
    if: needs.check.outputs.should-deploy == 'true'
    runs-on: ubuntu-latest
    steps:
      - run: echo "Deploying..."

Reusable Workflows

Defining a Reusable Workflow

# .github/workflows/reusable-test.yml
name: Reusable Test Workflow
on:
  workflow_call:
    inputs:
      node-version:
        description: 'Node.js version'
        required: false
        default: '20'
        type: string
      working-directory:
        description: 'Directory to run tests in'
        required: false
        default: '.'
        type: string
    secrets:
      NPM_TOKEN:
        required: false
        description: 'NPM auth token'
    outputs:
      coverage-percent:
        description: 'Test coverage percentage'
        value: ${{ jobs.test.outputs.coverage }}

jobs:
  test:
    runs-on: ubuntu-latest
    outputs:
      coverage: ${{ steps.cov.outputs.percent }}
    defaults:
      run:
        working-directory: ${{ inputs.working-directory }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
          cache: npm
      - run: npm ci
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
      - run: npm test -- --coverage
      - id: cov
        run: |
          PERCENT=$(jq '.total.lines.pct' coverage/coverage-summary.json)
          echo "percent=$PERCENT" >> "$GITHUB_OUTPUT"

Calling a Reusable Workflow

# .github/workflows/ci.yml
name: CI
on: [push, pull_request]

jobs:
  test:
    uses: ./.github/workflows/reusable-test.yml
    with:
      node-version: '20'
    secrets:
      NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

  # Or inherit all secrets
  test-inherit:
    uses: ./.github/workflows/reusable-test.yml
    secrets: inherit

  # Call from another repo
  test-external:
    uses: org/shared-workflows/.github/workflows/test.yml@main
    with:
      node-version: '20'

  report:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - run: echo "Coverage was ${{ needs.test.outputs.coverage-percent }}%"

Composite Actions

Creating a Composite Action

# .github/actions/setup-project/action.yml
name: 'Setup Project'
description: 'Install dependencies and build'
inputs:
  node-version:
    description: 'Node.js version'
    required: false
    default: '20'
  install-command:
    description: 'Install command'
    required: false
    default: 'npm ci'
outputs:
  cache-hit:
    description: 'Whether cache was hit'
    value: ${{ steps.cache.outputs.cache-hit }}

runs:
  using: composite
  steps:
    - uses: actions/setup-node@v4
      with:
        node-version: ${{ inputs.node-version }}

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

    - if: steps.cache.outputs.cache-hit != 'true'
      run: ${{ inputs.install-command }}
      shell: bash

    - run: npm run build
      shell: bash                     # shell: is REQUIRED in composite

Using a Composite Action

steps:
  - uses: actions/checkout@v4
  - uses: ./.github/actions/setup-project
    with:
      node-version: '22'
  - run: npm test

Matrix Strategy

Basic Matrix

strategy:
  matrix:
    os: [ubuntu-latest, windows-latest, macos-latest]
    node: [18, 20, 22]
    # Creates 3 x 3 = 9 jobs

Include and Exclude

strategy:
  matrix:
    os: [ubuntu-latest, windows-latest]
    node: [18, 20]
    include:
      # Add a job with extra variables
      - os: ubuntu-latest
        node: 22
        experimental: true
      # Add variables to existing combo
      - os: windows-latest
        node: 20
        npm-version: 10
    exclude:
      # Remove a specific combo
      - os: windows-latest
        node: 18

Matrix with continue-on-error

strategy:
  fail-fast: false
  matrix:
    node: [18, 20, 22]
    include:
      - node: 22
        experimental: true

jobs:
  test:
    continue-on-error: ${{ matrix.experimental || false }}

Single-Dimension Matrix (List of Configs)

strategy:
  matrix:
    include:
      - name: Unit Tests
        command: npm run test:unit
      - name: Integration Tests
        command: npm run test:integration
        timeout: 30
      - name: E2E Tests
        command: npm run test:e2e
        timeout: 60

Artifacts

Upload and Download Between Jobs

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm run build

      - uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/
          retention-days: 1           # Short-lived build artifacts
          if-no-files-found: error    # Fail if nothing to upload

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

      - run: ls -la dist/            # Verify download

Multiple Artifact Upload (Matrix)

# Upload with unique names per matrix
- uses: actions/upload-artifact@v4
  with:
    name: results-${{ matrix.os }}-${{ matrix.node }}
    path: test-results/

# Download all in a later job
- uses: actions/download-artifact@v4
  with:
    pattern: results-*
    merge-multiple: true
    path: all-results/

Environment Protection Rules

Environments provide deployment gates and scoped secrets.

Setting Up Environments

Environments are configured in Settings > Environments on GitHub. Options:

Setting Purpose
Required reviewers Manual approval before deployment (up to 6 reviewers)
Wait timer Delay in minutes before deployment proceeds
Deployment branches Restrict which branches can deploy (e.g., only main)
Environment secrets Secrets scoped to this environment only
Environment variables Variables scoped to this environment

Using Environments in Workflows

jobs:
  deploy-staging:
    runs-on: ubuntu-latest
    environment:
      name: staging
      url: https://staging.example.com   # Shown in deployment status
    steps:
      - run: deploy --env staging
        env:
          API_KEY: ${{ secrets.API_KEY }}  # Environment-scoped secret

  deploy-production:
    needs: deploy-staging
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://example.com
    steps:
      - run: deploy --env production

Concurrency Control

Cancel Previous Runs on Same Branch

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

Deployment Queue (No Cancellation)

concurrency:
  group: deploy-production
  cancel-in-progress: false           # Queue instead of cancel

Per-PR Concurrency

concurrency:
  group: pr-${{ github.event.pull_request.number }}
  cancel-in-progress: true

Self-Hosted Runners

Runner Labels

jobs:
  build:
    runs-on: [self-hosted, linux, x64, gpu]    # Match all labels

Runner Groups (Enterprise/Org)

jobs:
  build:
    runs-on:
      group: production-runners
      labels: [linux, x64]

Hybrid Strategy

strategy:
  matrix:
    runner: [ubuntu-latest, self-hosted]

jobs:
  test:
    runs-on: ${{ matrix.runner }}

OIDC for Cloud Deployment

OIDC eliminates stored cloud credentials. GitHub issues a short-lived JWT that your cloud provider trusts.

AWS

permissions:
  id-token: write
  contents: read

steps:
  - uses: aws-actions/configure-aws-credentials@v4
    with:
      role-to-assume: arn:aws:iam::123456789012:role/GitHubActions
      aws-region: us-east-1
      # No access keys needed

  - run: aws s3 sync dist/ s3://my-bucket

GCP

permissions:
  id-token: write
  contents: read

steps:
  - uses: google-github-actions/auth@v2
    with:
      workload_identity_provider: 'projects/123/locations/global/workloadIdentityPools/github/providers/my-repo'
      service_account: 'deploy@my-project.iam.gserviceaccount.com'

  - uses: google-github-actions/setup-gcloud@v2

  - run: gcloud run deploy my-service --image gcr.io/my-project/app

Azure

permissions:
  id-token: write
  contents: read

steps:
  - uses: azure/login@v2
    with:
      client-id: ${{ secrets.AZURE_CLIENT_ID }}
      tenant-id: ${{ secrets.AZURE_TENANT_ID }}
      subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

  - run: az webapp deploy --name my-app --src-path dist/

Common Action Recipes

Checkout

# Standard checkout
- uses: actions/checkout@v4

# Full history (for changelogs, git describe)
- uses: actions/checkout@v4
  with:
    fetch-depth: 0

# Checkout PR head (for pull_request_target)
- uses: actions/checkout@v4
  with:
    ref: ${{ github.event.pull_request.head.sha }}

# Checkout with submodules
- uses: actions/checkout@v4
  with:
    submodules: recursive
    token: ${{ secrets.PAT }}         # For private submodules

Setup Node.js

- uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: npm                        # Or pnpm, yarn
    registry-url: https://npm.pkg.github.com

Setup Go

- uses: actions/setup-go@v5
  with:
    go-version-file: go.mod           # Read from go.mod
    cache: true                       # Cache go modules

Setup Python

- uses: actions/setup-python@v5
  with:
    python-version: '3.12'
    cache: pip                        # Or pipenv, poetry

Docker Build and Push

- uses: docker/setup-buildx-action@v3

- uses: docker/login-action@v3
  with:
    registry: ghcr.io
    username: ${{ github.actor }}
    password: ${{ secrets.GITHUB_TOKEN }}

- uses: docker/metadata-action@v5
  id: meta
  with:
    images: ghcr.io/${{ github.repository }}
    tags: |
      type=semver,pattern={{version}}
      type=semver,pattern={{major}}.{{minor}}
      type=sha,prefix=
      type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}

- 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
    platforms: linux/amd64,linux/arm64

Debugging Workflows

Enable Debug Logging

Set repository secret ACTIONS_STEP_DEBUG to true for verbose step output.

Or re-run a failed job with "Enable debug logging" checkbox.

Debug Expressions

- run: |
    echo "Event: ${{ github.event_name }}"
    echo "Ref: ${{ github.ref }}"
    echo "SHA: ${{ github.sha }}"
    echo "Actor: ${{ github.actor }}"
    echo "Matrix: ${{ toJson(matrix) }}"
    echo "Env: ${{ toJson(env) }}"

# Dump full event payload
- run: cat "$GITHUB_EVENT_PATH" | jq .

Local Testing with act

# Install act (https://github.com/nektos/act)
brew install act                      # macOS
choco install act-cli                 # Windows

# Run default event (push)
act

# Run specific workflow
act -W .github/workflows/ci.yml

# Run specific job
act -j test

# Run with specific event
act pull_request

# Pass secrets
act -s GITHUB_TOKEN="$(gh auth token)"

# Use specific runner image
act -P ubuntu-latest=catthehacker/ubuntu:act-latest

# Dry run (show what would run)
act -n

Common Debugging Patterns

# Temporarily add to any step
- run: |
    echo "::group::Debug Info"
    env | sort
    echo "::endgroup::"

# Check file existence
- run: |
    echo "::group::Workspace Contents"
    find . -maxdepth 3 -type f | head -50
    echo "::endgroup::"

# Conditional debug step
- if: runner.debug == '1'
  run: |
    echo "Debug mode enabled"
    cat package.json | jq '.scripts'

Workflow Run Annotations

# Warning annotation
- run: echo "::warning file=app.js,line=1::Missing error handling"

# Error annotation
- run: echo "::error file=app.js,line=10,col=5::Syntax error"

# Notice annotation
- run: echo "::notice::Deployment complete"

# Group log lines
- run: |
    echo "::group::Install Dependencies"
    npm ci
    echo "::endgroup::"