GitHub Actions Advanced

Design, debug, and harden GitHub Actions CI/CD workflows, including reusable workflows, matrix builds, self-hosted runners, OIDC authentication, caching, environments, secrets, and release automation.

Published by @sickn33 and contributors·0 agent reads / 30d·0 saves·

GitHub Actions Advanced Skill

Expert guidance for designing, writing, debugging, and securing production-grade GitHub Actions workflows.


When to Use This Skill

  • User mentions GitHub Actions, .github/workflows, CI/CD pipelines, runners, jobs, steps, or actions
  • User wants to automate builds, tests, deployments, or releases via GitHub
  • User asks about matrix builds, reusable workflows, composite actions, or self-hosted runners
  • User needs help with OIDC authentication, caching strategies, or secrets management
  • User says "my GitHub pipeline is failing" or "set up CI for my repo"
  • User asks about workflow security, hardening, or environment protection rules

When NOT to Use This Skill

  • The user is working with GitLab CI/CD → recommend gitlab-ci-patterns
  • The user is working with CircleCI, Jenkins, or other CI platforms
  • The task is purely about Docker image building without GitHub context → recommend docker-expert
  • The task is about Kubernetes deployment configuration → recommend kubernetes-architect

Step 1: Understand Context Before Responding

When invoked, first gather context:

# Discover existing workflows in the repo
find .github/workflows -name "*.yml" -o -name "*.yaml" 2>/dev/null | head -20

# Check for composite actions
find .github/actions -name "action.yml" 2>/dev/null

# Detect tech stack (influences runner OS, language setup actions)
ls package.json requirements.txt Gemfile go.mod Cargo.toml pom.xml 2>/dev/null

Then adapt recommendations to:

  • Existing workflow patterns in the repo
  • The tech stack and language runtime
  • Whether this is a monorepo or single-project repo
  • Whether self-hosted or GitHub-hosted runners are in use

Workflow Structure Reference

name: Workflow Name

on:                          # Triggers (see Triggers section)
  push:
    branches: [main]

permissions:                 # Always declare — principle of least privilege
  contents: read

env:                         # Workflow-level env vars
  NODE_VERSION: '20'

concurrency:                 # Prevent duplicate runs
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true   # Cancel older runs for same branch

jobs:
  job-id:
    name: Human-readable name
    runs-on: ubuntu-24.04    # Pin OS version — never use -latest in prod
    timeout-minutes: 15      # Always set — prevents runaway jobs
    environment: production  # Links to GitHub Environment (approvals/secrets)

    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2
      - name: Step name
        run: echo "hello"

Triggers (on:)

Common Patterns

on:
  push:
    branches: [main, 'release/**']
    paths-ignore: ['**.md', 'docs/**']   # Skip docs-only changes

  pull_request:
    types: [opened, synchronize, reopened]
    branches: [main]

  workflow_dispatch:                      # Manual trigger with inputs
    inputs:
      environment:
        description: 'Deploy target'
        required: true
        type: choice
        options: [staging, production]
      dry-run:
        description: 'Dry run only?'
        type: boolean
        default: false

  schedule:
    - cron: '0 2 * * 1'                 # Monday 2am UTC

  workflow_call:                          # Called by other workflows (reusable)
    inputs:
      image-tag:
        type: string
        required: true
    secrets:
      deploy-token:
        required: true

  release:
    types: [published]                   # Trigger only on published releases

  pull_request_target:                   # Runs with repo secrets — use with care!
    types: [labeled]                     # Gate with label + author_association check

Security Warning: pull_request_target runs with repo secrets. Only use after a maintainer labels the PR. Never check out fork code without explicit sandboxing.


Reusable Workflows

Split large pipelines into composable units stored in .github/workflows/.

Convention: Prefix internal/reusable workflows with _ (e.g., _build.yml).

Caller (.github/workflows/deploy.yml)

jobs:
  call-build:
    uses: ./.github/workflows/_build.yml        # Same-repo reusable
    # uses: org/repo/.github/workflows/build.yml@main  # Cross-repo
    with:
      image-tag: ${{ github.sha }}
    secrets: inherit                             # Pass all caller secrets down
  
  call-test:
    uses: ./.github/workflows/_test.yml
    with:
      node-version: '20'
    secrets:
      NPM_TOKEN: ${{ secrets.NPM_TOKEN }}       # Explicit secret passing

