Thuta Learning
IntermediateDevOps & Toolsbeginner

Reusable Workflows and Composite Actions

What you'll walk away with

  • Explain the core ideas behind Reusable Workflows and Composite Actions
  • Read the diagram and trace how the pipeline flows and what gates each stage
  • Read the workflow file and predict which jobs run, in what order, and what gates them

Build the mental model

Pipelines get copied. One repository gets a good Node CI workflow, the next team pastes it in, and eighteen months later five repositories run five slightly different versions of the same idea: different Node versions, different cache keys, one of them still on a deprecated action. Nobody made a decision to diverge. Nobody had a place to make the fix once.

GitHub offers two mechanisms for sharing, and the split between them is about granularity. A reusable workflow is a whole workflow file whose trigger is on: workflow_call. It declares typed inputs, optionally declares secrets, and can declare outputs. A caller references it as jobs.<id>.uses: owner/repo/.github/workflows/file.yml@ref, and that caller job has no runs-on and no steps of its own, because the called workflow brings its own jobs and its own runners. You are sharing entire jobs.

A composite action is a sequence of steps packaged in an action.yml with runs.using: composite. A caller uses it inside a job, as one step among others, and it executes on that job's runner sharing that job's filesystem and working directory. You are sharing part of a job.

The choice follows directly from that. Install Node, restore the cache, run npm ci is a step sequence that other steps in the same job depend on having happened on the same machine, so it wants to be a composite action. Lint, typecheck, test, and build, on this matrix, in this order, is a whole pipeline, so it wants to be a reusable workflow.

Pin the reference to a tag or a commit SHA, never a branch. Using @main means every caller silently changes behaviour the day someone edits the shared file.

text
THREE REPOS CALLING ONE SHARED WORKFLOW
---------------------------------------
  repo: api-service        repo: web-app          repo: worker
  .github/workflows/       .github/workflows/     .github/workflows/
    ci.yml                   ci.yml                 ci.yml
  jobs.test.uses: --+      jobs.test.uses: --+    jobs.test.uses: --+
                    |                        |                      |
                    v                        v                      v
  +-----------------------------------------------------------+
  | my-org/ci-workflows                                        |
  | .github/workflows/node-ci.yml@v1                           |
  |   on: workflow_call                                        |
  |   inputs:  node-version, run-lint                          |
  |   secrets: npm-token                                       |
  |   jobs:    test   <- runs-on and steps live HERE           |
  +-----------------------------------------------------------+

  one fix here reaches all three repos when you retag v1

  composite action  = a reusable SEQUENCE OF STEPS inside a job
  reusable workflow = one or more whole JOBS, each with a runner

Connect it to a real scenario

Create a dedicated repository, say my-org/ci-workflows, and put node-ci.yml in its .github/workflows/ directory with on: workflow_call. Declare node-version as a string input with a default, run-lint as a boolean input, and npm-token as an optional secret. Inside the jobs you read them as inputs.node-version and secrets.npm-token, exactly as you would read any other context.

A consuming repository then reduces its entire CI file to a few lines: a job whose uses: points at my-org/ci-workflows/.github/workflows/node-ci.yml@v1, a with: block supplying node-version, and a secrets: block passing npm-token from its own repository secret.

Note what the caller job does not have: no runs-on, no steps. Adding either is a schema error, and it is the single most common mistake when converting an existing job into a call.

Two operational points. Secrets are not inherited automatically, so you pass each one explicitly, or write secrets: inherit when the callee should receive everything the caller has. And @v1 is a tag you move deliberately, so a fix lands in every caller when you retag rather than the instant you push. If the shared repository is private, its Actions access setting has to allow other repositories in the organisation to call it, otherwise every caller fails to resolve the reference.

Try the working example

yaml
name: Reusable Node CI

on:
  workflow_call:
    inputs:
      node-version:
        description: Node major version to test against
        required: false
        default: '20'
        type: string
      run-lint:
        description: Whether to run the lint step
        required: false
        default: true
        type: boolean
    secrets:
      npm-token:
        description: Token for the private registry
        required: false
    outputs:
      test-result:
        description: Outcome of the test step
        value: ${{ jobs.test.outputs.result }}

jobs:
  test:
    runs-on: ubuntu-latest
    outputs:
      result: ${{ steps.run-tests.outputs.outcome }}
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
          cache: npm

      - name: Install dependencies
        run: npm ci
        env:
          NODE_AUTH_TOKEN: ${{ secrets.npm-token }}

      - name: Lint
        if: ${{ inputs.run-lint }}
        run: npm run lint

      - name: Test
        id: run-tests
        run: |
          npm test
          echo "outcome=passed" >> "$GITHUB_OUTPUT"
You should see
On its own this file never runs. There is no push or pull_request trigger, only workflow_call, so it executes solely when another workflow references it. When a caller does reference it, the test job runs on a runner belonging to this workflow, not the caller's. inputs.node-version resolves to whatever the caller passed under with:, or to the declared default of 20 when the caller passed nothing. The lint step runs only when run-lint evaluates true, which is also its declared default. secrets.npm-token is an empty string unless the caller explicitly passed it, so a caller that forgets it fails at install time against a private registry rather than at parse time, which is why the omission is easy to miss in review. The workflow's declared output, test-result, carries the test step's result back to the caller, which can read it through needs on a downstream job.

5-minute try-it

Pick two repositories whose CI files started as copies of each other and diff them; the differences are the drift this lesson is about. Move the common part into a reusable workflow in a shared repository, tag it v1, and convert both repos to call it. Then take one step sequence whose steps must share a filesystem with what follows, such as setup plus cache plus install, and package that as a composite action instead, so you have built one of each and can feel where the boundary between them lies.

One important caution

Adding runs-on or steps to a job whose uses: points at a reusable workflow: the caller job supplies neither, and the workflow fails to parse.

Referencing a shared workflow at @main: every consuming repository changes behaviour the moment someone edits the shared file, with no release to review and nothing to roll back to.

GitHub Docs - Reusing workflowsCI/CD with GitHub Actions

Easy traps

  • Adding runs-on or steps to a job whose uses: points at a reusable workflow: the caller job supplies neither, and the workflow fails to parse.
  • Referencing a shared workflow at @main: every consuming repository changes behaviour the moment someone edits the shared file, with no release to review and nothing to roll back to.
  • Validate a workflow on a branch or test repository before pointing it at a production deployment.

Exercise

Pick two repositories whose CI files started as copies of each other and diff them; the differences are the drift this lesson is about. Move the common part into a reusable workflow in a shared repository, tag it v1, and convert both repos to call it. Then take one step sequence whose steps must share a filesystem with what follows, such as setup plus cache plus install, and package that as a composite action instead, so you have built one of each and can feel where the boundary between them lies.

You'll know it worked when: On its own this file never runs. There is no push or pull_request trigger, only workflow_call, so it executes solely when another workflow references it. When a caller does reference it, the test job runs on a runner belonging to this workflow, not the caller's. inputs.node-version resolves to whatever the caller passed under with:, or to the declared default of 20 when the caller passed nothing. The lint step runs only when run-lint evaluates true, which is also its declared default. secrets.npm-token is an empty string unless the caller explicitly passed it, so a caller that forgets it fails at install time against a private registry rather than at parse time, which is why the omission is easy to miss in review. The workflow's declared output, test-result, carries the test step's result back to the caller, which can read it through needs on a downstream job.

Reusable Workflows and Composite Actions | Thuta Learning