Chapter 16Lesson 02~230 minutes

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.

Disposable labTwo callersNested workflowLeast privilegeEvidence

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_SECRET and 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?

Why can the leaf call use permissions: {} even though the middle workflow has contents: read?

Why are the two callers useful?

Would caller workflow-level env: RUNTIME=3.13 replace the with: input?

What should happen to the fake secret after the lab?

Next lesson

Choose the right reuse architecture

Lesson 3 compares reusable workflows, composite actions, central repositories, ref strategies, secret styles and nesting depth.

Official references and version notes

Version and compatibility note

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.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this address.