GitHub Actions: Workflow Design Patterns

Your GitHub Actions workflows don't have to be unmaintainable YAML spaghetti. Here are the design patterns, refactoring techniques, and organizational strategies that keep workflows clean as they grow.

My .github/workflows folder used to be a graveyard. Dozens of YAML files, most of them copy-pasted from Stack Overflow, half of them broken, none of them documented. Every time I needed to change something, I’d spend twenty minutes figuring out which workflow did what—and another twenty untangling the dependencies I’d forgotten about.

Sound familiar?

This is Part 2 of my GitHub Actions series. The first post covered what’s possible. This one is about keeping it all from collapsing under its own weight. Because the hard part of GitHub Actions isn’t writing workflows—it’s writing workflows that you can still understand six months from now.

The Spaghetti Problem

Let me show you what workflow sprawl looks like. Here’s an example I stumbled upon on GitHub (names changed to protect the guilty):

.github/workflows/
├── build.yml
├── build-and-test.yml
├── build-prod.yml
├── ci.yml
├── ci-main.yml
├── deploy.yml
├── deploy-staging.yml
├── deploy-prod.yml
├── deploy-prod-manual.yml
├── lint.yml
├── lint-and-test.yml
├── notify.yml
├── pr-check.yml
├── release.yml
├── release-v2.yml
├── test.yml
└── test-integration.yml

Seventeen workflows. I counted. At least four of them were duplicates with slightly different triggers. Two were completely broken and hadn’t run successfully in months. One was named release-v2.yml which tells you exactly how confident someone was about deleting release.yml.

The problem isn’t that workflows are inherently hard to maintain. The problem is that YAML makes it dangerously easy to copy-paste your way into a mess. There’s no compiler to catch your mistakes, no IDE that truly understands workflow syntax, and no tests to verify your workflows actually do what you think they do.

But there are patterns that help. Let’s look at them.

When to Split vs. Combine

The first question everyone asks: should I have one workflow or many?

Split workflows when:

  • They have genuinely different triggers (push vs. schedule vs. manual)
  • They operate on different parts of the codebase (frontend vs. backend)
  • Different teams own different workflows
  • One workflow failing shouldn’t block others

Combine workflows when:

  • Steps naturally flow together (build, test, deploy)
  • You need to pass data between jobs
  • You want atomic success/failure (everything works or nothing ships)
  • The trigger conditions are identical

A common mistake is splitting too early. You start with build.yml and test.yml as separate workflows, then realize you need to share artifacts between them, so you add artifact upload/download steps, then you realize the test workflow sometimes runs before the build is done, so you add a workflow_run trigger, and suddenly you have two workflows doing the job of one—badly.

Here’s my rule of thumb: start with one workflow, split when it hurts.

# Good: One workflow with multiple jobs
name: CI

on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm run build
      - uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/

  test:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/download-artifact@v4
        with:
          name: dist
      - run: npm test

  lint:
    runs-on: ubuntu-latest  # No 'needs', runs in parallel with build
    steps:
      - uses: actions/checkout@v4
      - run: npm run lint

The needs keyword is your friend. It defines dependencies between jobs, and jobs without dependencies run in parallel automatically. You get clear sequencing without separate workflow files.

Reusable Workflows: The Game Changer

If you only learn one thing from this post, make it reusable workflows. They’re the closest thing YAML has to functions, and they solve the copy-paste problem better than anything else.

A reusable workflow lives in one place and gets called from others:

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

on:
  workflow_call:
    inputs:
      node-version:
        required: false
        type: string
        default: '20'
      coverage-threshold:
        required: false
        type: number
        default: 80
    secrets:
      npm-token:
        required: false

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
      - run: npm ci
        env:
          NPM_TOKEN: ${{ secrets.npm-token }}
      - run: npm test -- --coverage
      - name: Check coverage threshold
        run: |
          coverage=$(cat coverage/coverage-summary.json | jq '.total.lines.pct')
          if (( $(echo "$coverage < ${{ inputs.coverage-threshold }}" | bc -l) )); then
            echo "Coverage $coverage% is below threshold ${{ inputs.coverage-threshold }}%"
            exit 1
          fi

