Refactor CI for Reusability

Refactor CI workflows for reusability — CI/CD foundations. Learn to identify repeated pipeline steps, extract them into reusable actions or templates, and apply best practices for maintainability.

Focus: refactor ci workflows for reusability

Sponsored

Your CI pipeline started small — a few jobs, a couple of repositories, nothing you couldn't handle. But as your organization grows, so does your workflow code. You find yourself copying and pasting the same checkout, setup-python, and cache steps into every single workflow file. When a dependency bumps or a security policy changes, you edit the same xml config in a dozen places, praying you didn't miss one. This duplication isn't just annoying — it's a breeding ground for drift, bugs, and wasted time. If this sounds familiar, you're ready to refactor your CI workflows for reusability, and this lesson will show you exactly how.

The problem this lesson solves

Copy-pasting pipeline steps across repositories is like recycling a flimsy paper towel: it works for a moment but falls apart under stress. Each duplicated block is a place where configuration can drift — one repository runs an old version of a linter, another uses a different cache key, and suddenly your CI results are inconsistent across the team. More critically, every duplicated step is a maintenance trap. A security fix or a performance tweak must be applied to every copy manually, and the more copies you have, the more likely you are to miss one.

Duplication also slows down onboarding. New developers see bloated, repetitive workflow files and struggle to identify what actually matters for their service. They might even copy the existing bad patterns into new repositories, propagating the problem further. The pain is real, and the cost compounds as your pipeline grows.

The maintenance nightmare

Imagine a scenario I've seen far too often: a team has 15 microservices, each with its own GitHub Actions workflow. One Monday, a global Node.js security patch drops. The team lead assigns five developers to update all 15 files. They work for two hours, and somehow two repositories still run the vulnerable version — because someone missed a pull request that had already created a duplicate workflow in a feature branch. This is not a hypothetical. It's what happens when you treat CI workflows as one-off scripts rather than as first-class code.

The refactoring opportunity

Refactoring CI workflows for reusability is the process of extracting repeated logic into a single, versioned, and testable component — typically a composite action or a reusable workflow in GitHub Actions. The goal is to stop duplicating and start composing. Instead of writing the same deployment steps in every repo, you write them once, publish them in a central location, and call them from anywhere. The result is a pipeline that's easier to maintain, audit, and evolve.

Core concept / mental model