Reusable Workflow (.github/workflows/_build.yml)

on:
  workflow_call:
    inputs:
      image-tag:
        type: string
        required: true
      push:
        type: boolean
        default: false
    secrets:
      registry-token:
        required: false
    outputs:
      digest:
        description: "Image digest"
        value: ${{ jobs.build.outputs.digest }}

jobs:
  build:
    runs-on: ubuntu-24.04
    timeout-minutes: 20
    outputs:
      digest: ${{ steps.build.outputs.digest }}
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2
      - id: build
        uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75  # v6.9.0
        with:
          push: ${{ inputs.push }}
          tags: myapp:${{ inputs.image-tag }}

Matrix Builds

jobs:
  test:
    strategy:
      fail-fast: false           # Don't cancel others if one fails
      max-parallel: 4            # Limit concurrent runners
      matrix:
        os: [ubuntu-24.04, windows-2022, macos-14]
        node: ['18', '20', '22']
        exclude:
          - os: windows-2022
            node: '18'
        include:
          - os: ubuntu-24.04
            node: '22'
            experimental: true   # Custom matrix variable

    runs-on: ${{ matrix.os }}
    timeout-minutes: 20

    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2
      - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af  # v4.1.0
        with:
          node-version: ${{ matrix.node }}
          cache: 'npm'
      - run: npm ci
      - run: npm test
        continue-on-error: ${{ matrix.experimental == true }}

Dynamic Matrix via Script

jobs:
  generate-matrix:
    runs-on: ubuntu-24.04
    outputs:
      matrix: ${{ steps.set-matrix.outputs.matrix }}
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2
      - id: set-matrix
        run: |
          SERVICES=$(find services -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | jq -R -s -c 'split("\n")[:-1]')
          printf 'matrix={"service":%s}\n' "$SERVICES" >> "$GITHUB_OUTPUT"

  build:
    needs: generate-matrix
    strategy:
      matrix: ${{ fromJson(needs.generate-matrix.outputs.matrix) }}
    runs-on: ubuntu-24.04
    steps:
      - env:
          SERVICE: ${{ matrix.service }}
        run: echo "Building $SERVICE"

Caching Strategies

Language Setup Actions (Preferred — No Extra Step Needed)

# Node.js
- uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af  # v4.1.0
  with:
    node-version: '20'
    cache: 'npm'           # or 'yarn' or 'pnpm'

# Python
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b  # v5.3.0
  with:
    python-version: '3.12'
    cache: 'pip'

# Go
- uses: actions/setup-go@3041bf56c941b39c61721a86cd11f3bb1338122a  # v5.2.0
  with:
    go-version: '1.23'
    cache: true

# Java / Gradle / Maven
- uses: actions/setup-java@7a6d8a8234af8eb26422e24052f73b12b0e46a27  # v4.6.0
  with:
    distribution: 'temurin'
    java-version: '21'
    cache: 'maven'        # or 'gradle'

Manual Cache (Any Tool)

- uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307ccb58a  # v4.1.2
  id: cache-deps
  with:
    path: |
      ~/.cache/pip
      .venv
    key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt') }}
    restore-keys: |
      ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt') }}
      ${{ runner.os }}-pip-

- name: Install deps (only on cache miss)
  if: steps.cache-deps.outputs.cache-hit != 'true'
  run: pip install -r requirements.txt

Docker Layer Caching

- uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75  # v6.9.0
  with:
    cache-from: type=gha
    cache-to: type=gha,mode=max
    # For registry-backed cache (cross-branch):
    # cache-from: type=registry,ref=ghcr.io/myorg/myapp:buildcache
    # cache-to: type=registry,ref=ghcr.io/myorg/myapp:buildcache,mode=max

OIDC Authentication (Keyless Cloud Auth)

Never store long-lived cloud credentials as secrets. Use OIDC to get short-lived tokens that expire automatically.

AWS

permissions:
  id-token: write
  contents: read