And calling it from another workflow:

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

on: [push, pull_request]

jobs:
  test:
    uses: ./.github/workflows/reusable-test.yml
    with:
      node-version: '22'
      coverage-threshold: 85
    secrets:
      npm-token: ${{ secrets.NPM_TOKEN }}

The magic happens when you share these across repositories. Put your reusable workflows in a dedicated repo (I call mine workflow-templates) and reference them from anywhere:

jobs:
  test:
    uses: myorg/workflow-templates/.github/workflows/node-test.yml@main
    with:
      node-version: '20'

Now when you need to update your testing approach—new Node version, different coverage tool, additional security checks—you change it once and every repo benefits. That’s the kind of leverage that makes maintaining dozens of projects actually feasible.

Reusable Workflow Gotchas

A few things that’ll bite you if you’re not careful:

  1. Secrets don’t inherit automatically. You have to explicitly pass them down. This is a security feature, but it’s easy to forget.

  2. The @ref matters. Using @main means you always get the latest version, which is great for iteration but risky for stability. Consider using tags (@v1) for production workflows.

  3. Nesting has limits. A reusable workflow can call another reusable workflow, but only one level deep. GitHub prevents infinite recursion.

  4. Matrix jobs in the caller don’t work how you expect. If you try to call a reusable workflow from inside a matrix, each matrix combination creates a separate workflow run. Sometimes that’s what you want; often it isn’t.

Composite Actions: DRY for Steps

Reusable workflows are great for entire jobs. But what if you just want to reuse a few steps?

That’s where composite actions come in. They’re like macros for workflow steps.

# .github/actions/setup-project/action.yml
name: 'Setup Project'
description: 'Install dependencies and configure environment'

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

runs:
  using: 'composite'
  steps:
    - name: Setup Node.js
      uses: actions/setup-node@v4
      with:
        node-version: ${{ inputs.node-version }}
        cache: 'npm'

    - name: Install dependencies
      shell: bash
      run: npm ci

    - name: Verify installation
      shell: bash
      run: npm ls --depth=0

Using it:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ./.github/actions/setup-project
        with:
          node-version: '22'
      - run: npm run build

The difference from reusable workflows is subtle but important:

  • Reusable workflows define entire jobs (can specify runs-on, have their own job context)
  • Composite actions define step sequences (run within an existing job, share its context)

Use composite actions when you find yourself copying the same three or four steps across multiple jobs. Setup sequences are the classic example—Node setup, caching, dependency installation. Package that up once, use it everywhere.

Passing Data Between Jobs

Jobs run in isolation. They don’t share filesystems, environment variables, or state. This is annoying until you understand the patterns for working around it.

Artifacts for Files

Need to pass built files from one job to another? Artifacts.

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm run build
      - uses: actions/upload-artifact@v4
        with:
          name: build-output
          path: dist/
          retention-days: 1  # Don't hoard artifacts

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

Keep artifact retention short for intermediate build outputs. There’s no reason to keep CI artifacts for 90 days (the default). I usually set retention-days: 1 for anything that’s just passing between jobs.

Outputs for Values

For simple values—version strings, commit hashes, computed flags—use job outputs.

jobs:
  prepare:
    runs-on: ubuntu-latest
    outputs:
      version: ${{ steps.version.outputs.value }}
      should-deploy: ${{ steps.check.outputs.deploy }}
    steps:
      - uses: actions/checkout@v4

      - name: Determine version
        id: version
        run: echo "value=$(cat VERSION)" >> $GITHUB_OUTPUT

      - name: Check if deployment needed
        id: check
        run: |
          if git diff --name-only HEAD~1 | grep -q "^src/"; then
            echo "deploy=true" >> $GITHUB_OUTPUT
          else
            echo "deploy=false" >> $GITHUB_OUTPUT
          fi

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

  deploy:
    needs: [prepare, build]
    if: needs.prepare.outputs.should-deploy == 'true'
    runs-on: ubuntu-latest
    steps:
      - run: echo "Deploying!"

The $GITHUB_OUTPUT file is how you set outputs from a step. The syntax is name=value, one per line. The job then exposes these via the outputs key, and downstream jobs access them through needs.<job>.outputs.<name>.