The mental model for refactoring CI is best captured by the DRY principle (Don't Repeat Yourself). In software development, DRY means that every piece of knowledge should have a single, unambiguous representation. Your CI workflows are code, and they deserve the same rigor as your application code.

Think of your workflow files as orchestration scripts — they should coordinate steps, not implement them. When a workflow file is cluttered with dozens of inline steps, it's hard to see the forest for the trees. By extracting steps into reusable components, you create a separation of concerns:

  • The workflow answers what should run and when.
  • The reusable action or workflow answers how to run it.

You can visualize this with a simple mental diagram. Without reusability, you have a web of duplicated nodes, each connected to different repositories. With reusability, you have a central hub — a single source of truth — that everyone points to.

Definitions you'll need

Before we dive deeper, let's define the two key building blocks in GitHub Actions:

  • Composite action: A self-contained unit that bundles multiple steps into a single action. You can call it with inputs and use its outputs. It runs inline on the runner.
  • Reusable workflow: A full workflow file (.github/workflows) marked with workflow_call that can be invoked from another workflow. It supports inputs, secrets, and outputs.

We'll discuss when to choose which later, but for now, remember that composite actions are for reusable steps, and reusable workflows are for reusable pipelines.

How it works step by step

Refactoring CI workflows for reusability follows a clear, repeatable process. Think of it as a mini-methodology:

  1. Audit your existing workflows. Go through every workflow file in your repositories and identify repeated groups of steps. Highlight any block that appears more than twice — that's a refactor candidate.
  2. Choose the right abstraction. If the repeated block is a step sequence (like setting up Python, caching, and installing dependencies), create a composite action. If the repeated block is a complete job (like a full test suite with matrix strategy), create a reusable workflow.
  3. Extract the logic into a new file. For a composite action, create a action.yml in a dedicated repository or a subdirectory. For a reusable workflow, create a new .yml file with on: workflow_call.
  4. Define inputs and outputs. Make your new component flexible by exposing variables that change between contexts — like Python version, root directory, or flags.
  5. Replace the duplicated blocks with a single call to your new component, passing the appropriate inputs.
  6. Test the refactor. Run your pipeline on at least one repository. Verify that the behavior is identical to the old version.
  7. Version and document. Tag your action with a semantic version (e.g., v1, v2) and add a README so others know how to use it.

Cause and effect

When you remove duplication, the cause is a cleaner codebase; the effect is faster, more reliable CI. Changes propagate instantly across all consumers. If you need to bump a Node version, you do it in one place and all workflows that reference the action inherit the update.

Hands-on walkthrough

Let's put the theory into practice. We'll start with a typical, duplicated GitHub Actions workflow and refactor it to use a reusable workflow.

Step 1: The Duplicated Workflow (before)

Here's a workflow for a simple Node.js project. Note the repeated steps across multiple jobs:

name: CI
on: [push, pull_request]
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
  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

The checkout, setup-node, and npm ci steps are duplicated in both jobs. This is a perfect candidate for a reusable workflow.

Step 2: Create the Reusable Workflow

Create a new file, e.g., node-ci.yml, in .github/workflows/ of a central repository (or in the same repo under a workflows/ directory):

name: Node CI
on:
  workflow_call:
    inputs:
      node-version:
        description: 'Node.js version to use'
        type: string
        default: '20'
    secrets:
      NPM_TOKEN:
        required: false
    outputs:
      test-result:
        value: ${{ jobs.test.outputs.result }}

jobs:
  test:
    runs-on: ubuntu-latest
    outputs:
      result: ${{ steps.run-tests.outcome }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
          cache: 'npm'
      - run: npm ci
      - id: run-tests
        run: npm test
  lint:
    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
      - run: npm run lint

Step 3: Call the Reusable Workflow

Now, replace the original workflow with a minimal caller:

name: CI
on: [push, pull_request]
jobs:
  node-ci:
    uses: your-org/central-workflows/.github/workflows/node-ci.yml@v1
    with:
      node-version: '20'

The uses syntax references a file in another repository, followed by a tag or commit SHA. This single job triggers the entire reusable workflow.

Expected output

When you push this change, GitHub Actions will show a single job named node-ci that expands to include the test and lint jobs from the reusable workflow. The pipeline runs exactly as before, but now the logic lives in one place.

Alternative: Composite Action

If you only wanted to extract the initialization steps (checkout, setup-node, npm ci) into a reusable action, you'd create a composite action instead:

# action.yml in .github/actions/setup-node-project/
name: 'Setup Node Project'
description: 'Checkout, setup Node, and install dependencies'
inputs:
  node-version:
    description: 'Node version to use'
    required: true
runs:
  using: 'composite'
  steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-node@v4
      with:
        node-version: ${{ inputs.node-version }}
        cache: 'npm'
    - run: npm ci
      shell: bash

Then call it in each job:

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

Notice the shell: bash requirement inside composite actions — always specify a shell for run steps.

Compare options / when to choose what

You now have two main tools: composite actions and reusable workflows. They're both for reusability, but they serve different purposes. The table below helps you decide:

Composite Action Reusable Workflow
Reuses a set of steps within a job Reuses a complete job (or jobs)
Runs inline — can't set a different runner at call time Can specify runs-on and other job-level settings
Can use all context and secrets available in the caller Has a restricted context; secrets must be passed explicitly
No on trigger — used as a step Declared with on: workflow_call
Inputs and outputs are defined in action.yml Inputs, outputs, and secrets are defined in the workflow's workflow_call
Ideal for utility steps like setup, build, or deploy Ideal for entire pipelines like a full CI suite
Shared via a repository or path in your repo Shared via a repository reference like owner/repo/.github/workflows/file.yml@ref

When to use a composite action

  • You have a specific step sequence that's repeated across jobs or workflows (e.g., set up Python, create a virtual env, cache pip).
  • You want to keep the logic inside the same repository to avoid cross-repo dependencies.
  • You need to pass secrets and use them directly within the action — composite actions inherit the caller's secrets.

When to use a reusable workflow

  • You have a whole job that repeats (e.g., a test job with matrix strategy, a build job, a deploy job).
  • You want to centralize the pipeline configuration for many repositories in an organization.
  • You need job-level outputs to feed into downstream jobs.

A common pattern is to use both: call a reusable workflow, and inside that workflow, use composite actions to handle repeated setup steps. This gives you a clean layered architecture.

Troubleshooting & edge cases

Refactoring isn't always smooth. Here are the most common pitfalls and how to fix them.

Issue 1: "Composite action doesn't have access to secrets"

Symptom: A secret passed from the caller is empty inside a composite step.

Fix: Unlike normal actions, composite actions do not automatically inherit secrets. You must explicitly pass secrets as inputs:

- uses: ./.github/actions/deploy
  with:
    token: ${{ secrets.DEPLOY_TOKEN }}

And inside action.yml, declare the input and store it in an environment variable:

runs:
  using: composite
  steps:
    - run: echo "${{ inputs.token }}" | some-command
      env:
        TOKEN: ${{ inputs.token }}

Issue 2: "Calling a reusable workflow from a private repo fails with 404"

Symptom: GitHub returns a 404 or 'Not Found' even though the workflow exists.

Fix: This is almost always a permissions issue. The caller workflow must have access to the repository containing the reusable workflow. If both repos are in the same organization, this usually works automatically, but if the central repo is in a different organization or is private, you need to configure access. Consider publishing the reusable workflow to a public repository, or grant access to the other repo's GITHUB_TOKEN by setting permissions: contents: read and ensuring the org's settings allow it.

Issue 3: "Matrix strategy doesn't expand inside a reusable workflow"

Symptom: You define a matrix in the reusable workflow, but all jobs run once.

Fix: Ensure the matrix is defined inside the reusable workflow under a job. The caller can pass with: strategy to override, but you cannot spread a matrix from the caller into the reusable job's matrix — that's a known limitation. If you need dynamic matrices, use the caller's matrix and dynamically call the reusable workflow for each combination, or use a composite action for the per-job steps.

Issue 4: "Version tags are missing"

Symptom: You pushed a new commit to your central workflow, but downsteam workflows don't pick it up.

Fix: If you reference @v1, make sure a tag named v1 exists and has been moved to the current commit. Best practice: Use semantic versioning with release tags (e.g., v1.0.0, v1.1.0) and allow major refs. Provide clear release notes.

Edge case: Reusable workflows don't support if conditions on the whole workflow

You can't put if at the workflow_call level. Instead, put conditions on individual jobs. For example, to skip the whole workflow, wrap the call in a job that evaluates a condition and fails, or use a simple guard job in the caller.

What you learned & what's next

Congratulations! You now understand the core idea behind refactoring CI workflows for reusability: eliminating duplication and creating a single source of truth. You've learned how to identify repeated steps, extract them into composite actions or reusable workflows, and how to choose between them. Hands-on, you refactored a duplicated Node.js workflow into a reusable workflow and a composite action, and you know how to handle common pitfalls like secret passing and versioning.

You applied the DRY principle to your pipeline, and you're now ready to tackle more advanced CI/CD practices. In the next lesson, you'll build on this foundation by learning how to version and promote artifacts across environments. Understanding how to make workflows reusable will directly help you manage artifact promotion at scale, because you'll be able to call a single promotion workflow from many repositories.

Keep that central repository clean, document your reusable components, and enjoy a CI that's easier to maintain and a team that's more productive. Now go refactor!

Practice recap

Choose one of your own repositories and audit its workflows. Find a set of steps that appear in at least two jobs — e.g., setup, dependency install — and extract them into a composite action. Then run the pipeline to confirm behavior is identical. Finally, replace the inline steps with a call to your new action and push the change. You'll immediately feel the difference in maintainability.

Common mistakes

  • Passing secrets directly to composite actions without declaring them as inputs — composite actions don't inherit caller secrets automatically. Always hoist them into an environment variable.
  • Using @main or @master as the version ref for a reusable workflow — this makes changes unpredictable and breaks reproducibility. Always use semantic version tags like @v1 or @v1.2.3.
  • Trying to use a reusable workflow from a private repo without ensuring permissions — the calling workflow needs read access to the central repository, or you'll get a confusing 404.
  • Putting if conditions at the workflow_call level in a reusable workflow — GitHub ignores them. Move conditions to the job level instead.

Variations

  1. Instead of a centralized repository for reusable workflows, you can keep them in a common directory inside the same repository and reference them with a relative path — but this only works for a single repo.
  2. A third-party option is to use a CI framework like Dagger or Tekton, which lets you define reusable components in Go or TypeScript and compose them across any CI system.
  3. You can also use marketplace actions (published composite actions) instead of your own custom workflows — benefit from community maintenance but lose in-house control.

Real-world use cases

  • A platform team maintains a single 'back-end CI' reusable workflow that runs lint, unit tests, and builds for all services, giving them one place to enforce quality gates.
  • A shared 'deploy-to-kubernetes' composite action is called by multiple team pipelines to standardize rollout, credentials, and health checks across environments.
  • An org uses a versioned reusable workflow for mobile app builds so all iOS and Android repos can bump to a new signing or Xcode version by updating a single tag.

Key takeaways

  • Refactoring CI for reusability eliminates duplication and drift — treat workflows as code with DRY principles.
  • Composite actions are for reusable step sequences; reusable workflows are for reusable full jobs.
  • Always version reusable components with semantic tags to ensure predictable behavior.
  • Secrets must be explicitly passed to composite actions; reusable workflows require explicit secret declaration.
  • Versioning and documentation are critical for teammates to adopt the reusable components successfully.
  • Start with a quick audit of your existing workflows; every repeated block is a refactor candidate.

Sponsored

Sponsored

Discussion

Questions, corrections, and tips help everyone reading this page.

0 comments

Add a comment

Shown publicly with your comment.

Be constructive · max 4,000 characters

No comments yet — start the thread.

Related tutorials, quizzes, and articles for this topic.