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
v1that points at the latestv1.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’sgithub-actionsecosystem raise the bump PRs so pinning does not mean stagnation. - Never
@mainin 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.
Everything that applies
Ordered by what to do first: legal requirements you can close quickly, then larger pieces of work, then what is expected rather than required. Not exhaustive, and not a legal audit.
Dated PDF, yours to keep or circulate.
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.