It’s verbose, I know. But it’s explicit, and explicit is good when you’re debugging a failed workflow at 2 AM.

Conditional Execution

Not every job needs to run every time. GitHub Actions has robust conditional execution, and using it well keeps your workflows fast and focused.

Basic Conditions

jobs:
  deploy:
    if: github.ref == 'refs/heads/main'
    # ...

  notify-failure:
    if: failure()  # Only runs if a previous job failed
    # ...

  release:
    if: startsWith(github.ref, 'refs/tags/v')
    # ...

Path Filtering

For monorepos or projects with distinct components, path filters are essential:

on:
  push:
    paths:
      - 'src/**'
      - 'package.json'
      - '!src/**/*.test.js'  # Exclude test files

This workflow only triggers when files in src/ or package.json change—but not when only test files change. The ! prefix excludes paths.

Path-Filtered Jobs

Sometimes you want the workflow to trigger, but only run certain jobs based on what changed:

jobs:
  changes:
    runs-on: ubuntu-latest
    outputs:
      frontend: ${{ steps.filter.outputs.frontend }}
      backend: ${{ steps.filter.outputs.backend }}
    steps:
      - uses: actions/checkout@v4
      - uses: dorny/paths-filter@v3
        id: filter
        with:
          filters: |
            frontend:
              - 'frontend/**'
            backend:
              - 'backend/**'
              - 'api/**'

  build-frontend:
    needs: changes
    if: needs.changes.outputs.frontend == 'true'
    runs-on: ubuntu-latest
    steps:
      - run: echo "Building frontend..."

  build-backend:
    needs: changes
    if: needs.changes.outputs.backend == 'true'
    runs-on: ubuntu-latest
    steps:
      - run: echo "Building backend..."

This pattern is a lifesaver in monorepos. Why rebuild the entire frontend when you only changed a backend API endpoint?

Matrix Strategies Done Right

Matrices let you run the same job across multiple configurations. They’re powerful but easy to misuse.

The Basics

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

This creates 9 jobs (3 OS variants times 3 Node versions). Each combination runs independently and in parallel (within your concurrency limits).

Excluding Combinations

Sometimes certain combinations don’t make sense:

strategy:
  matrix:
    os: [ubuntu-latest, macos-latest, windows-latest]
    node: [18, 20, 22]
    exclude:
      - os: windows-latest
        node: 18  # Don't test Node 18 on Windows

Including Extra Configurations

Add specific combinations that don’t fit the cross-product pattern:

strategy:
  matrix:
    os: [ubuntu-latest, macos-latest]
    node: [20]
    include:
      - os: ubuntu-latest
        node: 22
        experimental: true  # Custom variable

The include entries add to the matrix. You can also attach custom variables that steps can reference.

Fail-Fast Behavior

By default, if one matrix job fails, GitHub cancels all other running jobs. Sometimes that’s what you want (fail quickly), sometimes not (you want all results even if one fails):

strategy:
  fail-fast: false  # Let all jobs complete
  matrix:
    os: [ubuntu-latest, macos-latest, windows-latest]

Don’t Go Overboard

I’ve seen matrices with 50+ combinations. That’s almost always a mistake. Each job has overhead—spinning up a runner, checking out code, setting up the environment. If you’re testing 5 Node versions times 3 operating systems times 2 package managers, ask yourself if you really need all 30 combinations.

Test the full matrix on nightly builds. For PR checks, test a representative subset.

Naming Conventions and Folder Structure

Good naming is underrated. Here’s what I’ve landed on after years of trial and error:

Workflow Files

.github/workflows/
├── ci.yml                    # Main CI pipeline
├── release.yml               # Release automation
├── scheduled-maintenance.yml # Cron jobs
├── deploy-staging.yml        # Environment-specific deploys
├── deploy-production.yml
└── _reusable-test.yml        # Underscore prefix for reusable workflows

The underscore prefix for reusable workflows is a personal convention—it sorts them to the top and makes clear they’re not meant to run directly.

Job and Step Names

