GitHub Actions Reusable Workflows: How We Standardized CI Across 20 Open Source Repos Without Losing Our Minds

1 comment
(GitHub and Open Source) - Maintaining CI across multiple open source repos is a nightmare — until you embrace reusable workflows. Here's how we cut our CI maintenance effort by 90% and standardized testing across 20 repos using GitHub Actions reusable workflows, with real YAML examples and production metrics.

GitHub Actions Reusable Workflows: How We Standardized CI Across 20 Open Source Repos Without Losing Our Minds

I maintain 20+ open source repos. For a while, that meant 20+ separate CI configurations, each with its own quirks, drift, and broken secrets.

It was a slow-motion disaster. A fix applied to one repo’s workflow would take days to propagate to the others — if I remembered at all. Then GitHub shipped reusable workflows in 2022, and everything changed.

Outsourcing Software in 2025: Why Smart CTOs Are Rethinking Offshore Engineering

Outsourcing Software in 2025: Why Smart CTOs Are Rethinking Offshore Engineering

TL;DR: Outsourcing software isn’t dead—it’s just matured. The gold rush of cheap hourly rates is over. What works… ...

Here’s the exact strategy we used to consolidate our CI, the mistakes we made along the way, and the concrete metrics that proved it wasn’t just a “good idea.” It saved us real time and real money.

The Problem: Copy-Paste CI Doesn’t Scale

Before reusable workflows, every repo had its own `.github/workflows/ci.yml`. They looked 80% identical — same linting step, same test matrix, same deployment logic. But that 20% difference? It was killer.

Why You Should Hire Vietnamese Developers: A Strategic Guide for Tech Leaders

Why You Should Hire Vietnamese Developers: A Strategic Guide for Tech Leaders

TL;DR: Vietnam is emerging as a premier offshore destination for software development, offering a unique blend of technical… ...

One repo would pin `actions/checkout@v3`, another would drift to `v2`. A new ESLint rule would get added to the monorepo’s config, but three repos would miss the update. We’d catch these during broken builds, not before.

The numbers were brutal:

Metric Before Reusable Workflows
Hours/week maintaining CI ~12
CI failures due to config drift 8-12/month
Time to add a new repo to the CI pattern ~90 minutes
Workflow files to update for a global change 20+

We needed a better pattern. We found it in reusable workflows.

What Are GitHub Actions Reusable Workflows?

Think of them as composable CI building blocks. A reusable workflow is a `.yml` file stored in a central repo (like `.github`) that other workflows can call with `uses:`. Same syntax as calling an action, but you’re calling an entire workflow — complete with jobs, steps, matrices, and conditionals.

yaml
# .github/workflows/reusable-ci.yml
name: Reusable CI

on:
  workflow_call:
    inputs:
      node-version:
        required: true
        type: string
        default: '20'
      run-lint:
        required: false
        type: boolean
        default: true

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

Then calling it from a consumer repo is a one-liner:

yaml
# consumer-repo/.github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
  ci:
    uses: ./.github/workflows/reusable-ci.yml@main
    with:
      node-version: '20'

That’s it. One file in one repo defines the standard. Twenty repos just point to it.

Where We Put the Reusable Workflows

You’ve got two options for where to store them:

  1. Public repo — A dedicated `.github` repo or any public repo with the workflows in `.github/workflows/`
  2. Local `.github/workflows/` — If you’re inside an organization monorepo, you can reference workflows from the same repo

We chose option 1 — a public `.github` repo under our organization. This lets any repo inside or outside the org reference the canonical CI workflows. Everything stays DRY.

One rule we enforced early: Never override the called workflow’s critical steps in the caller. If a consumer repo needs custom behavior, it can pass inputs. If it needs a fundamentally different flow, it shouldn’t be using this reusable workflow. That’s a separate conversation.

The Real Gain: Centralized Updates

Here’s where the magic happens. When we upgraded from Node 18 to Node 20 across all repos, it was one change in one file. No grep-and-replace across 20 repos. No hoping we didn’t miss one.

When GitHub deprecated `set-output` — remember that headache? — we updated the reusable workflow. Every repo’s CI fixed itself on the next run. No PRs, no rollbacks, no midnight debugging sessions.

After six months, the metrics told the story:

