# Reusable workflows vs composite actions in GitHub Actions (https://tenki.cloud/blog/reusable-workflows-vs-composite-actions)

> For the complete documentation index, see [llms.txt](https://tenki.cloud/llms.txt)

- Author: Hayssem Elsayed (Product)
- Published: Feb 6, 2026
- Updated: Sep 25, 2026
- Category: [CI/CD](https://tenki.cloud/blog/category/ci-cd)
- Reading time: 14 min

When to share GitHub Actions config as a reusable workflow or a composite action, covering secrets, runners, logs, nesting limits and versioning.

GitHub Actions has two ways to stop copying the same YAML between workflows. A reusable workflow packages whole jobs: the caller hands over a job to it, and the called workflow decides the runner, the steps and the job graph. A composite action packages steps: it runs as one step inside a job the caller already owns, on the caller's runner and in the caller's workspace.

Most of the differences below follow from that. If you're sharing a pipeline, use a reusable workflow. If you're sharing a sequence of steps that different jobs need, use a composite action. The edge cases come down to secrets, runners, logs, limits and versioning, covered below. The YAML examples pass [actionlint](https://github.com/rhysd/actionlint).

## The differences at a glance

|                      | Reusable workflow                                                   | Composite action                                            |
| -------------------- | ------------------------------------------------------------------- | ----------------------------------------------------------- |
| Defined in           | `.github/workflows/<file>.yml`, with `on: workflow_call`            | `action.yml` in any directory, with `runs.using: composite` |
| Called from          | A job: `jobs.<job_id>.uses`                                         | A step: `steps[*].uses`                                     |
| Contains             | One or more jobs                                                    | Steps only                                                  |
| Runner               | Each job sets its own `runs-on`                                     | The calling job's runner                                    |
| Secrets              | `secrets` block, or `secrets: inherit`                              | No `secrets` context; pass values as inputs                 |
| Workflow-level `env` | Not passed from the caller                                          | Inherited from the calling job                              |
| Logs                 | Every job and step logged separately                                | Logged as one step                                          |
| Nesting              | Up to 10 levels of workflows, 50 unique reusable workflows per file | Up to 10 composite actions                                  |
| Marketplace          | Can't be published                                                  | Can be published                                            |

The rows come from GitHub's [comparison of reusable workflows and composite actions](https://docs.github.com/en/actions/concepts/workflows-and-actions/reusing-workflow-configurations#key-differences-between-reusable-workflows-and-composite-actions) and the [reusable workflow reference](https://docs.github.com/en/actions/reference/workflows-and-actions/reusing-workflow-configurations).

## Reusable workflows

A reusable workflow is an ordinary workflow file whose triggers include `workflow_call`. It declares the inputs, secrets and outputs its callers can use:

```yaml
# my-org/shared-workflows/.github/workflows/node-ci.yml
name: Node CI

on:
  workflow_call:
    inputs:
      node-version:
        description: Node.js version to test with
        type: string
        default: "22"
      runner:
        description: runs-on label for the job
        type: string
        default: ubuntu-latest
    secrets:
      npm-token:
        description: Token for the private npm registry
        required: true
    outputs:
      version:
        description: Version from package.json
        value: ${{ jobs.build-and-test.outputs.version }}

permissions:
  contents: read

jobs:
  build-and-test:
    runs-on: ${{ inputs.runner }}
    outputs:
      version: ${{ steps.version.outputs.version }}
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: ${{ inputs.node-version }}
          registry-url: https://registry.npmjs.org
          cache: npm
      - run: npm ci
        env:
          NODE_AUTH_TOKEN: ${{ secrets.npm-token }}
      - run: npm test
      - id: version
        run: echo "version=$(node -p 'require("./package.json").version')" >> "$GITHUB_OUTPUT"
```

`registry-url` matters here: `setup-node` only writes an `.npmrc` that reads `NODE_AUTH_TOKEN` when it's set. Outputs take two hops. A step output is mapped to a job output, and the job output is mapped to a workflow output under `on.workflow_call.outputs`.

The caller references the file in a job's `uses`, not in a step:

```yaml
# .github/workflows/pr.yml in the service repo
name: PR checks
on: pull_request

jobs:
  ci:
    uses: my-org/shared-workflows/.github/workflows/node-ci.yml@v2
    with:
      node-version: "22"
    secrets:
      npm-token: ${{ secrets.NPM_TOKEN }}

  report:
    needs: ci
    runs-on: ubuntu-latest
    steps:
      - run: echo "Tested version $VERSION"
        env:
          VERSION: ${{ needs.ci.outputs.version }}
```

A job that calls a reusable workflow can only use a short list of keys: `name`, `uses`, `with`, `secrets`, `strategy`, `needs`, `if`, `concurrency`, `permissions` and `cache-mode`. It can't have `runs-on`, `steps`, `env` or `environment`, so you can't add a step before or after the call in the same job. Put that work in another job and connect them with `needs`, as `report` does above. The `uses` value also can't contain expressions, so the file and ref are fixed when the workflow is written.

Input types are checked: each input is a `string`, `number` or `boolean`, and the value passed in `with` has to match.

## Composite actions

A composite action is an `action.yml` with `runs.using: composite` and a list of steps. It can live in its own repository or in any directory of the repository that uses it:

```yaml
# .github/actions/setup-node-project/action.yml
name: Set up Node project
description: Install Node.js and the project's npm dependencies
inputs:
  node-version:
    description: Node.js version to install
    default: "22"
  npm-token:
    description: Token for the private npm registry
    required: true
outputs:
  cache-hit:
    description: Whether the npm cache was restored
    value: ${{ steps.setup.outputs.cache-hit }}
runs:
  using: composite
  steps:
    - id: setup
      uses: actions/setup-node@v7
      with:
        node-version: ${{ inputs.node-version }}
        registry-url: https://registry.npmjs.org
        cache: npm
    - run: npm ci
      shell: bash
      env:
        NODE_AUTH_TOKEN: ${{ inputs.npm-token }}
```

The job that uses it checks out the repository first, then calls the action like any other step and keeps adding steps after it:

```yaml
# .github/workflows/pr.yml
name: PR checks
on: pull_request

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - id: setup
        uses: ./.github/actions/setup-node-project
        with:
          npm-token: ${{ secrets.NPM_TOKEN }}
      - run: npm test
      - run: npm run lint
```

The action's steps run in the same job, so files written by `npm ci` are there for `npm test`. A composite action's outputs are mapped from its steps with `value`, and the caller reads them as `steps.setup.outputs.cache-hit`. Composite inputs are always strings.

### Mistakes that break a composite action

The runner checks `action.yml` against a [strict schema](https://github.com/actions/runner/blob/main/src/Runner.Worker/action_yaml.json), and actionlint doesn't catch all of it. These four mistakes fail the step:

* A `run` step without `shell`. In a workflow, `run` steps fall back to a default shell. In a composite action, [`shell` is required](https://docs.github.com/en/actions/reference/workflows-and-actions/metadata-syntax#runsstepsshell) on every `run` step.
* A `uses` step with `shell`, which is the reverse mistake. A `uses` step accepts `name`, `id`, `if`, `uses`, `with`, `env` and `continue-on-error`, and nothing else.
* A local action called before checkout. `uses: ./.github/actions/...` resolves against the runner's workspace, which is empty until `actions/checkout` runs. The runner stops with "Did you forget to run actions/checkout before running your local action?" Checking out inside the composite action doesn't help, because the runner has to find `action.yml` before any of its steps run.
* `secrets` or `vars` read inside the action. Neither context exists in `action.yml`, so pass the values in as inputs.

## Secrets, variables and the token

Reusable workflows have a dedicated `secrets` block. The called workflow declares the secrets it needs and the caller maps them by name. A caller in the same organization or enterprise can pass `secrets: inherit` to forward all of its secrets instead. Secrets are only passed one level down: in a chain A → B → C, workflow C sees a secret from A only if B passes it on explicitly.

Environment secrets are the exception. `on.workflow_call` has no `environment` key, so a caller can't pass them. Instead, a job in the called workflow sets `environment:` and gets that environment's protection rules, required reviewers and secrets, which makes a reusable workflow the natural place for a gated production deploy. GitHub's docs say setting `environment:` is enough, but in practice the environment's secrets resolve to empty strings unless the caller also passes `secrets: inherit` ([actions/runner#4453](https://github.com/actions/runner/issues/4453), open at the time of writing). Nothing fails, so check that the secret isn't empty before a deploy step uses it.

Composite actions have no `secrets` context. The caller passes a secret as an input, as `npm-token` does above. The value is still masked in logs because it came from a secret, but nothing in `action.yml` marks the input as sensitive. Document which inputs expect a secret, and never echo them.

Plain environment variables don't cross into a reusable workflow at all. Workflow-level `env` in the caller is not passed to a reusable workflow, and `env` set in the called workflow isn't visible to the caller; use inputs and outputs. For values that many workflows need, set [configuration variables](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-variables) at the organization or repository level and read them with `vars`. A composite action runs inside the calling job, so it inherits that job's environment, and `echo "NAME=value" >> "$GITHUB_ENV"` in one of its steps sets a variable for the rest of the job.

For `GITHUB_TOKEN`, a called workflow gets `github.token` and `secrets.GITHUB_TOKEN` automatically. Its permissions can stay the same or be reduced down the chain, never raised: if the caller grants `packages: read`, the called workflow can't ask for `packages: write`. Set `permissions` on the calling job to what the called workflow needs. A composite action uses the calling job's token, available as `github.token`.

The `github` context inside a reusable workflow belongs to the caller. `github.workflow` is the caller's workflow name, and `actions/checkout` checks out the caller's repository, not the one the reusable workflow lives in. To know which file and commit defined the running job, use the [`job.workflow_ref` and `job.workflow_sha`](https://github.blog/changelog/2026-09-03-github-actions-early-september-2026-updates/) context properties.

## Runners

Each job in a reusable workflow sets its own `runs-on`. The caller can't override it unless the workflow exposes it as an input, as the `runner` input above does. A composite action has no say in the runner; it runs wherever the calling job runs. If some steps need a bigger machine or a different OS than the rest, they need their own job, which a composite action can't create.

Runner access follows the caller. [GitHub-hosted runners are assigned and billed in the caller's context](https://docs.github.com/en/actions/reference/workflows-and-actions/reusing-workflow-configurations#how-reusable-workflows-use-runners). A called workflow can use the caller's self-hosted runners only if both are owned by the same user or organization.

That makes a shared workflow a handy place to set runner sizes for a whole organization. With [Tenki Runners](https://tenki.cloud/products/runners), the platform team changes `runs-on` in one file to a label from [Runner Sizes & Labels](https://tenki.cloud/docs/runners/sizes.md), such as `tenki-standard-medium-4c-8g`, and every caller picks it up at its next version bump. GitHub treats Tenki runners as self-hosted, and each Tenki workspace connects to one GitHub organization, so keep the shared workflow in that organization.

## Logs

In the run view, a reusable workflow's jobs appear as jobs of the caller's run, and every job and step has its own log. A composite action appears as one step, however many steps it contains. So when a long composite action fails, you read one combined log to find the step that broke. In a reusable workflow the failed step is marked on its own.

## Nesting and limits

A reusable workflow can call other reusable workflows. You can connect [up to ten levels](https://docs.github.com/en/actions/how-tos/reuse-automations/reuse-workflows#nesting-reusable-workflows): the top-level caller plus nine levels of reusable workflows. Loops aren't allowed. A single workflow file can call at most 50 unique reusable workflows, counting every workflow in the nested tree.

Composite actions can call other actions, including other composite actions. GitHub documents a limit of 10 nested composite actions, and past that the runner fails with "Composite action depth exceeded".

Two more limits catch people:

* Reusable workflows must sit directly in `.github/workflows/`. Subdirectories aren't supported, so a repository with many shared workflows keeps them in one flat folder. Composite actions can live in any directory.
* Because a reusable workflow is called from a job rather than a step, `GITHUB_ENV` can't carry values back to the caller. Use workflow outputs.

## Where they can live and who can use them

Both can be referenced from the same repository or from another one:

* In a public repository, any workflow can use them, as long as the caller's Actions settings allow public actions and reusable workflows.
* In a private repository, other private repositories owned by the same user or organization can use them once you allow it under Settings → Actions → General → Access on the hosting repository. Public repositories can't.
* In an enterprise, an internal or private repository can be [shared with every organization in the enterprise](https://docs.github.com/en/enterprise-cloud@latest/actions/how-tos/reuse-automations/share-with-your-enterprise). Public repositories can't use what's in an internal one.

A private repository can't be shared with an unrelated organization. If you need that, make the repository public.

GitHub doesn't follow redirects for actions or reusable workflows. Renaming the repository or its owner breaks every caller that uses the old name.

## Versioning and pinning

Cross-repository references take a ref after `@`: a commit SHA, a tag or a branch. If a tag and a branch share a name, the tag wins. GitHub recommends a [full commit SHA](https://docs.github.com/en/actions/reference/security/secure-use#reusing-third-party-workflows) as the safest option. A common setup for an internal shared repository:

1. Tag releases with semver (`v1.2.0`) and move a major tag (`v1`) forward with each compatible release.
2. Let internal callers use the major tag, and pin third-party code to a SHA.
3. Use Dependabot or Renovate to propose ref updates. Dependabot [updates reusable workflow references](https://github.blog/changelog/2023-03-13-dependabot-updates-support-reusable-workflows-for-github-actions/) as well as actions.

Organizations can [require SHA pinning](https://github.blog/changelog/2025-08-15-github-actions-policy-now-supports-blocking-and-sha-pinning-actions/) through the allowed-actions policy. The policy applies to actions, composite ones included, while reusable workflows can still be referenced by tag.

Re-runs behave differently depending on the ref. If you re-run all jobs, GitHub resolves a tag or branch reference again and may pick up a newer version. If you re-run failed jobs or a single job, it uses the same commit SHA as the first attempt.

References within the same repository don't take a ref, so a change to the shared code and to its callers can land in one pull request. `./.github/workflows/x.yml` always runs the reusable workflow from the caller's commit. `./.github/actions/x` runs whatever is checked out in the workspace, which is usually the same commit. Inside a reusable workflow called from another repository, the workspace is the caller's repository, so `./` doesn't find the reusable workflow's own actions. GitHub added a [`$/` self-repository reference](https://github.blog/changelog/2026-07-30-reference-same-repository-actions-with-self-repository-syntax/) for this case: `uses: $/.github/actions/x` resolves to the repository of the file it's written in, at the running commit, with no checkout. It needs runner 2.336.0 or newer, doesn't take an `@ref`, and isn't available on GitHub Enterprise Server.

## Concurrency and matrix

A job that calls a reusable workflow can set `concurrency`, for example to let only one production deploy run at a time:

```yaml
jobs:
  deploy:
    uses: ./.github/workflows/deploy.yml
    with:
      target: prod
    concurrency:
      group: deploy-prod
      cancel-in-progress: false
```

Don't use the same concurrency group in the caller and the called workflow when `cancel-in-progress` is `true`. Since `github.workflow` is the caller's name inside the called workflow, a group like `${{ github.workflow }}-${{ github.ref }}` at both levels makes the called workflow cancel the run that called it.

A matrix can call a reusable workflow once per combination:

```yaml
jobs:
  deploy:
    strategy:
      matrix:
        target: [dev, staging, prod]
    uses: ./.github/workflows/deploy.yml
    with:
      target: ${{ matrix.target }}
```

Outputs don't aggregate across the matrix. The caller gets the output of the last successful run that set a value, so if you need every result, have each run upload an artifact instead.

Composite actions don't have either setting. They take on the concurrency and matrix of the job that uses them.

## Using both together

The two combine well. Reusable workflows own the job graph, runner choice and deploy gates, and composite actions hold the step sequences those jobs share. A central deploy workflow might look like this:

```yaml
# my-org/shared-workflows/.github/workflows/build-and-deploy.yml
name: Build and deploy

on:
  workflow_call:
    inputs:
      service:
        description: Service to build and deploy
        type: string
        required: true

permissions:
  contents: read
  packages: write

jobs:
  build:
    runs-on: tenki-standard-medium-4c-8g
    steps:
      - uses: actions/checkout@v7
      - uses: my-org/ci-actions/docker-build@v1
        with:
          image: ghcr.io/my-org/${{ inputs.service }}
          token: ${{ github.token }}

  deploy:
    needs: build
    runs-on: tenki-standard-small-2c-4g
    environment: production
    steps:
      - uses: my-org/ci-actions/deploy-k8s@v1
        with:
          service: ${{ inputs.service }}
          kubeconfig: ${{ secrets.KUBECONFIG }}
```

`docker-build` and `deploy-k8s` are composite actions in subdirectories of `my-org/ci-actions`, referenced as `owner/repo/path@ref`. The `deploy` job targets the `production` environment, so it waits for that environment's reviewers and reads `KUBECONFIG` from the environment's secrets. The caller has to grant the permissions the workflow asks for, since a called workflow can't raise them, and passes `secrets: inherit` so the environment secret resolves:

```yaml
jobs:
  release:
    permissions:
      contents: read
      packages: write
    uses: my-org/shared-workflows/.github/workflows/build-and-deploy.yml@v3
    with:
      service: api
    secrets: inherit
```

For repetition inside a single workflow file, you may not need either. GitHub Actions supports [YAML anchors and aliases](https://docs.github.com/en/actions/reference/workflows-and-actions/reusing-workflow-configurations#yaml-anchors-and-aliases), which can repeat a block of steps, an `env` map or a whole job definition within the file.