6. Reusable workflows and composite actions
Sections
Reading settings
Natural voice recorded for this course, the same in every browser.
No voice for this language in this browser.
More voices
Code and English terms
Off: the page’s voice reads everything, with no pause between two voices.
No voice for this language in this browser.
More voices
More voices: Microsoft Edge has natural online voices, and Windows (Settings › Time & language › Speech), macOS and Android install others.
The copy problem
Section titled “The copy problem”After five lessons, the same four lines appear in several workflows: setup-dotnet with global-json-file, the NuGet cache, dotnet restore --locked-mode. GitHub Actions has two ways to share them, which C# developers can map to familiar ideas:
| Composite action | Reusable workflow | |
|---|---|---|
| Shares | a sequence of steps | whole jobs |
| File | action.yml in its own folder |
a workflow in .github/workflows/ with on: workflow_call |
| Called from | a step: uses: ./.github/actions/<name> |
a job: uses: ./.github/workflows/<file>.yml |
| Chooses the runner | no, runs in the caller’s job | yes, each job has its own runs-on |
| Secrets | “Cannot use secrets” directly — pass them as inputs | receives secrets: or secrets: inherit |
| In the log | “Logged as one step even if it contains multiple steps” | each job and step logged normally |
| C# analogy | a helper method called in the middle of your code | a whole build template |
| Azure Pipelines | step template | job or stage template |
The quotes come from the documentation’s comparison table. Both can live in the same repository or be called from another one (owner/repo/path@ref).
A composite action
Section titled “A composite action”.github/actions/dotnet-restore/action.yml:
# GitHub Actions course, lesson 6: a composite action that installs the SDK and restores with the NuGet cachename: Set up and restore a .NET solutiondescription: Installs the SDK pinned by global.json, then restores with the NuGet cache and the lock files.
inputs: directory: description: Folder that contains global.json, the solution and the packages.lock.json files required: true
outputs: sdk-version: description: The SDK version reported by dotnet --version value: ${{ steps.sdk.outputs.version }}
runs: using: composite steps: - name: Keep NuGet packages in the workspace shell: bash run: echo "NUGET_PACKAGES=$GITHUB_WORKSPACE/.nuget/packages" >> "$GITHUB_ENV" - uses: actions/setup-dotnet@v6 with: global-json-file: ${{ inputs.directory }}/global.json cache: true cache-dependency-path: ${{ inputs.directory }}/*/packages.lock.json - id: sdk shell: bash working-directory: ${{ inputs.directory }} run: echo "version=$(dotnet --version)" >> "$GITHUB_OUTPUT" - shell: bash working-directory: ${{ inputs.directory }} run: dotnet restore --locked-modeinputsare read with${{ inputs.directory }}; an output must be mapped explicitly from an inner step (value: ${{ steps.sdk.outputs.version }}).- Every
run:step in a composite action needs ashell:— exercise 1 shows what happens without it. - The first step writes to
$GITHUB_ENV: the variable exists for the following steps of the action and for the rest of the caller’s job. A composite action shares the job’s machine, workspace and environment.
A reusable workflow
Section titled “A reusable workflow”.github/workflows/gha-06-dotnet-test.yml uses the action and runs the tests:
# GitHub Actions course, lesson 6: a reusable workflow that tests the .NET sample on one OSname: "GHA 06: reusable .NET test"
on: workflow_call: inputs: os: description: Runner label type: string default: ubuntu-latest outputs: sdk-version: description: SDK used by the tests value: ${{ jobs.test.outputs.sdk-version }} tested-on: description: Operating system of the runner value: ${{ jobs.test.outputs.tested-on }}
jobs: test: runs-on: ${{ inputs.os }} outputs: sdk-version: ${{ steps.setup.outputs.sdk-version }} tested-on: ${{ runner.os }} steps: - uses: actions/checkout@v7 - id: setup uses: ./.github/actions/dotnet-restore with: directory: code/github-actions/dotnet - run: dotnet test --no-restore working-directory: code/github-actions/dotnet - name: What the called workflow sees shell: bash run: | echo "event_name: ${{ github.event_name }}" echo "github.workflow: ${{ github.workflow }}" echo "github.workflow_ref: ${{ github.workflow_ref }}" echo "job.workflow_ref: ${{ job.workflow_ref }}" echo "COURSE from caller: '$COURSE'" echo "NUGET_PACKAGES: $NUGET_PACKAGES"Outputs climb two levels: step → job (jobs.test.outputs) → workflow (on.workflow_call.outputs).
And the caller, .github/workflows/gha-06-reuse.yml:
# GitHub Actions course, lesson 6: calling a reusable workflow with a matrix, and reading its outputsname: "GHA 06: reuse"
on: workflow_dispatch: push: branches: [main] paths: ['.github/workflows/gha-06-reuse.yml', '.github/workflows/gha-06-dotnet-test.yml', '.github/actions/dotnet-restore/**']
env: COURSE: github-actions
jobs: test: strategy: fail-fast: false matrix: os: [ubuntu-latest, windows-latest] uses: ./.github/workflows/gha-06-dotnet-test.yml with: os: ${{ matrix.os }}
summary: needs: test runs-on: ubuntu-latest steps: - run: | echo "COURSE in the caller: '$COURSE'" echo "sdk-version: ${{ needs.test.outputs.sdk-version }}" echo "tested-on: ${{ needs.test.outputs.tested-on }}"A job that calls a reusable workflow has no runs-on and no steps: only name, uses, with, secrets, strategy, needs, if, concurrency, permissions and cache-mode.
Put together, the caller, the reusable workflow and the composite action form a chain, and the outputs climb back up it to the summary job.
flowchart TB subgraph wfcaller["gha-06-reuse.yml"] test["job test: matrix ubuntu-latest, windows-latest"] summary["job summary: needs test"] end subgraph wfcalled["gha-06-dotnet-test.yml, on: workflow_call"] job["job test: runs-on inputs.os"] end subgraph composite["composite action dotnet-restore"] steps["four steps, logged as one"] end test -->|"uses, with os"| job job -->|"step setup: uses, with directory"| steps steps -.->|"sdk-version: step output, then job output"| job job -.->|"on.workflow_call.outputs"| test test -.->|"needs.test.outputs"| summaryWhat the run shows
Section titled “What the run shows”test (ubuntu-latest) / test successtest (windows-latest) / test successsummary successJob names are <caller job> / <called job>. In the API, the Ubuntu job has these steps:
1 Set up job2 Run actions/checkout@v73 Run ./.github/actions/dotnet-restore4 Run dotnet test --no-restore5 What the called workflow sees9 Post Run ./.github/actions/dotnet-restore10 Post Run actions/checkout@v711 Complete jobThe four steps of the composite action are one step (3), and their logs are nested groups inside it; numbers 6 to 8 don’t appear in the list. The Post step 9 is the post step of setup-dotnet inside the action — the NuGet cache save, skipped here because the cache was hit.
What the called workflow sees
Section titled “What the called workflow sees”event_name: pushgithub.workflow: GHA 06: reusegithub.workflow_ref: spareilleux/learn/.github/workflows/gha-06-reuse.yml@refs/heads/mainjob.workflow_ref: spareilleux/learn/.github/workflows/gha-06-dotnet-test.yml@refs/heads/mainCOURSE from caller: ''NUGET_PACKAGES: /home/runner/work/learn/learn/.nuget/packages- The
githubcontext belongs to the caller: same event, same workflow name. To know which file defines the running job, readjob.workflow_ref. COURSEis empty. The documentation is explicit: “environment variables set in anenvcontext defined at the workflow level in the caller workflow are not propagated to the called workflow”. Pass values asinputs, or usevars(repository variables).NUGET_PACKAGEScame from the composite action’s$GITHUB_ENVand reached the next steps of the job.
And the caches? The action restored dotnet-cache-Linux-557e0cce…, the cache saved by lesson 5’s workflow: same key, same path, same branch — caches belong to the repository, not to a workflow.
Outputs of a matrix
Section titled “Outputs of a matrix”COURSE in the caller: 'github-actions'sdk-version: 10.0.401tested-on: WindowsTwo matrix jobs set tested-on, the caller got one value: Windows, the job that finished last (13:25:06, against 13:24:29 for Ubuntu). That’s the documented rule: “the output will be the output set by the last successful completing reusable workflow of the matrix which actually sets a value”. Don’t use a matrix output for anything that differs between the combinations; upload an artifact per combination instead.
Limits worth knowing
Section titled “Limits worth knowing”From the reference:
- “You can connect up to ten levels of workflows” (the caller plus nine nested levels);
- “You can call a maximum of 50 unique reusable workflows from a single workflow file”;
- a reusable workflow referenced by branch or tag can change under your feet; a commit SHA can’t. Lesson 7 comes back to pinning.
Key takeaways
Section titled “Key takeaways”- Repeated steps → composite action, called by a step, running in the caller’s job. Repeated jobs → reusable workflow, called by a job, with its own runners.
- Composite
run:steps needshell:; local actions and workflows needactions/checkoutfirst (for actions) and a path starting with./. - The called workflow sees the caller’s
githubcontext but not itsenv; useinputs,outputsandjob.workflow_ref. - With a matrix, a reusable workflow’s output is the one of the last job to finish.
Exercises
Section titled “Exercises”- Remove
shell: bashfrom arun:step of a composite action. When is the error detected, and what does it say?
Solution
Only when a job runs the action — the push that commits it doesn’t complain. Checked with gha-06-exercises.yml and a deliberately broken exercise-no-shell action:
##[error]/home/runner/work/learn/learn/./.github/actions/exercise-no-shell/action.yml (Line: 8, Col: 7): Required property is missing: shell##[error]Failed to load /home/runner/work/learn/learn/./.github/actions/exercise-no-shell/action.ymlIn a workflow, run: has a default shell; in a composite action, it has none.
- A job starts directly with
uses: ./.github/actions/dotnet-restore, withoutactions/checkout. What happens?
Solution
##[error]Can't find 'action.yml', 'action.yaml' or 'Dockerfile' under '/home/runner/work/learn/learn/.github/actions/dotnet-restore'. Did you forget to run actions/checkout before running your local action?A local action is read from the workspace, which is empty until the checkout. A reusable workflow referenced with ./ doesn’t have this problem: GitHub reads it from the repository before the job starts.
- The caller passes
configuration: Releaseinwith:, butgha-06-dotnet-test.ymldeclares only theosinput. When does it fail?
Solution
Before any job starts. The run ends with the conclusion startup_failure and no jobs; gh run view only says “This run likely failed because of a workflow file issue”, and the run page shows:
The workflow is not valid. .github/workflows/gha-06-invalid-input.yml (Line: 13, Col: 22): Invalid input, configuration is not defined in the referenced workflow.Checked on a temporary branch, so that an invalid workflow wouldn’t fail every push on main (see lesson 1). Inputs are a contract checked when the run is created, like the parameters of a method at compile time.