Reusable Workflows, workflow_call, Inputs, Secrets, Outputs, and Nesting: Guided Hands-On Workflow
This guided lab turns duplicated CI logic into a same-repository reusable contract, calls it from two manual workflows, passes one synthetic secret explicitly, forwards it one more hop to a nested reusable workflow, and returns non-sensitive outputs. The workflow never prints the secret and never mutates an external system.
Learning objectives
- Create two manual caller workflows that invoke one central same-repository reusable CI contract.
- Define and consume typed string/boolean inputs plus non-sensitive workflow outputs.
- Pass one synthetic secret explicitly from caller to contract and then to a nested reusable workflow.
- Down-scope token permissions at the nested call and verify the call chain through safe evidence.
- Run the complete lab without third-party actions, external credentials, packages, releases or deployments.
1. Disposable repository and preflight
Create a throwaway GitHub.com repository named
gha-reuse-lab. The lab uses only GitHub-hosted
ubuntu-24.04, shell steps and same-repository workflow
files. It does not need a cloud account, package registry,
self-hosted runner or Marketplace action.
Create one repository Actions secret named
CH16_FAKE_SECRET with a synthetic value such as
chapter16-demo-only-change-me. This value is not a real
credential. The workflows only test whether the secret is non-empty;
they never print its content, length, hash or transformed form.
Before running anything, predict: (1) both caller workflows will
delegate to the same reusable file at their own source SHA; (2) the
nested leaf receives the fake secret only because the middle
workflow forwards it; and (3) the nested call receives no
configurable GITHUB_TOKEN permissions because the
middle workflow sets permissions: {} on that call job.
2. Leaf workflow: smallest nested contract
The leaf accepts one string and one required fake secret. It returns only a non-sensitive status string. This makes secret propagation visible without exposing the secret itself.
# .github/workflows/reusable-leaf.yml
name: chapter16-reusable-leaf
on:
workflow_call:
inputs:
channel:
required: true
type: string
secrets:
probe_secret:
required: true
outputs:
leaf_summary:
description: Safe proof that the nested contract ran
value: ${{ jobs.probe.outputs.summary }}
permissions: {}
jobs:
probe:
runs-on: ubuntu-24.04
outputs:
summary: ${{ steps.result.outputs.summary }}
steps:
- id: result
name: Validate nested input and secret presence
shell: bash
env:
CHANNEL: ${{ inputs.channel }}
PROBE_SECRET: ${{ secrets.probe_secret }}
run: |
set -euo pipefail
test -n "$PROBE_SECRET"
case "$CHANNEL" in
strict|relaxed) ;;
*) echo "unsupported channel: $CHANNEL" >&2; exit 21 ;;
esac
printf 'summary=leaf-%s-secret-present
' "$CHANNEL" >> "$GITHUB_OUTPUT"
3. Central reusable CI contract
The middle workflow validates the public interface, creates one
output and then calls the leaf workflow as another job. Notice the
second explicit secrets: mapping. Secrets are not
transitively inherited merely because the middle workflow received
them.
# .github/workflows/reusable-ci.yml
name: chapter16-reusable-ci
on:
workflow_call:
inputs:
runtime:
description: Supported runtime family
required: true
type: string
strict:
description: Select strict or relaxed contract behavior
required: false
default: true
type: boolean
secrets:
demo_secret:
description: Synthetic lab-only secret
required: true
outputs:
contract_result:
description: Non-sensitive contract result
value: ${{ jobs.contract.outputs.result }}
leaf_summary:
description: Non-sensitive nested-workflow result
value: ${{ jobs.leaf.outputs.leaf_summary }}
permissions:
contents: read
jobs:
contract:
runs-on: ubuntu-24.04
outputs:
result: ${{ steps.check.outputs.result }}
steps:
- id: check
name: Validate CI contract
shell: bash
env:
RUNTIME: ${{ inputs.runtime }}
STRICT: ${{ inputs.strict }}
DEMO_SECRET: ${{ secrets.demo_secret }}
run: |
set -euo pipefail
echo "caller_repository=$GITHUB_REPOSITORY"
echo "source_sha=$GITHUB_SHA"
echo "run=$GITHUB_RUN_ID attempt=$GITHUB_RUN_ATTEMPT"
echo "called_path=.github/workflows/reusable-ci.yml"
test -n "$DEMO_SECRET"
if [[ "$RUNTIME" != "3.13" ]]; then
echo "unsupported runtime: $RUNTIME; contract supports 3.13" >&2
exit 23
fi
printf 'result=runtime-%s-strict-%s
' "$RUNTIME" "$STRICT" >> "$GITHUB_OUTPUT"
leaf:
needs: contract
permissions: {}
uses: ./.github/workflows/reusable-leaf.yml
with:
channel: ${{ inputs.strict && 'strict' || 'relaxed' }}
secrets:
probe_secret: ${{ secrets.demo_secret }}
The leaf call job has no runs-on and no
steps; those belong to reusable-leaf.yml.
It also deliberately reduces permissions from
contents: read to an empty permission set.
4. Caller A: strict mode
# .github/workflows/caller-a.yml
name: chapter16-caller-a
on:
workflow_dispatch:
permissions: {}
jobs:
ci:
name: Call central CI contract
permissions:
contents: read
uses: ./.github/workflows/reusable-ci.yml
with:
runtime: '3.13'
strict: true
secrets:
demo_secret: ${{ secrets.CH16_FAKE_SECRET }}
report:
needs: ci
permissions: {}
runs-on: ubuntu-24.04
steps:
- name: Report safe outputs
shell: bash
env:
CONTRACT_RESULT: ${{ needs.ci.outputs.contract_result }}
LEAF_SUMMARY: ${{ needs.ci.outputs.leaf_summary }}
run: |
echo "contract_result=$CONTRACT_RESULT"
echo "leaf_summary=$LEAF_SUMMARY"
Caller A grants the reusable call only contents: read.
The report job has no configurable token permissions. The fake
secret is mapped by name and is not available to the report job
unless separately referenced there.
5. Caller B: relaxed mode, same contract
# .github/workflows/caller-b.yml
name: chapter16-caller-b
on:
workflow_dispatch:
permissions: {}
jobs:
ci:
name: Call central CI contract
permissions:
contents: read
uses: ./.github/workflows/reusable-ci.yml
with:
runtime: '3.13'
strict: false
secrets:
demo_secret: ${{ secrets.CH16_FAKE_SECRET }}
report:
needs: ci
permissions: {}
runs-on: ubuntu-24.04
steps:
- name: Report safe outputs
shell: bash
env:
CONTRACT_RESULT: ${{ needs.ci.outputs.contract_result }}
LEAF_SUMMARY: ${{ needs.ci.outputs.leaf_summary }}
run: |
echo "contract_result=$CONTRACT_RESULT"
echo "leaf_summary=$LEAF_SUMMARY"
6. Run both callers and collect evidence
Commit the four workflow files together. Record the commit SHA. From
the Actions tab, manually run chapter16-caller-a and
chapter16-caller-b. Record each run ID and attempt
before reading the logs.
Caller A should emit runtime-3.13-strict-true and
leaf-strict-secret-present. Caller B should emit
runtime-3.13-strict-false and
leaf-relaxed-secret-present. The text
secret-present is a fixed label, not the secret value.
7. What the run proves about secret and permission propagation
The caller explicitly maps CH16_FAKE_SECRET to the
middle workflow's demo_secret. The middle workflow
explicitly maps that received secret to the leaf's
probe_secret. Remove the second mapping and the nested
workflow cannot satisfy its required secret interface. This is
intentional one-hop propagation.
The caller allows at most contents: read. The middle
workflow requests that same permission for its ordinary job, then
the leaf call job requests permissions: {}. At no point
does a downstream workflow gain write authority. Because the lab
never accesses repository contents, the token itself is not printed
or used.
8. Output chain: step → job → reusable workflow → caller needs
| Boundary | Declaration | Consumer |
|---|---|---|
| Step → job |
$GITHUB_OUTPUT then
jobs.contract.outputs.result
|
middle workflow |
| Job → workflow |
on.workflow_call.outputs.contract_result.value
|
caller call-job output |
| Nested workflow → middle job | leaf workflow output | jobs.leaf.outputs.leaf_summary |
| Call job → report job | caller needs: ci |
needs.ci.outputs.… |
Outputs are appropriate here because the values are tiny, non-sensitive control/evidence strings. A test report or binary should be an artifact instead; a secret should be neither.
9. Challenge: choose the right boundary
You need to make the same coverage_threshold available
to both callers and the central workflow, but it is not sensitive.
Should you create a secret, rely on caller env, add an
input, or upload an artifact? For a caller-selected value that is
part of the contract, add a typed input. If the value is centrally
governed across many repositories, an organization/repository
vars value may be more appropriate.
10. Verification and cleanup
- Verify both runs reference the expected caller workflow and commit SHA.
- Verify the central job log records only safe run/revision metadata.
- Verify the leaf outputs differ only by the strict/relaxed channel.
- Search logs for the literal fake secret value; it should not appear.
-
Delete
CH16_FAKE_SECRETand the throwaway repository after the lab.
Deleting a workflow run is not required for cleanup. If you retain it as evidence, it contains only synthetic data and no secret value.
Knowledge check
Why does the middle workflow pass the fake secret again to the leaf?
Because secrets are available only to the directly called workflow unless that workflow explicitly forwards them to the next nested workflow.
Why can the leaf call use permissions: {} even
though the middle workflow has
contents: read?
Downstream reusable calls may reduce the caller token permissions. The permission ceiling prevents elevation but allows narrowing.
Why are the two callers useful?
They prove that one stable reusable contract can serve different caller-selected typed inputs while returning the same shaped output interface.
Would caller workflow-level
env: RUNTIME=3.13 replace the
with: input?
No. Caller workflow-level env does not automatically propagate into the reusable workflow; contract data should be passed through inputs.
What should happen to the fake secret after the lab?
Delete it and the disposable repository. It is synthetic, but explicit cleanup reinforces correct secret lifecycle habits.
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. The mandatory lab contains no
third-party action references, so there is no external action SHA
to pin. All reusable calls are same-repository relative references
selected from the same source commit as their callers.
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.