steps:
  - uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502  # v4.0.2
    with:
      role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsRole
      aws-region: us-east-1
      role-session-name: GitHubActions-${{ github.run_id }}

  # Trust policy on the IAM role must include:
  # "token.actions.githubusercontent.com" as OIDC provider
  # Condition: "repo:org/repo:ref:refs/heads/main" (restrict to branch)

GCP (Workload Identity Federation)

permissions:
  id-token: write
  contents: read

steps:
  - uses: google-github-actions/auth@6fc4af4b145ae7821d527454aa9bd537d1f2dc5f  # v2.1.7
    with:
      workload_identity_provider: projects/123456789/locations/global/workloadIdentityPools/github-pool/providers/github-provider
      service_account: [email protected]
      token_format: access_token   # or 'id_token'

Azure (Federated Identity)

permissions:
  id-token: write
  contents: read

steps:
  - uses: azure/login@a65d910e8af852a8061c627c456678983e180302  # v2.2.0
    with:
      client-id: ${{ secrets.AZURE_CLIENT_ID }}
      tenant-id: ${{ secrets.AZURE_TENANT_ID }}
      subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
      # No client secret needed! Uses OIDC federated credentials

Environments & Deployment Protection

jobs:
  deploy-staging:
    environment:
      name: staging
      url: https://staging.myapp.com
    runs-on: ubuntu-24.04
    timeout-minutes: 30
    steps:
      - run: ./scripts/deploy.sh staging

  deploy-production:
    needs: deploy-staging
    environment:
      name: production
      url: https://myapp.com      # Shown in the GitHub UI deployment panel
    runs-on: ubuntu-24.04
    timeout-minutes: 30
    steps:
      - run: ./scripts/deploy.sh production

Configure in Settings → Environments:

  • Required reviewers — manual approval gate before run
  • Wait timer — delay after approval (e.g., 10-minute buffer)
  • Branch/tag restrictions — only main or v* tags can deploy to prod
  • Environment-specific secrets — override repo-level secrets per environment
  • Deployment branches — whitelist which branches can target this environment

Secrets Management

# Access repo/org/environment secrets
env:
  DB_PASSWORD: ${{ secrets.DB_PASSWORD }}

# Auto-provided token — no setup needed
- uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea  # v7.0.1
  with:
    github-token: ${{ secrets.GITHUB_TOKEN }}

# Hierarchy (most specific wins):
# environment secret > repo secret > org secret

Masking Dynamic Values

- name: Generate and mask dynamic token
  run: |
    TOKEN=$(./scripts/generate-token.sh)
    echo "::add-mask::$TOKEN"          # Mask in all subsequent logs
    echo "DEPLOY_TOKEN=$TOKEN" >> $GITHUB_ENV

Secrets in Composite Actions

# Secrets cannot be passed as inputs to composite actions
# Pass them as env vars instead:
- uses: ./.github/actions/my-action
  env:
    SECRET_VALUE: ${{ secrets.MY_SECRET }}

Composite Actions

Package reusable step sequences into local actions. No container spin-up, no separate workflow file needed.

Action Definition (.github/actions/setup-app/action.yml)

name: Setup App
description: Install and configure application dependencies

inputs:
  node-version:
    description: 'Node.js version'
    required: false
    default: '20'
  install-flags:
    description: 'Additional npm install flags'
    required: false
    default: ''

outputs:
  cache-hit:
    description: 'Whether the dependency cache was hit'
    value: ${{ steps.cache.outputs.cache-hit }}

runs:
  using: composite
  steps:
    - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af  # v4.1.0
      with:
        node-version: ${{ inputs.node-version }}
        cache: npm

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

    - name: Install dependencies
      if: steps.cache.outputs.cache-hit != 'true'
      shell: bash
      env:
        INSTALL_FLAGS: ${{ inputs.install-flags }}
      run: |
        args=()
        case "$INSTALL_FLAGS" in
          "") ;;
          "--ignore-scripts") args+=(--ignore-scripts) ;;
          *) echo "Unsupported install flags" >&2; exit 1 ;;
        esac
        npm ci "${args[@]}"

    - name: Build
      shell: bash
      run: npm run build

Usage in a Workflow

