
GitHub Actions Workflow Lockfiles Are Coming
Every GitHub Actions setup hits the same inflection point. You've got three repos copying the same 40-line build-test-deploy workflow. Someone suggests "let's share this," and suddenly you're choosing between two mechanisms that sound similar but work very differently under the hood.
Reusable workflows and composite actions both exist to DRY up CI configuration. But they operate at different levels of abstraction, handle secrets differently, and impose constraints that matter a lot once you're managing shared CI across dozens of repositories. Pick wrong, and you'll be migrating YAML for weeks.
Before comparing them, it helps to be precise about what each one does at runtime. The terminology is confusing because both involve YAML files that get referenced from other YAML files. The similarity ends there.
A reusable workflow is a full workflow file that lives in .github/workflows/ and declares on: workflow_call as its trigger. It can contain multiple jobs, each with their own runners, steps, and environment configurations. When another workflow calls it, the caller uses jobs.<job_id>.uses to reference it. The called workflow's jobs run as separate jobs in the caller's workflow run.
This is the critical architectural detail: a reusable workflow replaces an entire job. You can't mix its steps with other steps in the same job. The called workflow controls its own runs-on, its own environment, and its own job graph.
# .github/workflows/ci-reusable.yml (the called workflow)
name: Shared CI
on:
workflow_call:
inputs:
node-version:
required: true
type: string
secrets:
npm-token:
required: true
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
- run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.npm-token }}
- run: npm test# .github/workflows/pr.yml (the caller workflow)
name: PR Checks
on: pull_request
jobs:
ci:
uses: my-org/shared-workflows/.github/workflows/ci-reusable.yml@v2
with:
node-version: '20'
secrets:
npm-token: ${{ secrets.NPM_TOKEN }}A composite action is defined in an action.yml file (not in .github/workflows/) and uses runs: using: composite. It packages multiple steps into a single action that gets used as a step inside an existing job. The composite action's steps execute in the same runner, same workspace, same job context as the surrounding steps.
Think of it as a macro expansion. The steps defined in the composite action get inlined into the calling job.
# .github/actions/setup-node-project/action.yml
name: Setup Node Project
description: Install Node.js and project dependencies
inputs:
node-version:
required: true
default: '20'
runs:
using: composite
steps:
- uses: actions/checkout@v4
shell: bash
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: npm
- run: npm ci
shell: bash# .github/workflows/pr.yml
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: ./.github/actions/setup-node-project
with:
node-version: '20'
- run: npm test
- run: npm run lintNotice the difference: the composite action is a step inside a job. You can add more steps after it. With a reusable workflow, the ci job is entirely delegated and you can't append steps to it.
This is where teams get burned. The two mechanisms have fundamentally different security models for passing sensitive data.
Reusable workflows have a dedicated secrets keyword. The called workflow declares which secrets it expects, and the caller passes them explicitly. There's also a secrets: inherit shorthand that forwards all the caller's secrets at once. This works within the same organization or enterprise.
Composite actions can't receive secrets directly. They only have inputs. If a composite action needs a secret, the caller has to pass it as a regular input value. This means the secret gets treated as a plain string in the action's context. It still gets masked in logs, but there's no type-level distinction between "this is an API token" and "this is a branch name." That's a weaker contract.
Environment variables also differ. A reusable workflow doesn't inherit env variables from the caller's workflow-level context. You have to pass everything through inputs or secrets explicitly. A composite action, since it runs inside the caller's job, inherits the job's environment variables automatically.
Both mechanisms support the same reference formats for cross-repo usage: a SHA, a tag, or a branch name. But the conventions and risks differ.
For reusable workflows, GitHub recommends pinning to a commit SHA for security. When you re-run failed jobs, GitHub uses the SHA from the original attempt regardless of what the tag or branch currently points to. This prevents a subtle class of bugs where re-runs pick up unintended changes.
For composite actions, the same SHA-pinning advice applies, but in practice teams often use a floating major version tag like @v2 that they move forward on each release. This is the convention set by most Marketplace actions.
A practical versioning setup for an internal shared repo:
Same-repo composite actions have a versioning advantage here: the ./ path always uses the action code from the same commit as the workflow. No ref management needed. Changes to the action and the workflow can land in the same PR.
Reusable workflows shine when you need to standardize an entire pipeline across multiple repositories. The platform team defines the jobs, the runner selection, the environment gates, and the step sequence. Consuming teams just call it and pass in their parameters.
Specific scenarios where they're the better choice:
Cross-repo standardization. You want every service in the org to run the same build-test-scan-deploy pipeline. A reusable workflow in a central repo gives you one place to update the pipeline, and every consumer picks up the change on their next run (if they're using a branch ref) or after a controlled version bump (if they're pinned to a tag).
Environment protection rules. Reusable workflows can reference deployment environments with required reviewers, wait timers, and branch policies. Because they own the job definition, they can enforce that production deploys go through a specific environment gate. Composite actions can't do this since they don't control the job.
Secret isolation. The explicit secrets contract means the called workflow declares exactly which secrets it needs. Auditing is easier. The secrets: inherit option is convenient for same-org usage, but named secrets are better for cross-org or open-source scenarios.
Multi-job pipelines. If your shared pipeline includes multiple jobs with dependencies (build → test → deploy), a reusable workflow is the only way to package that as a unit. A composite action can only represent steps within a single job.
Matrix strategies at the workflow level. The caller can use a matrix to invoke a reusable workflow with different input combinations. This is powerful for testing across Node versions, OS combinations, or deployment targets.
Composite actions are the right pick when you want to share a sequence of steps without dictating the entire job structure. They're more flexible for the consumer and easier to compose with other logic.
Step-level reuse within a repo. Three workflows in the same repo all need the same "checkout, setup Node, install deps" preamble. A composite action in .github/actions/setup/ eliminates the duplication and each workflow can still add its own steps after setup.
Composability. You can use multiple composite actions in the same job, mix them with inline steps, and control the order precisely. Reusable workflows don't compose this way; each one takes over a full job.
Shared workspace access. Because composite actions run in the same job, they share the filesystem with surrounding steps. Files created by a composite action are immediately available to subsequent steps. With reusable workflows, you'd need to use artifacts to pass files between jobs.
Marketplace distribution. If you're building something for the GitHub Actions Marketplace, composite actions are the format to use. Reusable workflows aren't listed on the Marketplace.
No runner overhead. A reusable workflow spins up its own runner(s) for its job(s). If you only need to share three steps, that's an unnecessary runner allocation. Composite actions add zero runner overhead because they execute in the existing job's runner.
Here's a quick reference for the decision. It's not exhaustive, but it covers the scenarios that come up most often.
"I want to share a complete CI/CD pipeline across repos." → Reusable workflow. The consumer calls it as a job and doesn't need to understand the internals.
"I want to share a setup/teardown sequence used inside different jobs." → Composite action. It slots in as a step and the consumer keeps full control over the job.
"I need environment protection rules on deploy." → Reusable workflow. Only jobs can reference environments.
"I need to pass secrets to shared logic." → Both work, but reusable workflows have a cleaner model with the dedicated secrets keyword.
"I need steps that share the filesystem with surrounding steps." → Composite action. Reusable workflows run in separate jobs with separate filesystems.
"I want to publish this on the Marketplace." → Composite action. Reusable workflows can't be listed on the Marketplace.
"I need to nest up to 10 levels of shared logic." → Reusable workflows support nesting up to 10 levels (with a max of 50 unique called workflows per file). Composite actions can call other actions, but there's no formal nesting limit documented.
This isn't an either-or decision. Most mature GitHub Actions setups use both.
A common pattern: the platform team maintains a central repo with reusable workflows that define the org-wide CI/CD pipeline. Inside those reusable workflows, they use composite actions to share step sequences between jobs. The reusable workflow handles the job graph, environment protection, and secrets. The composite actions handle the step-level logic like "setup Node and restore cache" or "build Docker image and push to registry."
# Reusable workflow that uses composite actions internally
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: my-org/actions/setup-node@v1 # composite action
- uses: my-org/actions/build-docker@v1 # composite action
with:
registry: ghcr.io
deploy:
needs: build
environment: production # environment protection
runs-on: ubuntu-latest
steps:
- uses: my-org/actions/deploy-k8s@v1 # composite action
with:
cluster: prod-eastThis layered approach gives you the best of both: job-level governance through reusable workflows and step-level composability through composite actions.
A few constraints that catch people off guard:
Reusable workflows can only be stored in .github/workflows/. No subdirectories. If you have 30 reusable workflows in a shared repo, they all live flat in one directory. Composite actions don't have this restriction and can be organized into any directory structure.
Caller workflow-level env variables don't propagate to reusable workflows. This trips people up because it works fine for jobs defined inline in the same file. You'll need to pass values through inputs instead.
GITHUB_TOKEN permissions can only stay the same or get more restrictive when passed to a reusable workflow. You can't elevate permissions in the called workflow. This is a security feature, but it means you need to set sufficient permissions at the caller level.
Composite actions require a shell field on every run step. Regular workflow steps inherit a default shell, but composite action steps don't. Forgetting this is the most common syntax error when writing your first composite action.
Private repo access must be explicitly configured. For reusable workflows in private repos, you need to enable access in the repo's Actions settings. For composite actions in private repos, the same applies. Neither works across org boundaries without explicit configuration.
The simplest heuristic: if you're sharing an entire job or pipeline, use a reusable workflow. If you're sharing steps within a job, use a composite action. Don't overthink the edge cases until you hit them.
The migration cost is real, though. Converting a reusable workflow to a composite action (or vice versa) means updating every consumer. At 50 repos, that's a multi-day effort even with automation. So spend 30 minutes with the decision matrix before you commit. Your future self will thank you.
Recommended for you
What's next in your stack.
GET TENKI