Version Control

GitHub Actions Deep Dive: Beyond Basic CI

Advanced GitHub Actions patterns including matrix builds, reusable workflows, custom actions, secrets management, artifacts, caching, and deployment automation.

GitHub Actions Deep Dive: Beyond Basic CI

Most GitHub Actions workflows stop at "run tests on push." That is barely scratching the surface. Actions can build and deploy to multiple environments, run matrix tests across Node versions and operating systems, share workflows across repositories, manage secrets per environment, cache dependencies for faster builds, and create custom actions that encapsulate your team's patterns.

I run Actions on every project. The workflows in this guide are production patterns — not toy examples, but the configurations I use to ship software reliably.

Prerequisites

  • A GitHub repository
  • Basic YAML knowledge
  • Understanding of CI/CD concepts
  • Node.js project (for examples)

Workflow Structure

# .github/workflows/ci.yml
name: CI Pipeline

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

permissions:
  contents: read

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

Advanced Triggers

Filtered Triggers

on:
  push:
    branches: [main, 'release/**']
    paths:
      - 'src/**'
      - 'tests/**'
      - 'package.json'
    paths-ignore:
      - '**.md'
      - 'docs/**'

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

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

  workflow_dispatch:
    inputs:
      environment:
        description: 'Deploy target'
        required: true
        default: 'staging'
        type: choice
        options:
          - staging
          - production
      dry_run:
        description: 'Dry run mode'
        type: boolean
        default: false

  release:
    types: [published]

Conditional Execution

jobs:
  deploy:
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    steps:
      - run: echo "Deploying to production"

  skip-draft:
    if: "!github.event.pull_request.draft"
    runs-on: ubuntu-latest
    steps:
      - run: echo "Only runs on non-draft PRs"

Matrix Builds

Test across multiple configurations simultaneously:

jobs:
  test:
    strategy:
      matrix:
        node-version: [18, 20, 22]
        os: [ubuntu-latest, windows-latest, macos-latest]
      fail-fast: false  # Continue other matrix jobs if one fails

    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'
      - run: npm ci
      - run: npm test

Matrix Includes and Excludes

strategy:
  matrix:
    node-version: [18, 20, 22]
    os: [ubuntu-latest, windows-latest]
    include:
      # Add extra config for specific combinations
      - node-version: 22
        os: ubuntu-latest
        coverage: true
    exclude:
      # Skip Node 18 on Windows
      - node-version: 18
        os: windows-latest

steps:
  - run: npm test
  - run: npm run coverage
    if: matrix.coverage == true

Caching

npm Cache

- uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: 'npm'  # Built-in npm caching

Custom Caching

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

- run: npm ci
  if: steps.npm-cache.outputs.cache-hit != 'true'

Multi-Path Caching

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

Artifacts

Uploading Build Artifacts

- run: npm run build
- uses: actions/upload-artifact@v4
  with:
    name: build-output
    path: dist/
    retention-days: 7

Downloading in Another Job

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/

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: dist
          path: dist/
      - run: ls -la dist/
      - run: echo "Deploy dist/ to server"

Test Reports

- run: npm test -- --reporter=junit --outputFile=test-results.xml
- uses: actions/upload-artifact@v4
  if: always()  # Upload even if tests fail
  with:
    name: test-results
    path: test-results.xml

Secrets and Environment Variables

Using Secrets

steps:
  - run: npm run deploy
    env:
      API_KEY: ${{ secrets.API_KEY }}
      DATABASE_URL: ${{ secrets.DATABASE_URL }}

Environments

jobs:
  deploy-staging:
    environment: staging
    runs-on: ubuntu-latest
    steps:
      - run: echo "Deploying to ${{ vars.DEPLOY_URL }}"
        env:
          API_KEY: ${{ secrets.API_KEY }}  # Staging-specific secret

  deploy-production:
    needs: deploy-staging
    environment:
      name: production
      url: https://myapp.com
    runs-on: ubuntu-latest
    steps:
      - run: echo "Deploying to production"
        env:
          API_KEY: ${{ secrets.API_KEY }}  # Production-specific secret