steps:
  - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2
  - uses: ./.github/actions/setup-app
    with:
      node-version: '22'
      install-flags: '--ignore-scripts'

Self-Hosted Runners

jobs:
  build-gpu:
    runs-on: [self-hosted, linux, x64, gpu]    # Label matching
    timeout-minutes: 60

  build-arm:
    runs-on: [self-hosted, linux, arm64]

Runner Best Practices

PracticeDetails
Ephemeral runnersUse Actions Runner Controller (ARC) on Kubernetes for fresh runners per job
IsolationNever share prod runners with untrusted/fork PR workflows
Cleanup hooksSet ACTIONS_RUNNER_HOOK_JOB_COMPLETED to reset environment
Runner groupsUse groups to restrict which repos/workflows can access which runners
LabelsUse custom labels (e.g., gpu, high-memory) for precise targeting
SecurityDisable fork PR access to self-hosted runners in Settings
# Actions Runner Controller (Kubernetes) — recommended for ephemeral runners
helm install arc \
  --namespace arc-systems \
  --create-namespace \
  oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set-controller

Conditional Execution & Flow Control

# Condition on branch + event
- run: ./scripts/deploy.sh
  if: github.ref == 'refs/heads/main' && github.event_name == 'push'

# Continue on error (non-blocking steps)
- run: ./scripts/lint.sh
  continue-on-error: true

# Job dependency and conditional execution
jobs:
  test:
    runs-on: ubuntu-24.04
    outputs:
      result: ${{ steps.run-tests.outcome }}

  deploy:
    needs: [test, build]
    if: |
      needs.test.result == 'success' &&
      needs.build.result == 'success' &&
      github.ref == 'refs/heads/main'
    runs-on: ubuntu-24.04

  notify-failure:
    needs: [test, deploy]
    if: failure()          # Runs even if earlier jobs fail
    runs-on: ubuntu-24.04
    steps:
      - run: ./scripts/notify-slack.sh "Pipeline failed!"

Passing Data Between Jobs

jobs:
  prepare:
    runs-on: ubuntu-24.04
    outputs:
      version: ${{ steps.get-version.outputs.version }}
      should-deploy: ${{ steps.check.outputs.deploy }}

    steps:
      - id: get-version
        run: |
          VERSION=$(tr -d '\r\n' < VERSION)
          case "$VERSION" in
            ""|*[!0-9A-Za-z._-]*) echo "Invalid VERSION" >&2; exit 1 ;;
          esac
          printf 'version=%s\n' "$VERSION" >> "$GITHUB_OUTPUT"

      - id: check
        run: |
          if git log -1 --pretty=%B | grep -q '\[deploy\]'; then
            echo "deploy=true" >> $GITHUB_OUTPUT
          else
            echo "deploy=false" >> $GITHUB_OUTPUT
          fi

  build:
    needs: prepare
    if: needs.prepare.outputs.should-deploy == 'true'
    runs-on: ubuntu-24.04
    steps:
      - env:
          VERSION: ${{ needs.prepare.outputs.version }}
        run: echo "Building version $VERSION"

Security Hardening

1. Always Declare Permissions (Least Privilege)

# Workflow-level default — restrict everything
permissions:
  contents: read

jobs:
  publish:
    # Job-level override — only expand what's needed
    permissions:
      contents: write        # Only for release/publish jobs
      packages: write        # Only for container push jobs
      pull-requests: write   # Only for PR comment jobs
      id-token: write        # Only for OIDC auth jobs

2. Pin Third-Party Actions to Full Commit SHA

# ❌ UNSAFE — tag can be mutated or hijacked
- uses: actions/checkout@v4

# ✅ SAFE — commit SHA is immutable
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2

# Tool to automate SHA pinning:
# npx pin-github-action .github/workflows/*.yml
# or: pip install ratchet && ratchet pin .github/workflows/

3. Prevent Script Injection

# ❌ UNSAFE — attacker controls PR title, which gets expanded in shell
- run: echo "${{ github.event.pull_request.title }}"

# ✅ SAFE — pass through environment variable (shell doesn't evaluate it)
- env:
    PR_TITLE: ${{ github.event.pull_request.title }}
  run: echo "$PR_TITLE"