Metric After Reusable Workflows Reduction
Hours/week maintaining CI ~1.5 87.5%
CI failures due to config drift 0-1/month ~90%
Time to add a new repo to the CI pattern 5 minutes 94%
Workflow files to update for a global change 1 95%

Honestly, the biggest win wasn’t the time savings — it was the confidence. Every repo runs the same tests the same way. “Works on my machine” became “works in CI, verified by the same matrix every other repo uses.”

The Pattern We Actually Use in Production

Here’s our production-grade reusable workflow. It supports multiple Node versions, linting, type-checking, testing with coverage, and a conditional deploy step — all configurable via inputs.

yaml
# .github/workflows/reusable-node-ci.yml
name: Node.js CI Pipeline

on:
  workflow_call:
    inputs:
      node-versions:
        description: 'Node.js versions to test against'
        required: false
        type: string
        default: '["18", "20", "22"]'
      run-lint:
        description: 'Run ESLint and Prettier checks'
        required: false
        type: boolean
        default: true
      run-types:
        description: 'Run TypeScript type checking'
        required: false
        type: boolean
        default: true
      run-coverage:
        description: 'Upload coverage to Codecov'
        required: false
        type: boolean
        default: false
      deploy-on-main:
        description: 'Deploy on push to main'
        required: false
        type: boolean
        default: false
    secrets:
      NPM_TOKEN:
        required: false
      CODECOV_TOKEN:
        required: false