Environments support:

  • Environment-specific secrets and variables
  • Required reviewers before deployment
  • Wait timers
  • Branch restrictions

OIDC for Cloud Authentication

permissions:
  id-token: write
  contents: read

steps:
  - uses: aws-actions/configure-aws-credentials@v4
    with:
      role-to-assume: arn:aws:iam::123456789:role/github-actions
      aws-region: us-east-1

  # No AWS keys needed — uses OIDC federation
  - run: aws s3 sync dist/ s3://my-bucket/

Job Dependencies and Outputs

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

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

  build:
    needs: [lint, test]  # Wait for both to pass
    runs-on: ubuntu-latest
    outputs:
      version: ${{ steps.version.outputs.value }}
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm run build
      - id: version
        run: echo "value=$(node -p 'require(\"./package.json\").version')" >> "$GITHUB_OUTPUT"

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

Reusable Workflows

Creating a Reusable Workflow

# .github/workflows/reusable-test.yml
name: Reusable Test Workflow

on:
  workflow_call:
    inputs:
      node-version:
        required: false
        type: string
        default: '20'
      working-directory:
        required: false
        type: string
        default: '.'
    secrets:
      npm-token:
        required: false

jobs:
  test:
    runs-on: ubuntu-latest
    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'
          cache-dependency-path: ${{ inputs.working-directory }}/package-lock.json
      - run: npm ci
        env:
          NODE_AUTH_TOKEN: ${{ secrets.npm-token }}
      - run: npm test
      - run: npm run lint

Using a Reusable Workflow

# .github/workflows/ci.yml
name: CI

on: [push, pull_request]

jobs:
  test-api:
    uses: ./.github/workflows/reusable-test.yml
    with:
      working-directory: packages/api
      node-version: '20'

  test-web:
    uses: ./.github/workflows/reusable-test.yml
    with:
      working-directory: packages/web
      node-version: '20'

  # Use from another repository
  shared-checks:
    uses: myorg/shared-workflows/.github/workflows/node-checks.yml@main
    with:
      node-version: '20'
    secrets: inherit  # Pass all secrets

Custom Actions

Composite Action

# .github/actions/setup-node-project/action.yml
name: 'Setup Node Project'
description: 'Install Node.js, restore cache, install dependencies'

inputs:
  node-version:
    description: 'Node.js version'
    required: false
    default: '20'
  working-directory:
    description: 'Working directory'
    required: false
    default: '.'

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

    - uses: actions/cache@v4
      id: cache
      with:
        path: ${{ inputs.working-directory }}/node_modules
        key: ${{ runner.os }}-node-${{ inputs.node-version }}-${{ hashFiles(format('{0}/package-lock.json', inputs.working-directory)) }}

    - run: npm ci
      shell: bash
      working-directory: ${{ inputs.working-directory }}
      if: steps.cache.outputs.cache-hit != 'true'

Usage:

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

JavaScript Action

// .github/actions/check-package-version/index.js
var core = require("@actions/core");
var fs = require("fs");

function run() {
  try {
    var packagePath = core.getInput("package-path") || "package.json";
    var packageJson = JSON.parse(fs.readFileSync(packagePath, "utf-8"));
    var version = packageJson.version;

    core.info("Package version: " + version);
    core.setOutput("version", version);

    var parts = version.split(".");
    core.setOutput("major", parts[0]);
    core.setOutput("minor", parts[1]);
    core.setOutput("patch", parts[2]);

    // Check if version was bumped
    var minVersion = core.getInput("min-version");
    if (minVersion) {
      var current = version.split(".").map(Number);
      var minimum = minVersion.split(".").map(Number);

      for (var i = 0; i < 3; i++) {
        if (current[i] > minimum[i]) break;
        if (current[i] < minimum[i]) {
          core.setFailed("Version " + version + " is below minimum " + minVersion);
          return;
        }
      }
    }
  } catch (error) {
    core.setFailed(error.message);
  }
}