# ✅ SAFE — expressions in if: conditions are evaluated by Actions, not shell
- if: github.event.pull_request.draft == false
  run: echo "Not a draft"

Never place ${{ ... }} directly inside run: when the value can come from PR metadata, workflow inputs, repository files, matrix JSON, or earlier job outputs. Put it in env: first, validate allowlisted values where possible, and reference the shell variable with quotes.

4. Restrict pull_request_target Usage

# Only run when a maintainer adds a specific label — prevents untrusted execution
on:
  pull_request_target:
    types: [labeled]

jobs:
  validate:
    # Double-guard: check label name AND author_association
    if: |
      github.event.label.name == 'safe-to-test' &&
      (github.event.pull_request.author_association == 'COLLABORATOR' ||
       github.event.pull_request.author_association == 'MEMBER' ||
       github.event.pull_request.author_association == 'OWNER')

5. Harden with StepSecurity

# Add to every workflow — hardens runner, monitors outbound traffic
- uses: step-security/harden-runner@4d991eb9995541a0b71d1b66f1f98a5f1bef422c  # v2.11.0
  with:
    egress-policy: audit          # Start with 'audit', move to 'block' after confirming allowlist
    allowed-endpoints: >
      api.github.com:443
      registry.npmjs.org:443
      objects.githubusercontent.com:443

Debugging Techniques

# Enable runner diagnostic logging via repo secrets:
# ACTIONS_RUNNER_DEBUG = true
# ACTIONS_STEP_DEBUG = true

# Dump full GitHub context for inspection
- name: Debug — dump github context
  if: runner.debug == '1'
  env:
    GITHUB_CONTEXT: ${{ toJson(github) }}
  run: echo "$GITHUB_CONTEXT" | jq '.'

# Dump all available contexts
- name: Debug — dump all contexts
  if: runner.debug == '1'
  run: |
    echo "github: ${{ toJson(github) }}"
    echo "env: ${{ toJson(env) }}"
    echo "vars: ${{ toJson(vars) }}"
    echo "runner: ${{ toJson(runner) }}"

# SSH into a failing runner for interactive debugging
- uses: mxschmitt/action-tmate@7b04f3521e6b0a9fc56fa8f9f50da4bcfb5fc7b5  # v3.19.0
  if: failure() && runner.debug == '1'
  with:
    limit-access-to-actor: true    # Only the workflow triggerer can SSH in
    timeout-minutes: 30

# Check what's pre-installed on GitHub-hosted runners
- run: |
    echo "=== Tool Versions ===" 
    node --version
    python3 --version
    go version
    docker --version
    echo "=== Disk Space ==="
    df -h
    echo "=== Memory ==="
    free -h

Complete Pipeline Patterns

Pattern 1: Build → Test → Push → Deploy

name: CI/CD Pipeline

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

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}

permissions:
  contents: read

jobs:
  # ── Build & Test ──────────────────────────────────────
  build-test:
    runs-on: ubuntu-24.04
    timeout-minutes: 20
    permissions:
      contents: read
      checks: write        # For test result reporting

    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2

      - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af  # v4.1.0
        with:
          node-version: '20'
          cache: 'npm'

      - run: npm ci
      - run: npm run lint
      - run: npm run test -- --coverage
      - run: npm run build

      - uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882  # v4.4.3
        with:
          name: build-artifacts
          path: dist/
          retention-days: 7

  # ── Push Image (main branch only) ─────────────────────
  push-image:
    needs: build-test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-24.04
    timeout-minutes: 20
    permissions:
      contents: read
      packages: write
      id-token: write      # For OIDC
    outputs:
      image-digest: ${{ steps.push.outputs.digest }}

    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2

      - uses: docker/setup-buildx-action@c47758b77c9736f4b2ef4073d4d51994fabfe349  # v3.7.1

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

      - uses: docker/metadata-action@70b2cdc6480c1a8b86edf1777157f8f437de2166  # v5.5.1
        id: meta
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            type=sha,format=long
            type=raw,value=latest

      - id: push
        uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75  # v6.9.0
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          provenance: true    # SLSA provenance attestation
          sbom: true          # Software Bill of Materials

  # ── Deploy Staging ────────────────────────────────────
  deploy-staging:
    needs: push-image
    runs-on: ubuntu-24.04
    timeout-minutes: 30
    environment:
      name: staging
      url: https://staging.myapp.com
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2
      - env:
          IMAGE_DIGEST: ${{ needs.push-image.outputs.image-digest }}
        run: ./scripts/deploy.sh staging "$IMAGE_DIGEST"

  # ── Deploy Production (manual approval required) ──────
  deploy-production:
    needs: deploy-staging
    runs-on: ubuntu-24.04
    timeout-minutes: 30
    environment:
      name: production
      url: https://myapp.com
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2
      - env:
          IMAGE_DIGEST: ${{ needs.push-image.outputs.image-digest }}
        run: ./scripts/deploy.sh production "$IMAGE_DIGEST"