jobs:
  build-and-test:  # Lowercase, hyphenated
    name: Build and Test  # Human-readable name for UI
    steps:
      - name: Checkout code  # Every step gets a name
        uses: actions/checkout@v4

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

Always add name fields. The GitHub Actions UI is much easier to navigate when every step is labeled. “Run npm test” is clearer than “Run” followed by the command.

Composite Actions Structure

.github/
├── actions/
│   ├── setup-node/
│   │   └── action.yml
│   ├── deploy/
│   │   └── action.yml
│   └── notify/
│       └── action.yml
└── workflows/
    └── ci.yml

Each composite action gets its own directory with an action.yml file. This keeps things organized and allows for adding supporting files (scripts, templates) alongside the action definition.

workflow_call vs. workflow_dispatch

These two triggers look similar but serve different purposes.

workflow_call

Makes a workflow reusable—callable from other workflows:

on:
  workflow_call:
    inputs:
      environment:
        type: string
        required: true

The caller workflow uses uses: to invoke it, just like an action.

workflow_dispatch

Makes a workflow manually triggerable from the GitHub UI or API:

on:
  workflow_dispatch:
    inputs:
      environment:
        type: choice
        description: 'Deployment environment'
        required: true
        options:
          - staging
          - production
      dry-run:
        type: boolean
        description: 'Perform a dry run'
        default: true

The UI renders this as a form with dropdowns, checkboxes, and text fields. It’s great for manual deployments, one-off tasks, or anything that needs human judgment before running.

You can use both on the same workflow:

on:
  workflow_call:
    inputs:
      environment:
        type: string
        required: true
  workflow_dispatch:
    inputs:
      environment:
        type: choice
        required: true
        options:
          - staging
          - production

Now the same workflow can be called programmatically from other workflows OR triggered manually from the UI. The input is named identically, so the job logic doesn’t need to care how it was invoked.

Real-World Example: A Release Workflow

Let’s put it all together. Here’s a release workflow that coordinates multiple jobs:

name: Release

on:
  push:
    tags:
      - 'v*'