run();
# .github/actions/check-package-version/action.yml
name: 'Check Package Version'
description: 'Read and validate package.json version'
inputs:
  package-path:
    description: 'Path to package.json'
    default: 'package.json'
  min-version:
    description: 'Minimum allowed version'
    required: false
outputs:
  version:
    description: 'Full version string'
  major:
    description: 'Major version number'
  minor:
    description: 'Minor version number'
  patch:
    description: 'Patch version number'
runs:
  using: 'node20'
  main: 'index.js'

Complete Working Example: Production CI/CD Pipeline

# .github/workflows/pipeline.yml
name: CI/CD Pipeline

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  release:
    types: [published]

permissions:
  contents: read
  packages: write

env:
  NODE_VERSION: '20'

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'
      - run: npm ci
      - run: npm run lint

  test:
    runs-on: ubuntu-latest
    strategy:
      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
      - uses: actions/upload-artifact@v4
        if: matrix.node-version == 20 && always()
        with:
          name: test-results
          path: coverage/

  build:
    needs: [lint, test]
    runs-on: ubuntu-latest
    outputs:
      version: ${{ steps.version.outputs.value }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'
      - run: npm ci
      - run: npm run build
      - id: version
        run: echo "value=$(node -p 'require(\"./package.json\").version')" >> "$GITHUB_OUTPUT"
      - uses: actions/upload-artifact@v4
        with:
          name: build
          path: dist/

  deploy-staging:
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    needs: build
    runs-on: ubuntu-latest
    environment:
      name: staging
      url: https://staging.myapp.com
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: build
          path: dist/
      - run: echo "Deploying v${{ needs.build.outputs.version }} to staging"
      - run: echo "Deploy commands here"
        env:
          DEPLOY_KEY: ${{ secrets.STAGING_DEPLOY_KEY }}

  deploy-production:
    if: github.event_name == 'release'
    needs: build
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://myapp.com
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: build
          path: dist/
      - run: echo "Deploying v${{ needs.build.outputs.version }} to production"
        env:
          DEPLOY_KEY: ${{ secrets.PRODUCTION_DEPLOY_KEY }}

Common Issues and Troubleshooting

Workflow does not trigger on expected event

The trigger conditions do not match or the workflow file has YAML errors:

Fix: Check paths filters — they are case-sensitive. Verify branch names match exactly. Check the Actions tab for workflow parse errors. Use workflow_dispatch to test triggers manually.

Cache not being restored

The cache key does not match any existing cache:

Fix: Check that hashFiles() targets the correct lock file path. Use restore-keys with progressively broader patterns as fallbacks. Cache is scoped to the branch — PR caches can restore from the default branch but not other PRs.

Secret is empty in the workflow

Secrets are not available in forked repository pull requests (security feature):

Fix: For pull requests from forks, secrets are intentionally unavailable. Use pull_request_target trigger (with caution) or run sensitive steps only on push to main. Check that the secret name matches exactly (case-sensitive).

Job times out or runs too long

Default timeout is 360 minutes. Complex builds or hanging processes consume minutes:

Fix: Set explicit timeouts: timeout-minutes: 15 on jobs or steps. Kill background processes in your scripts. Use --forceExit flags on test runners like Jest.

Best Practices

  • Cache aggressively. npm ci with cached node_modules takes seconds instead of minutes. Use actions/cache or the built-in cache option in actions/setup-node.
  • Use matrix builds for compatibility testing. Test across Node versions and operating systems in parallel. Use fail-fast: false to see all failures, not just the first.
  • Pin action versions to SHA hashes. uses: actions/checkout@abc1234 is more secure than @v4. Tags can be moved, but SHA hashes are immutable.
  • Separate build and deploy into different jobs. Build artifacts once and deploy to multiple environments. This saves time and ensures identical deployments.
  • Use environments for deployment protection. Required reviewers, wait timers, and branch restrictions prevent accidental production deployments.
  • Create reusable workflows for shared patterns. If multiple repos use the same CI steps, extract them into a shared workflow repository. Changes propagate automatically.
  • Set explicit permissions. Use permissions: contents: read instead of the default write access. Follow the principle of least privilege.

References

Powered by Contentful