Pattern 2: Automated Release with Changelog

name: Release

on:
  push:
    tags: ['v[0-9]+.[0-9]+.[0-9]+']

permissions:
  contents: write

jobs:
  release:
    runs-on: ubuntu-24.04
    timeout-minutes: 15

    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2
        with:
          fetch-depth: 0    # Full history needed for changelog generation

      - uses: softprops/action-gh-release@e7a8f85e1c67a31e6ed99a94b41bd0b71bbee6b8  # v2.0.9
        with:
          generate_release_notes: true    # Auto-generates from PR titles and commits
          make_latest: true
          fail_on_unmatched_files: true
          files: |
            dist/**/*.tar.gz
            dist/**/*.zip

Pattern 3: Dependency Auto-Update with PR

name: Dependency Updates

on:
  schedule:
    - cron: '0 9 * * 1'    # Every Monday at 9am UTC
  workflow_dispatch:

permissions:
  contents: write
  pull-requests: write

jobs:
  update-deps:
    runs-on: ubuntu-24.04
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2

      - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af  # v4.1.0
        with:
          node-version: '20'

      - run: npx npm-check-updates -u
      - run: npm install

      - uses: peter-evans/create-pull-request@5e914681df9dc83aa4e4905692ca88beb2f9e91f  # v7.0.5
        with:
          commit-message: 'chore: update npm dependencies'
          title: 'chore: update npm dependencies'
          branch: 'chore/npm-updates'
          delete-branch: true
          body: |
            Automated dependency updates generated by the dependency update workflow.
            Please review and test before merging.

Pattern 4: Security Scanning Pipeline

name: Security Scan

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  schedule:
    - cron: '0 6 * * *'    # Daily at 6am UTC

permissions:
  contents: read
  security-events: write    # For uploading SARIF results

jobs:
  codeql:
    runs-on: ubuntu-24.04
    timeout-minutes: 30
    permissions:
      security-events: write
      actions: read
      contents: read
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2
      - uses: github/codeql-action/init@4f3212b61783c3c68e8309a0f18a699764811cda  # v3.27.1
        with:
          languages: javascript-typescript
      - uses: github/codeql-action/autobuild@4f3212b61783c3c68e8309a0f18a699764811cda  # v3.27.1
      - uses: github/codeql-action/analyze@4f3212b61783c3c68e8309a0f18a699764811cda  # v3.27.1

  container-scan:
    runs-on: ubuntu-24.04
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2
      - uses: aquasecurity/trivy-action@6e7b7d1fd3e4fef0c5fa8cce1229c54b2c9bd0d8  # v0.28.0
        with:
          scan-type: 'fs'
          format: 'sarif'
          output: 'trivy-results.sarif'
          severity: 'CRITICAL,HIGH'
      - uses: github/codeql-action/upload-sarif@4f3212b61783c3c68e8309a0f18a699764811cda  # v3.27.1
        with:
          sarif_file: 'trivy-results.sarif'

Common Pitfalls & Fixes

