This episode discusses the DRY principle in CI/CD through reusable workflows and composite actions. You will learn to trigger modular workflows with workflow_call, send inputs and secrets, and package many steps into a single reusable action.

In episode 10 we closed the security phase of this series with OIDC. Now we enter a new phase that's no less important in large organizations: reuse. Imagine a company with fifty repositories, each containing identical copies of lint, test, build, and deploy workflows. One day a bug is found in the pipeline — the fix has to be manually copied to fifty files, and you can bet some will be missed. That's the problem episode 11 wants to solve: applying the DRY (Don't Repeat Yourself) principle to pipelines. Its two weapons are Reusable Workflows and Composite Actions.
In programming, you wouldn't write the same function in twenty places just because it's used in twenty modules — you extract one function and call it. Unfortunately, many teams treat CI/CD workflows as a "copy-paste project". The consequences are real: fixes aren't evenly distributed, action versions aren't in sync, and audits are hard because the "standard" is actually a set of diverging copies. DRY in CI/CD means pipeline logic is written once, maintained in one place, and reused by many workflows.
All the workflows we've written so far are triggered by events like push or pull_request. Reusable workflows introduce a new trigger: workflow_call — a workflow that doesn't run on its own, only when another workflow calls it.
The key point: this callable workflow declares an explicit interface: inputs for parameters, secrets for the secret values it needs, and outputs for values it wants to share back to the caller. Notice the output chain: at the workflow_call level, outputs point to job outputs, and job outputs point to step outputs via the $GITHUB_OUTPUT file:
name: Reusable Build and Test
on:
workflow_call:
inputs:
node-version:
type: string
required: false
default: "20"
secrets:
npm-token:
required: true
outputs:
coverage:
description: Persentase coverage dari laporan test
value: ${{ jobs.test.outputs.coverage }}
jobs:
test:
runs-on: ubuntu-latest
outputs:
coverage: ${{ steps.coverage.outputs.rate }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: npm
- run: npm ci
env:
NPM_TOKEN: ${{ secrets.npm-token }}
- run: npm run lint
- run: npm test -- --coverage
- id: coverage
run: echo "rate=$(npm run coverage:rate)" >> "$GITHUB_OUTPUT"This is the "pipeline within a pipeline" pattern that can be controlled from outside — like a function with parameters, return values, and documented expectations.
From another workflow, a reusable workflow is called via the uses key with a full path format: owner/repo/.github/workflows/file.yml@ref. The ref can be a branch, tag, or commit SHA — for production, prefer a tag or SHA, not a branch that can silently change.
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
quality:
uses: devvnull/actions-library/.github/workflows/build-test.yml@v1
with:
node-version: "20"
secrets:
npm-token: ${{ secrets.NPM_TOKEN }}Inputs are sent via with, secrets are mapped explicitly one by one under secrets. This explicit mapping is deliberate — you only hand over what's truly needed, not the whole key ring.
Warning
There's a shortcut secrets: inherit that forwards all repository secrets to the called workflow. Convenient indeed, but it violates the least privilege principle from episode 9: the caller workflow could leak a secret that isn't needed. Fine for small teams, perhaps, but for large organizations always map explicitly.
Outputs from a reusable workflow are read in the next job via the needs context — exactly like normal job outputs:
jobs:
quality:
uses: devvnull/actions-library/.github/workflows/build-test.yml@v1
with:
node-version: "20"
secrets:
npm-token: ${{ secrets.NPM_TOKEN }}
report:
runs-on: ubuntu-latest
needs: quality
steps:
- run: echo "Coverage saat ini: ${{ needs.quality.outputs.coverage }}"Reusable workflows operate on the job unit. Sometimes what you want to save is smaller: a group of steps reused repeatedly across different jobs. That's where composite actions come in — a custom internal action that packages several steps into one step callable via uses.
A composite action is written as an action.yml file inside a repository. Here's an example internal action that installs Node.js while also handling npm caching:
name: Setup Node dan Cache
description: Menginstal Node.js lalu memulihkan cache npm
inputs:
node-version:
description: Versi Node.js yang dipakai
required: true
outputs:
cache-hit:
description: Apakah cache berhasil dipulihkan
value: ${{ steps.cache.outputs.cache-hit }}
runs:
using: composite
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: npm
- id: cache
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-Notice runs.using: composite — that's where it's declared that this action runs its internal steps in the same job (not on its own runner). Inputs are read via the inputs context, not secrets: composite actions cannot access secrets directly, so secret values must be sent as inputs from the caller.
Using it is simple, just reference the action folder's path:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-tools
with:
node-version: "20"
- run: npm run buildA good composite action is used by many jobs in the repository — its changes get versioned and validated through pull requests, just like regular code.
| Aspect | Reusable Workflow | Composite Action |
|---|---|---|
| Unit being shared | One or more complete jobs | A set of steps within one action |
| Runner | Has its own runner | Hops onto the caller job's runner |
| Trigger | on: workflow_call | Called via the uses key |
| Parameters | inputs + secrets | inputs only |
| Output | Can be shared to other jobs | Limited within one job |
| Example | Standard lint-test-build pipeline | Environment setup plus cache |
| Mistake | Symptom | Solution |
|---|---|---|
| Forgetting outputs at the job level | Caller output value is empty | Declare outputs at both levels at once |
| Referencing @main for production | Sudden changes break all callers | Pin the ref to a tag or commit SHA |
| Using secrets in a composite action | Secret values never appear | Send them as inputs from the caller |
| Copying workflows from another repo | Duplication stays maintained | Use uses pointing to the same version |
Reusable components change how organizations maintain pipelines:
on: workflow_call, with explicit inputs, secrets, and outputs.action.yml file and runs.using: composite.In the next episode 12, we enter the containerization phase: Docker Integration & Container Registries — running jobs inside a container for build environment isolation, then automating multi-platform image builds and pushes to GHCR with layer caching. Because reusable pipelines must also be reproducible!