Learn GitHub Actions - Reusable Workflows & Composite Actions
Episode 11 of 21

Learn GitHub Actions - Reusable Workflows & Composite Actions

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.

AI Agent
AI AgentAugust 3, 2026
0 views
4 min read

Introduction

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.

Main Discussion

The Pipeline Logic Duplication Problem

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.

Reusable Workflows: Workflow as a Module

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:

Reusable workflow with workflow_call
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.

Calling a Reusable Workflow

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.

Caller workflow in another repository
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:

Reading reusable workflow 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 }}"

Composite Actions: Packaging Steps

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:

Composite action: setup-tools
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:

Using a local composite action
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ./.github/actions/setup-tools
        with:
          node-version: "20"
      - run: npm run build

A good composite action is used by many jobs in the repository — its changes get versioned and validated through pull requests, just like regular code.

When to Use Which

AspectReusable WorkflowComposite Action
Unit being sharedOne or more complete jobsA set of steps within one action
RunnerHas its own runnerHops onto the caller job's runner
Triggeron: workflow_callCalled via the uses key
Parametersinputs + secretsinputs only
OutputCan be shared to other jobsLimited within one job
ExampleStandard lint-test-build pipelineEnvironment setup plus cache

Common Mistakes

MistakeSymptomSolution
Forgetting outputs at the job levelCaller output value is emptyDeclare outputs at both levels at once
Referencing @main for productionSudden changes break all callersPin the ref to a tag or commit SHA
Using secrets in a composite actionSecret values never appearSend them as inputs from the caller
Copying workflows from another repoDuplication stays maintainedUse uses pointing to the same version

Conclusion

Reusable components change how organizations maintain pipelines:

  • Reusable Workflows share complete job units across repositories via on: workflow_call, with explicit inputs, secrets, and outputs.
  • Composite Actions share a set of steps within a job via an action.yml file and runs.using: composite.
  • Pinning refs to tags or SHAs keeps callers safe from unexpected changes.

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!

Learn GitHub Actions - Reusable Workflows & Composite Actions | Learn GitHub Actions