ProblemCauseFix
Workflow doesn't trigger on PR from forkFork PRs use restricted GITHUB_TOKENUse pull_request not pull_request_target; avoid repo secrets in fork context
Secret is *** in logs but exposedDynamic value not maskedUse echo "::add-mask::$VALUE" before using it
Cache never hits across branchesCache key too specificAdd restore-keys fallback without branch or hash segment
Matrix job fails silentlyfail-fast: true (default) cancels siblingsSet fail-fast: false during debugging
Job hangs indefinitelyNo timeout-minutes setAlways set timeout-minutes on every job
$GITHUB_OUTPUT not setOld set-output command usedUse echo "key=value" >> $GITHUB_OUTPUT
OIDC token request failsMissing id-token: write permissionAdd to job-level permissions block
Reusable workflow can't access caller secretsNo secrets: inheritAdd secrets: inherit or explicitly pass secrets

GitHub Actions Expressions Reference

# Context objects available in expressions
${{ github.sha }}                           # Commit SHA
${{ github.ref }}                           # Branch/tag ref
${{ github.ref_name }}                      # Short branch/tag name
${{ github.event_name }}                    # Event name (push, pull_request, etc.)
${{ github.actor }}                         # Username who triggered the run
${{ github.repository }}                    # org/repo
${{ github.run_id }}                        # Unique run ID
${{ runner.os }}                            # Linux, Windows, macOS

# Built-in functions
${{ toJson(github) }}                       # Serialize context to JSON
${{ fromJson(needs.job.outputs.matrix) }}   # Parse JSON string
${{ hashFiles('**/package-lock.json') }}    # Hash file(s) for cache keys
${{ format('{0}/{1}', var1, var2) }}        # String formatting
${{ join(matrix.items, ',') }}              # Join array

# Status functions (use in if: conditions)
${{ success() }}    # All previous steps succeeded
${{ failure() }}    # Any previous step failed
${{ cancelled() }}  # Workflow was cancelled
${{ always() }}     # Always runs (success OR failure OR cancelled)

Production Readiness Checklist

Before merging any workflow to main, verify:

Security

  • All third-party actions pinned to full commit SHA
  • permissions: declared at workflow and job level (least privilege)
  • No ${{ }} expressions directly in run: blocks (use env vars)
  • OIDC used for cloud credentials (no long-lived secrets stored)
  • pull_request_target gated with label check + author_association guard
  • Secrets never echoed or logged

Reliability

  • timeout-minutes set on every job
  • fail-fast: false set for matrix builds used for debugging
  • concurrency configured to cancel stale runs
  • Retry logic for flaky external calls
  • Artifact retention policy set appropriately

Performance

  • Dependency caching configured (setup-* cache or actions/cache)
  • Docker layer caching enabled (type=gha)
  • Path filters on push/pull_request to skip unrelated changes
  • Matrix parallelism appropriate (not exhausting runner pool)

Maintainability

  • Reusable workflows used for repeated patterns
  • Composite actions used for repeated step sequences
  • Workflow names and step names are human-readable
  • _ prefix on internal/reusable workflow files
  • Environment protection rules configured for production

Related Skills

  • gha-security-review — Deep security audit of existing workflow files
  • github-actions-templates — Copy-paste ready workflow templates
  • docker-expert — Container build optimization and Dockerfile best practices
  • kubernetes-architect — Deploying to Kubernetes from GitHub Actions
  • gitlab-ci-patterns — GitLab CI/CD equivalent patterns

Limitations

  • Use this skill only when the task clearly matches the scope described above.
  • Do not treat the output as a substitute for environment-specific validation, testing, or expert review.
  • Always test reusable workflows in a feature branch before merging to main.
  • Stop and ask for clarification if required inputs, permissions, safety boundaries, or success criteria are missing.

Bundled with this artifact

2 files

Reference files that ship alongside this artifact. Agents pull these in only when the task needs them.

More on the bench

SKILL0

Zustand Store Ts

Create Zustand stores following established patterns with proper TypeScript types and middleware.

ai-prompt-engineering+3
0
SKILL0

Zoom Automation

Automate Zoom meeting creation, management, recordings, webinars, and participant tracking via Rube MCP (Composio). Always search tools first for current schemas.

ai-prompt-engineering+3
0
SKILL0

Zoho Crm Automation

Automate Zoho CRM tasks via Rube MCP (Composio): create/update records, search contacts, manage leads, and convert leads. Always search tools first for current schemas.

ai-prompt-engineering+3
0