jobs:
  validate:
    runs-on: ubuntu-latest
    outputs:
      version: ${{ steps.version.outputs.value }}
    steps:
      - uses: actions/checkout@v4

      - name: Extract version from tag
        id: version
        run: echo "value=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT

      - name: Validate version format
        run: |
          if ! [[ "${{ steps.version.outputs.value }}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
            echo "Invalid version format"
            exit 1
          fi

  test:
    needs: validate
    uses: ./.github/workflows/_reusable-test.yml
    with:
      coverage-threshold: 90

  build:
    needs: [validate, test]
    strategy:
      matrix:
        include:
          - os: ubuntu-latest
            artifact: linux
          - os: macos-latest
            artifact: macos
          - os: windows-latest
            artifact: windows
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4

      - name: Build
        run: npm run build

      - uses: actions/upload-artifact@v4
        with:
          name: build-${{ matrix.artifact }}
          path: dist/

  publish:
    needs: [validate, build]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/download-artifact@v4
        with:
          path: artifacts/

      - name: Create GitHub Release
        uses: softprops/action-gh-release@v2
        with:
          files: artifacts/**/*
          generate_release_notes: true

      - name: Publish to npm
        run: npm publish
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

  notify:
    needs: [validate, publish]
    if: always()
    runs-on: ubuntu-latest
    steps:
      - name: Notify success
        if: needs.publish.result == 'success'
        run: |
          curl -X POST "${{ secrets.SLACK_WEBHOOK }}" \
            -H 'Content-Type: application/json' \
            -d '{"text":"Released v${{ needs.validate.outputs.version }}"}'

      - name: Notify failure
        if: needs.publish.result == 'failure'
        run: |
          curl -X POST "${{ secrets.SLACK_WEBHOOK }}" \
            -H 'Content-Type: application/json' \
            -d '{"text":"Release v${{ needs.validate.outputs.version }} failed!"}'

This workflow:

  1. Validates the version tag format
  2. Runs the full test suite via a reusable workflow
  3. Builds on three platforms in parallel
  4. Creates a GitHub release and publishes to npm
  5. Sends Slack notifications on success or failure

Each job has a clear responsibility, dependencies are explicit, and the whole thing reads top-to-bottom.

Before/After: Refactoring a Messy Workflow

Let’s look at a real refactoring example. Here’s a workflow I found on GitHub (simplified):

Before: The Mess

name: CI
on: [push, pull_request]

jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npm run lint
      - run: npm test
      - run: npm run build
      - if: github.ref == 'refs/heads/main'
        run: npm run deploy:staging
      - if: github.ref == 'refs/heads/main'
        run: npm run deploy:production
      - if: failure()
        run: curl -X POST ${{ secrets.SLACK_WEBHOOK }} -d '{"text":"CI failed"}'

Problems:

  • Everything runs sequentially (lint doesn’t need to wait for anything)
  • Deployment happens in the same job as testing (no approval, no separation)
  • Staging and production deploy unconditionally on main (no gates)
  • Single runner does everything (slow, no parallelism)
  • No artifacts, so you can’t debug failed builds

After: Clean Patterns

name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ./.github/actions/setup-node
      - run: npm run lint

  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ./.github/actions/setup-node
      - run: npm test
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: test-results
          path: test-results/

  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ./.github/actions/setup-node
      - run: npm run build
      - uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/
# .github/workflows/deploy.yml
name: Deploy

on:
  workflow_run:
    workflows: [CI]
    types: [completed]
    branches: [main]

jobs:
  deploy-staging:
    if: github.event.workflow_run.conclusion == 'success'
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - uses: actions/checkout@v4
      - uses: actions/download-artifact@v4
        with:
          name: dist
          github-token: ${{ secrets.GITHUB_TOKEN }}
          run-id: ${{ github.event.workflow_run.id }}
      - run: npm run deploy:staging

  deploy-production:
    needs: deploy-staging
    runs-on: ubuntu-latest
    environment: production  # Requires approval
    steps:
      - uses: actions/checkout@v4
      - uses: actions/download-artifact@v4
        with:
          name: dist
          github-token: ${{ secrets.GITHUB_TOKEN }}
          run-id: ${{ github.event.workflow_run.id }}
      - run: npm run deploy:production

The refactored version:

  • Splits CI and deployment into separate workflows
  • Runs lint, test, and build in parallel
  • Uses GitHub environments for deployment approvals
  • Preserves artifacts for debugging
  • Only deploys after CI succeeds (via workflow_run)
  • Uses a composite action for repetitive setup

More files? Yes. More maintainable? Absolutely.

Handling Workflow Sprawl in Large Projects

As projects grow, workflow management becomes its own discipline. Here’s how to stay sane:

Create a Workflow Documentation File

Keep a WORKFLOWS.md in your repo that explains what each workflow does, when it runs, and who owns it:

# Workflows

## ci.yml
Runs on every push and PR. Lints, tests, and builds.

## release.yml
Triggered by version tags. Builds artifacts and publishes.

## scheduled-cleanup.yml
Daily cron job. Cleans up old artifacts and stale branches.
Owner: Platform team

Regular Cleanup

Schedule time to audit your workflows. Delete ones that aren’t running. Consolidate duplicates. Update pinned action versions.

Use Branch Protection

Require status checks to pass before merging. This forces you to keep workflows working—broken workflows block PRs, which creates healthy pressure to fix them quickly.

Monitor Workflow Costs

GitHub Actions minutes aren’t free (for private repos). The usage page shows which workflows consume the most time. That 2-hour workflow running on every push? Probably worth optimizing.

Wrapping Up

Good workflow design is invisible. When it’s working, nobody notices—code gets tested, releases ship, notifications arrive. It’s only when things break that the structure (or lack thereof) becomes apparent.

The patterns in this post—reusable workflows, composite actions, clear job dependencies, thoughtful conditionals—aren’t complicated individually. The trick is applying them consistently before your workflows become unmaintainable, not after.

Start with the mess you have. Pick one workflow that’s causing pain. Refactor it using these patterns. Then do the next one. Eventually, you’ll have a .github/workflows folder you’re actually proud of.

Or at least one that doesn’t make you wince when you open it.

Next up in this series: security best practices. Because all the clean patterns in the world won’t help if your secrets are leaking into logs.