DRY CI: Reusable Workflows and Composite Actions in GitHub Actions

Copy-pasted CI is a maintenance tax you pay forever. Every repository gets its own build-test-deploy YAML, the copies drift, and the day you need to change one thing — a runner image, a cache key, a deploy step — you edit it fifteen times and miss two.

GitHub Actions gives you two mechanisms for writing shared pipeline logic once and consuming it from many repositories: composite actions and reusable workflows (called through the workflow_call trigger). They solve different halves of the problem. A composite action bundles a repeated sequence of steps; a reusable workflow packages a whole set of jobs. Used together they turn a sprawl of near-identical YAML into a small, versioned CI library that your repositories consume in a few lines each. This is how to build both, wire the inputs, secrets and outputs correctly, and version them so a fix propagates instead of rotting.

The composite action: one setup, defined once

The most-copied fragment in any organisation’s pipelines is the setup preamble: check out the code, install the language toolchain, warm the dependency cache. A composite action collapses that into a single reusable step. It lives in an action.yml file — by convention under .github/actions/<name>/ — and declares runs.using: composite. The one rule people forget: every run step inside a composite action must set its own shell. There is no workflow-level default to fall back on.

Free · 4 minutes

Do you actually know what you are running — and what it is about to cost you?

Fourteen questions on the systems you depend on, the ones nobody owns, and the support dates that turn a routine upgrade into a forced re-platform. Banded finding on screen, full sheet by email.

# .github/actions/setup/action.yml
name: Project setup
description: Checkout, install the Node toolchain and warm the dependency cache
inputs:
  node-version:
    description: Node.js version to install
    required: false
    default: "20"
outputs:
  node-version:
    description: The Node.js version that was resolved
    value: ${{ steps.resolve.outputs.version }}
runs:
  using: composite
  steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-node@v4
      with:
        node-version: ${{ inputs.node-version }}
        cache: npm
    - name: Install dependencies
      shell: bash
      run: npm ci
    - id: resolve
      shell: bash
      run: echo "version=$(node --version)" >> "$GITHUB_OUTPUT"

Inputs get typed defaults and are read as ${{ inputs.node-version }}. Outputs are mapped from a step’s $GITHUB_OUTPUT file, which is the modern replacement for the deprecated set-output command. This is exactly the kind of small, boring building block that makes a pipeline easy to reason about — the same instinct behind keeping a pre-commit baseline that actually runs rather than a wall of ad hoc checks.

The reusable workflow: the pipeline as a contract

A reusable workflow is a whole workflow — one or more jobs — that other workflows can call. You opt in by adding the workflow_call trigger, which is where you declare the public interface: typed inputs (string, boolean or number), named secrets, and outputs the caller can consume. Workflow-level outputs are mapped from job outputs, which are in turn mapped from step outputs — a two-hop chain that trips people up the first time.

One caveat worth stating plainly, because it is the single most common failure. Inside a reusable workflow, a local action reference like uses: ./.github/actions/setup resolves against the caller’s checkout, not the repository the reusable workflow lives in. If your composite action ships in the same central repository, reference it by its full owner/repo/path@ref path, as below.

# acme/ci  ->  .github/workflows/build-test-deploy.yml
name: build-test-deploy
on:
  workflow_call:
    inputs:
      environment:
        description: Deployment target
        required: true
        type: string
      node-version:
        required: false
        type: string
        default: "20"
    secrets:
      DEPLOY_TOKEN:
        required: true
    outputs:
      image-tag:
        description: The tag published by the build job
        value: ${{ jobs.build.outputs.image-tag }}

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      image-tag: ${{ steps.meta.outputs.tag }}
    steps:
      # full path, not ./ — see the caveat above
      - uses: acme/ci/.github/actions/setup@v1
        with:
          node-version: ${{ inputs.node-version }}
      - name: Build
        shell: bash
        run: npm run build
      - id: meta
        shell: bash
        run: echo "tag=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"

  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    steps:
      - name: Deploy
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
        run: ./deploy.sh "${{ needs.build.outputs.image-tag }}" "${{ inputs.environment }}"

Note that secret names may only contain alphanumeric characters and underscores — DEPLOY_TOKEN is valid, deploy-token is not, and GitHub will reject the latter when you create it. The environment: key on the deploy job also lets you attach protection rules and required reviewers, so the shared workflow becomes the place your deploy gates live too.

Calling it, and getting outputs back

A consuming repository now runs the whole pipeline in a handful of lines. A job that references a reusable workflow uses uses: at the job level — not inside steps: — and cannot mix in its own steps. Secrets are passed either individually or, more commonly, with secrets: inherit, which forwards the caller’s secrets wholesale.

# a consuming repo  ->  .github/workflows/ci.yml
name: CI
on:
  push:
    branches: [main]

jobs:
  pipeline:
    uses: acme/ci/.github/workflows/build-test-deploy.yml@v1
    with:
      environment: production
      node-version: "20"
    secrets: inherit

  notify:
    needs: pipeline
    runs-on: ubuntu-latest
    steps:
      - run: echo "Deployed image ${{ needs.pipeline.outputs.image-tag }}"

secrets: inherit is convenient, but it is a blunt instrument: it hands every one of the caller’s secrets to the shared workflow. If the reusable workflow only needs one token, pass it explicitly — secrets:\n DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }} — and keep the blast radius small. Treat CI credentials with the same discipline you would any API key you scope and rotate; a widely-inherited deploy token in a shared workflow is a liability that spans every repository that calls it.

The notify job shows the payoff of typed outputs: the caller reads needs.pipeline.outputs.image-tag and does something with it. Because the pipeline is now a contract, this is a real interface you can depend on, not a string you scraped out of a log.

Versioning: tags, SHAs and the drift you are trying to kill

The whole point of centralising is that a fix in one place reaches everywhere. That only works if consumers reference the shared workflow and action by a version you control. You reference by ref — a branch, a tag, or a commit SHA — appended after @.

  • Internal library, moving major tag. Publish semantic tags and maintain a moving v1 that points at the latest v1.x. Consumers pin @v1; you move the tag forward, and every repository picks up the fix on its next run. This is the model GitHub’s own actions use.
  • Third-party actions, pinned SHA. For anything you do not control, pin to a full commit SHA (uses: actions/checkout@<sha>), because a tag can be repointed at malicious code. Let Dependabot’s github-actions ecosystem raise the bump PRs so pinning does not mean stagnation.
  • Never @main in production. Referencing a moving branch means an unreviewed commit to the shared repository can change every downstream pipeline with no release step. That is the drift you were trying to remove, reintroduced through the back door.

A central, versioned CI library is also the natural place to instrument delivery: once every repository routes through the same workflow, you have one location to emit the signals behind the DORA four — lead time, deploy frequency, MTTR and change-failure rate — instead of bolting metrics onto fifteen bespoke pipelines.

The test of whether you have done this well is simple. When someone hands you a new repository, how many lines of CI do they write, and when a shared step needs to change, how many files do you touch? If the answers are “a few” and “one”, the copy-paste tax is gone. If they are anything else, you still have drifting copies waiting to bite you one at a time.

Build and rescue work

Hands-on delivery of this kind is handled by Sixteen Pillars Studio.

Free interactive tool

Website compliance checklist

What your site has to do, based on what it actually does

Answer as much or as little as you like — the list builds as you go. Nothing is stored against your name and no email is required.

Can you trust the architecture you have?

Architecture diagrams rarely show the reality of how systems actually operate. An independent review establishes what is really there.