jobs:
  lint:
    if: ${{ inputs.run-lint }}
    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 run lint

  types:
    if: ${{ inputs.run-types }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npx tsc --noEmit

  test:
    needs: [lint, types]
    strategy:
      matrix:
        node-version: ${{ fromJson(inputs.node-versions) }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'
      - run: npm ci
      - run: npm test
      - if: ${{ inputs.run-coverage }}
        uses: codecov/codecov-action@v4
        with:
          token: ${{ secrets.CODECOV_TOKEN }}

  deploy:
    if: ${{ inputs.deploy-on-main && github.ref == 'refs/heads/main' }}
    needs: [test]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: echo "Deploying..." # Replace with actual deploy

And here’s how a consumer repo calls it:

yaml
# my-open-source-lib/.github/workflows/ci.yml
name: CI
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  ci:
    uses: your-org/.github/.github/workflows/reusable-node-ci.yml@v1
    with:
      node-versions: '["18", "20", "22"]'
      run-coverage: true
      deploy-on-main: true
    secrets:
      CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

Notice the `@v1` tag. Always pin your reusable workflows to a tag or commit SHA. Using `@main` means a breaking change in the reusable workflow instantly breaks every downstream repo. We tag releases (`v1`, `v1.2`) and update downstream repos deliberately.

But what if you have 20 repos all pointing to `@v1` and you need to upgrade them to `@v2`? That’s the one real pain point we haven’t fully automated. We wrote a small script using the GitHub CLI to open PRs across all repos updating the version tag. It took an afternoon to write and saves us an hour every time we cut a new major version.

bash
#!/bin/bash
# Script to bump reusable workflow version across all repos
REPOS=("repo-a" "repo-b" "repo-c" ...)
OLD_TAG="@v1"
NEW_TAG="@v2"

for repo in "${REPOS[@]}"; do
  gh repo clone "your-org/$repo" "$repo-tmp"
  cd "$repo-tmp"
  sed -i "s|$OLD_TAG|$NEW_TAG|g" .github/workflows/*.yml
  git checkout -b "bump-reusable-workflow-to-$NEW_TAG"
  git add .
  git commit -m "chore: bump reusable workflow to $NEW_TAG"
  git push origin "bump-reusable-workflow-to-$NEW_TAG"
  gh pr create --title "Bump reusable workflow to $NEW_TAG" --body "Update CI to use the latest reusable workflow version." --base main
  cd ..
  rm -rf "$repo-tmp"
done

The Mistakes We Made (So You Don’t Have To)

Mistake #1: Too many inputs. We started with 15+ inputs thinking we needed maximum flexibility. That made the reusable workflow nearly as complex as the individual ones it replaced. We trimmed it to 6 core inputs. Everything else became a “that repo should just use its own workflow” decision.

Mistake #2: No versioning. We used `@main` for the first three months. A breaking change we pushed on a Friday afternoon broke CI across 12 repos before Monday morning. Don’t be us. Tag releases.

Mistake #3: Mixing reusable workflows with composite actions. Composite actions are for encapsulating a sequence of steps. Reusable workflows are for encapsulating an entire job with dependencies and matrices. They’re complementary, not interchangeable. We had a team member try to call a reusable workflow inside a composite action. It doesn’t work that way — composite actions can only use `uses` for actions, not for entire workflows.

When Not to Use Reusable Workflows

Reusable workflows aren’t a silver bullet. Here’s when we *don’t* use them:

  • A repo has fundamentally different CI requirements. If a Python library and a Node.js service need different toolchains, they shouldn’t share a reusable workflow. Create separate reusable workflows for each language family.
  • The workflow is already trivial. A single-step lint check doesn’t need a reusable workflow abstraction. You’re adding indirection for zero gain.
  • You’re calling a reusable workflow from 5+ levels deep. Reusable workflows can call other reusable workflows (up to four levels deep). Just because you can doesn’t mean you should. We limit to two levels max.

The ECOA Angle: How Our Vietnam-Based Team Amplified This

We practice what we preach. At ECOA AI, our Vietnam-based engineering teams maintain dozens of client projects — each with its own repos, toolchains, and CI pipelines. Reusable workflows aren’t a nice-to-have; they’re how a team of 30 engineers across Ho Chi Minh City and Can Tho can efficiently manage CI for a portfolio of 50+ active repos without dedicated DevOps headcount.

When we onboard a new client project, our first step is to evaluate whether its CI can be folded into one of our existing reusable workflow templates. If yes, the client gets battle-tested, standardized CI from day one — not a bespoke workflow that’ll drift over time. If no, we create a new template that future clients in the same vertical can reuse.

This isn’t just about efficiency. It’s about reliability. Standardized CI means standardized quality. Every client gets the same linting rigor, the same test matrix coverage, the same deployment guardrails. That’s the kind of consistency you pay for with a premium offshore team.

What’s Next: Making Reusable Workflows Smarter

We’re currently experimenting with GitHub Actions and AI. The idea: a reusable workflow that analyzes the repo’s codebase on the first run, detects the language and framework, and dynamically selects the right test matrix — all via a single `uses:` line.

But that’s a story for another post. For now, start with the basics. Consolidate your CI. Use reusable workflows. Tag your releases. Your future self — and your 20 repos — will thank you.

Frequently Asked Questions

Q: Can reusable workflows call other reusable workflows?

Yes, up to four levels deep. This is useful for composing workflows — for example, a “Node.js CI” workflow that calls a “Lint and Format” reusable workflow and a “Security Scan” reusable workflow as separate jobs. Just be careful not to create dependency chains that are hard to debug.

Q: How do I pass secrets to a reusable workflow?

Secrets must be explicitly declared in the `workflow_call` trigger using `secrets:` and passed from the caller using `secrets: inherit` or specifying individual secrets. Unlike regular actions, reusable workflows don’t automatically inherit secrets from the caller’s environment. This is a security feature — you must explicitly opt in.

Q: What’s the difference between a composite action and a reusable workflow?

Composite actions group multiple `run` or `uses` steps into a single step that can be used in any job’s `steps:`. Reusable workflows encapsulate entire jobs — complete with dependencies, matrices, and environment configurations. Use composite actions for reusable step sequences (like “install dependencies, run tests, upload coverage”) and reusable workflows for reusable CI pipelines with multiple jobs.

Q: Can I use reusable workflows across different GitHub organizations?

Yes. Reusable workflows in public repositories can be called from any repo on any organization. For private repos, you’ll need to configure access policies — either make the workflow repo accessible to all repos in the same org, or use fine-grained access tokens for cross-org references.

Related reading: Why Vietnam Outsourcing Is the Smartest Move for Your Tech Stack in 2025

Related reading: Outsourcing Software in 2025: Why Vietnam Is the Smartest Bet for Your Engineering Team

Leave a Comment

Your email address will not be published. Required fields are marked *

Ready to Build with AI-Powered Developers?

Hire Vietnamese engineers augmented by ECOA AI Platform + Claude Code. 5x faster, 40% cheaper.