Reusable Workflows, workflow_call, Inputs, Secrets, Outputs, and Nesting: Core Concepts and Mental Model
Chapter 15 separated execution topology inside one job. Chapter 16 separates automation ownership: a caller workflow can delegate a whole job graph to a reusable workflow, but the interface, source revision, permissions, secrets, outputs, runner access and nested call chain remain explicit state. Reuse is safe only when the contract is versioned and the caller still owns trust.
Learning objectives
- Explain a reusable workflow as a versioned job-graph contract rather than a text include.
- Distinguish caller event/SHA, called workflow path/ref/SHA, runner state and nested workflow state.
- Define typed inputs, named secrets, workflow outputs and permission ceilings before using them.
- Predict which caller state propagates automatically and which state must be passed explicitly.
- Inspect the call chain without exposing credentials or conflating reuse with deployment authorization.
1. The practical problem: copy-and-paste CI drifts faster than the application
Teams often start by duplicating a working test job into several workflow files. The copies look harmless until one caller updates a runtime, another adds a permission, a third fixes a security problem and a fourth keeps the old behavior. At that point the organization has several pipelines that claim to implement the same control but no single interface or version proves it.
A reusable workflow solves a different problem from a YAML anchor or a composite action. It lets one workflow delegate an entire job or job graph to another workflow file. The caller chooses the reusable workflow identity and supplies the declared interface; the called workflow owns its internal jobs and steps. That separation is useful only when the boundary stays explicit.
2. Mental model: caller state → contract → called jobs → outputs → caller decision
Start with the caller's event and exact source revision. The caller
job points to a reusable workflow path and, for cross-repository
reuse, a ref. GitHub validates typed inputs and secret names,
applies the caller's token-permission ceiling and runner-access
context, then runs the called workflow's jobs. The called workflow
can expose declared outputs. Back in the caller, downstream jobs
consume those outputs through needs. A nested reusable
workflow repeats the same boundary one hop deeper.
flowchart TD
A[Caller event + repository + exact SHA] --> B[Caller job uses reusable workflow]
B --> C[Workflow path + immutable ref identity]
B --> D[Typed inputs + named secrets + permissions]
C --> E[Called workflow jobs]
D --> E
E --> F[Runner from caller access context]
E --> G[Declared workflow outputs]
E --> H{Nested reusable call?}
H -->|Yes| I[Re-pass inputs secrets permissions]
I --> J[Nested jobs cannot elevate permission]
G --> K[Caller needs..outputs]
J --> K
K --> L[Evidence / next decision]
The important arrows are authorization boundaries, not decorative
flow. A secret available to workflow A does not magically appear in
workflow C through B. A token permission denied by A cannot be
invented by B or C. A caller-level env value is not an
API parameter. Reuse is therefore explicit dataflow plus explicit
authority.
3. State ledger before writing YAML
| Layer | State to record | Why it matters |
|---|---|---|
| Caller event/revision |
event, repository, ref, GITHUB_SHA, run
ID/attempt
|
proves what source and trigger created the call |
| Caller configuration | caller workflow path plus call job ID | proves which contract invocation was evaluated |
| Called workflow identity |
repository, .github/workflows/… path, ref and
resolved SHA
|
proves which reusable code ran |
| Interface | input names/types/defaults, secret names, output names | separates stable contract from implementation |
| Trust/token | caller permissions and each downstream reduction | nested workflows cannot elevate the caller token |
| Runner | hosted/self-hosted selection available from caller context | called repository does not donate a hosted runner to caller |
| Artifacts/cache | explicitly separate from workflow outputs | large files belong in artifacts; small scalar control data can be outputs |
| External/deployment | none unless a called job explicitly mutates a target | workflow reuse is not deployment approval or rollback |
| Governance | access policy, pinned refs, ownership and review process | central reuse creates a supply-chain dependency |
4. What makes a workflow reusable?
The workflow file must live under .github/workflows and
include workflow_call as a trigger. A reusable workflow
can also have other triggers, but the reusable interface is defined
under on.workflow_call. Inputs are typed as
boolean, number or string.
Secrets are named separately because they carry a different
sensitivity model. Workflow outputs map internal job outputs to a
caller-visible interface.
name: reusable-contract
on:
workflow_call:
inputs:
runtime:
description: Runtime family the contract supports
required: true
type: string
strict:
description: Enable the strict policy branch
required: false
default: true
type: boolean
secrets:
demo_secret:
description: Synthetic lab-only secret
required: true
outputs:
result:
description: Non-sensitive contract result
value: ${{ jobs.check.outputs.result }}
permissions:
contents: read
jobs:
check:
runs-on: ubuntu-24.04
outputs:
result: ${{ steps.result.outputs.value }}
steps:
- id: result
shell: bash
env:
RUNTIME: ${{ inputs.runtime }}
STRICT: ${{ inputs.strict }}
DEMO_SECRET: ${{ secrets.demo_secret }}
run: |
test -n "$DEMO_SECRET"
printf 'value=runtime-%s-strict-%s
' "$RUNTIME" "$STRICT" >> "$GITHUB_OUTPUT"
The secret is tested for presence but never printed, copied to an output or written to an artifact. The output is intentionally non-sensitive. That distinction is part of the interface contract.
5. A reusable workflow call is a job, not a step
An ordinary job declares runs-on and
steps. A caller job instead declares
uses at the job level. The caller can add only the
supported call-job keys such as with,
secrets, needs, if,
strategy, concurrency and
permissions. You do not mix steps or
runs-on into that call job; the called workflow owns
its internal jobs and runner selection.
jobs:
ci:
permissions:
contents: read
uses: ./.github/workflows/reusable-contract.yml
with:
runtime: '3.13'
strict: true
secrets:
demo_secret: ${{ secrets.CH16_FAKE_SECRET }}
report:
needs: ci
permissions: {}
runs-on: ubuntu-24.04
steps:
- run: echo "contract_result=${{ needs.ci.outputs.result }}"
6. Same-repository identity versus a central repository
A same-repository call such as
./.github/workflows/reusable-contract.yml uses the
reusable workflow from the same commit as the caller. That is
convenient for a disposable learning repository because one source
SHA identifies both files. A cross-repository call has the form
owner/repo/.github/workflows/file.yml@ref. The ref can
be a SHA, tag or branch; a full reviewed commit SHA is the safest
production reference because it cannot move later.
If a tag and branch share a name, GitHub gives the tag precedence. Do not rely on that subtlety as a versioning strategy. Treat a central workflow repository exactly like an executable dependency: review changes, record the resolved commit and update callers intentionally.
7. What crosses the boundary automatically?
| State | Automatic? | Correct interface |
|---|---|---|
Caller github context |
associated with caller | use safe caller identity fields; never dump the whole context |
GITHUB_TOKEN |
available but bounded by caller permissions | caller sets least privilege; called/nested workflows may only reduce |
Workflow-level caller env |
no |
pass an input, use vars, or return an output
|
| Named Actions secrets | no |
pass explicitly with secrets: or deliberately
use inherit
|
| Secret to a nested workflow | no transitive forwarding | the intermediate workflow passes it again |
| Called workflow output | only if declared |
map job output → workflow output → caller needs
|
| Runner access | evaluated from caller context | do not assume the called repo grants extra hosted/self-hosted capacity |
8. Permission ceiling and caller-owned trust
A called workflow does not create a new authority domain. If the
caller job has only contents: read, a called or nested
workflow cannot obtain issues: write just by declaring
it. This non-escalation property is central to platform workflow
design: callers decide the maximum authority they delegate.
At the same time, a central reusable workflow is executable code chosen by the caller. Pinning its commit does not make unsafe code safe; it makes the reviewed code identity stable. Review both the interface and implementation before granting secrets or write permissions.
9. Read-only inspection before mutation
echo "repository=$GITHUB_REPOSITORY"
echo "sha=$GITHUB_SHA"
echo "run=$GITHUB_RUN_ID attempt=$GITHUB_RUN_ATTEMPT"
echo "workflow=$GITHUB_WORKFLOW"
echo "ref=$GITHUB_REF"
# In a same-repository lab, the reusable file selected with ./ is from this same commit.
printf '%s
' 'called_path=.github/workflows/reusable-contract.yml'
These fields are safe bounded evidence. Do not dump
toJSON(github) indiscriminately because the context
includes token-bearing and event-derived fields that may be
sensitive or attacker-controlled.
10. Security rules for reusable workflow contracts
Prefer explicit secret names over secrets: inherit so
reviewers can see the capability being delegated. Never return a
secret as a workflow output. Avoid mutable cross-repository refs for
privileged workflows. Keep deployment credentials out of generic
build contracts. If a called job uses an environment, treat that
environment's protections and secrets as a separate authorization
layer rather than as a substitute for caller-to-workflow secret
passing.
Knowledge check
Why is a reusable workflow closer to an API than to copy-pasted YAML?
Because the caller depends on a declared, versioned interface—inputs, secrets, permissions and outputs—while the called workflow can change its internal jobs without changing that interface.
Can workflow B raise GITHUB_TOKEN permissions
above workflow A in an A → B → C chain?
No. Permissions can stay the same or become more restrictive as the chain descends; they cannot be elevated above the caller ceiling.
Does caller workflow-level env automatically
appear inside the called workflow?
No. Pass required data as a workflow input, use an appropriate configuration variable, or return data through declared outputs.
Why is ./.github/workflows/ci.yml useful in the
same repository?
It selects the called workflow from the same commit as the caller, so one exact source SHA identifies both files.
Where does a reusable workflow call appear syntactically?
At the job level with jobs.<job_id>.uses, not
as a step inside steps.
Official references and version notes
-
GitHub Docs — reuse workflows
—
workflow_call, typed inputs, secrets, outputs, nesting and matrix callers. - GitHub Docs — reusing workflow configurations — access rules, current limits, supported caller-job keywords, runner behavior, permissions and rerun semantics.
-
GitHub Docs — workflow syntax:
on.workflow_call— input, secret and workflow-output contract syntax. -
GitHub Docs —
jobs.<job_id>.uses— same-repository and cross-repository reusable-workflow references. - GitHub Docs — re-run workflows and jobs — run-attempt identity and rerun behavior.
- GitHub Docs — use secrets — secret availability and reusable-workflow propagation boundaries.
Version-sensitive behavior was rechecked on
2026-09-09 for GitHub.com. A reusable workflow is
called at the job level, not from a step. Current
GitHub.com limits allow up to
10 connected workflow levels and
50 unique reusable workflows in one top-level
workflow tree. Supported caller-job surfaces are
name, uses, with,
secrets, strategy, needs,
if, concurrency and
permissions. Nested
GITHUB_TOKEN permissions can stay the same or become
more restrictive, never more permissive. Secrets are passed only
to the directly called workflow unless forwarded again.
Workflow-level caller env values do not cross the
boundary automatically. Same-repository
./.github/workflows/file.yml calls use the same
commit as the caller; cross-repository production calls should use
a reviewed full commit SHA instead of a mutable branch or tag.
Re-running all jobs against a non-SHA ref resolves that ref again,
while re-running failed/specific jobs uses the called workflow
commit from the first attempt.
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this
address.