Mert Özen Further With Every Line
DevOps GitHub Actions — #12

Reducing Duplication in GitHub Actions: Composite Actions, Reusable Workflows and YAML Anchors

Mert Özen Nov 8, 2026 11 min 5 views
Reducing Duplication in GitHub Actions: Composite Actions, Reusable Workflows and YAML Anchors

Use a composite action to share steps, a reusable workflow to share jobs that need environments and approvals, and YAML anchors to repeat a block within one file. Bookstore's dotnet-ci action and reusable-deploy workflow under act; the risks of secrets: inherit, environment secrets, the 10-level/50-workflow limits, the $/ syntax, versioning and why merge keys are not supported.

Version note: The examples in this post were run with act 0.2.97, actionlint 1.7.12, Docker 29.2, .NET 10 (SDK 10.0.403), actions/checkout@v7 and actions/setup-dotnet@v6. Limits and new syntax were checked against the GitHub documentation as of November 2026.

Guess First

In Bookstore you moved the deploy steps into a composite action and wrote DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }} inside the action. When the workflow runs, what happens to the token: does it come from the environment, stay empty, or does the workflow not start at all?

Second question: to keep the env: blocks of the staging and production jobs identical, you used the <<: *deploy-env merge key in YAML (YAML Ain't Markup Language) and overrode only APP_URL in production. Does GitHub accept it?

Where Duplication Piles Up

Look at the workflows we have written in the series so far: api-ci, ci, deploy, deploy-azure, deploy-vm. All of them have checkout, setup-dotnet and the restore/build/test trio. The deploy jobs for staging and production are nearly identical line by line. The day you decide to add --locked-mode, you have to change five files, forget one, and two weeks later look into "it passed in CI (Continuous Integration) but broke in the deploy pipeline".

GitHub Actions has three tools for reducing duplication, each working at a different level. A composite action packages steps: inside a job it looks like a single step. A reusable workflow packages jobs: in the calling workflow it looks like a single job, but it has its own runner, environment and permissions. YAML anchors let you reuse a block within the same file. The DRY (Don't Repeat Yourself) principle we discussed in the Clean Code series applies here too, with the same warning: premature abstraction can cost more than the duplication itself.

Composite Action: Packaging Steps

Bookstore's .NET CI steps live in .github/actions/dotnet-ci/action.yml:

name: dotnet-ci
description: Restore, build and test the Bookstore API with the SDK pinned in global.json
inputs:
  configuration:
    description: Build configuration
    default: Release
  test-project:
    description: Test project to run
    default: api/tests/Bookstore.Api.Tests
outputs:
  sdk-version:
    description: The .NET SDK version that setup-dotnet installed
    value: ${{ steps.setup.outputs.dotnet-version }}
runs:
  using: composite
  steps:
    - id: setup
      uses: actions/setup-dotnet@v6
      with:
        global-json-file: global.json
    - name: Restore
      shell: bash
      run: dotnet restore api/Bookstore.slnx --locked-mode
    - name: Build
      shell: bash
      env:
        CONFIGURATION: ${{ inputs.configuration }}
      run: dotnet build api/Bookstore.slnx --no-restore -c "$CONFIGURATION"
    - name: Unit tests
      shell: bash
      env:
        CONFIGURATION: ${{ inputs.configuration }}
        TEST_PROJECT: ${{ inputs.test-project }}
      run: dotnet test --project "$TEST_PROJECT" --no-build -c "$CONFIGURATION"

A few rules differ from workflows. Every step with run: needs shell:; the workflow's defaults.run.shell does not carry over. Inputs are read through the inputs context and are all strings. An output is bound to an inner step's output with value:. Steps may use if: and continue-on-error. And the answer to the first question: there is no secrets context in a composite action. If a secret is needed, the calling workflow has to pass it as an input (or through the step's env:). actionlint does not check the expressions inside a local composite action, so you won't notice this mistake while writing the file; act, on the other hand, caught it immediately with its schema: Failed to match run-step: Line: 9 Column 23: Unknown Variable Access secrets. I also saw a difference in the opposite direction: act happily ran a composite step without shell:, while GitHub requires that field. A local run passing is not proof that it will pass on GitHub.

Using it is one line, but a checkout must come first; a local action (one starting with ./) is read from the runner's workspace:

name: pipeline

on:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  ci:
    runs-on: ubuntu-latest
    outputs:
      sdk-version: ${{ steps.ci.outputs.sdk-version }}
    steps:
      - uses: actions/checkout@v7
      - id: ci
        uses: ./.github/actions/dotnet-ci
      - run: dotnet publish api/src/Bookstore.Api -c Release -o out/api --no-build
      - uses: actions/upload-artifact@v7
        with:
          name: api-publish
          path: out/api

  deploy-staging:
    needs: ci
    uses: ./.github/workflows/reusable-deploy.yml
    with:
      environment: staging
    secrets: inherit

  report:
    needs: [ci, deploy-staging]
    runs-on: ubuntu-latest
    steps:
      - env:
          SDK: ${{ needs.ci.outputs.sdk-version }}
          FILES: ${{ needs.deploy-staging.outputs.deployed-files }}
        run: echo "SDK $SDK, deployed $FILES files to staging"

Reusable Workflow: Packaging Jobs

The deploy-staging job has no runs-on and no steps; instead it calls another workflow file with uses:. The called file is an ordinary workflow triggered by on: workflow_call:

name: reusable-deploy

on:
  workflow_call:
    inputs:
      environment:
        description: Target environment name
        type: string
        required: true
      artifact-name:
        type: string
        default: api-publish
    outputs:
      deployed-files:
        description: Number of files in the deployed package
        value: ${{ jobs.deploy.outputs.files }}

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment:
      name: ${{ inputs.environment }}
      url: ${{ vars.APP_URL }}
    concurrency:
      group: deploy-${{ inputs.environment }}
      cancel-in-progress: false
    outputs:
      files: ${{ steps.deploy.outputs.files }}
    steps:
      - uses: actions/checkout@v7
      - uses: actions/download-artifact@v8
        with:
          name: ${{ inputs.artifact-name }}
          path: out/api
      - id: deploy
        env:
          APP_URL: ${{ vars.APP_URL }}
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
          TARGET: ${{ inputs.environment }}
        run: |
          ./deploy/deploy.sh "$TARGET" out/api
          echo "files=$(find out/api -type f | wc -l | tr -d ' ')" >> "$GITHUB_OUTPUT"

The output takes three hops: the step output is written to $GITHUB_OUTPUT, lifted to the job's outputs:, and from there to on.workflow_call.outputs. The caller reads it as needs.deploy-staging.outputs.deployed-files. Inputs are typed (string, number, boolean); forget to pass a required: true input and you get an error before the workflow starts.

act can run local reusable workflows and composite actions. With the artifact version change I described in post 09, the run produced:

[pipeline/ci]   ✅  Success - Main Restore [3m52.585775875s]
[pipeline/ci]   ✅  Success - Main Build [6.092938958s]
[pipeline/ci]   | Test run summary: Passed!
[pipeline/ci]   |   total: 6
[pipeline/ci]   |   failed: 0
[pipeline/ci]   |   succeeded: 6
[pipeline/ci]   ✅  Success - Main Unit tests [2.14828825s]
[pipeline/ci]   ✅  Success - Main ./.github/actions/dotnet-ci [5m52.734271625s]
[pipeline/ci] 🏁  Job succeeded
[deploy-staging/reusable-deploy/deploy]   | Deploying 13 files to staging at https://staging.bookstore.example
[deploy-staging/reusable-deploy/deploy]   | Token length: 14
[deploy-staging/reusable-deploy/deploy] 🏁  Job succeeded
[pipeline/report        ]   | SDK 10.0.403, deployed 13 files to staging
[pipeline/report        ] 🏁  Job succeeded

The composite action appears in the log as a single step (./.github/actions/dotnet-ci), but its Restore, Build and Unit tests steps are reported individually. Restore taking almost four minutes is because the act container downloaded packages from scratch without a NuGet cache; the NuGet cache from post 05 can be put inside this action too. The reusable workflow's job appears as deploy-staging/reusable-deploy/deploy, that is, the chain of calling job, called workflow and its job. The report job combines outputs arriving by two different routes: the SDK version from the composite action into a job output, the file count from the reusable workflow's output.

Secrets, Environments and secrets: inherit

Secrets do not flow into a reusable workflow on their own. Either you pass them one by one (secrets: DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}, which must also be declared under on.workflow_call.secrets in the called workflow) or you pass every secret the caller can access with secrets: inherit. There is a detail for environment secrets: the calling job cannot use environment:, so you put the environment on the job in the called workflow. According to the documentation, if that job has an environment secret with the same name, it wins; but the caller still has to pass the secret, and if it doesn't, the value is silently an empty string. Declaring required: true turns that silence into an error.

secrets: inherit is convenient but broad: the called workflow sees all of the caller's repository and organization secrets. Calling another repository's reusable workflow in the same organization with inherit means exposing those secrets to anyone who can change that file. Use inherit only for workflows in the same repository, and pass secrets by name everywhere else. There is also a chaining rule: secrets are passed only to the directly called workflow; if A calls B and B calls C, B has to pass them on to C explicitly.

Permissions work similarly: along the chain, GITHUB_TOKEN permissions can only stay the same or narrow. The calling workflow's top-level env: does not reach the called workflow at all; anything that needs to be shared must be an input.

Which One When: A Comparison

TopicComposite actionReusable workflow
Unit it packagesStepsJobs
Where it is calledA step's uses:A job's uses:
Runner and runs-onThe calling job'sDefined in its own jobs
Environments and approvalsNoneenvironment: on the called job
SecretsOnly as inputsBy name or secrets: inherit
How it looks in the logOne step, expandableSeparate jobs
Typical useSetup, build, test stepsDeploy, release, multi-job processes

Roughly: "how to build" is a composite action, "how to deploy" is a reusable workflow. Deploying needs environments, approvals and concurrency, and those live at the job level.

Limits, Versioning and Sharing

Since November 2025, a reusable workflow chain on github.com can go ten levels deep (including the caller), and a workflow file can use at most 50 unique reusable workflows, nested calls included; the previous limits were four levels and 20 workflows. Loops are not allowed. If you are getting close to these limits, the problem is probably not the limit but the design.

The $/ syntax, introduced in July 2026, lets you reference an action in the same repository at the running commit without a checkout: uses: $/.github/actions/dotnet-ci. It takes no @ref, always resolves to the repository of the file it appears in, requires runner 2.336.0 or later and is not available on GitHub Enterprise Server. The tools don't know it yet; actionlint and act both rejected it:

.github/workflows/selfref.yml:9:15: specifying action "$/.github/actions/dotnet-ci" in invalid format because ref is missing. available formats are "{owner}/{repo}@{ref}" or "{owner}/{repo}/{path}@{ref}" [action]

[self-ref/ci] Expected format {org}/{repo}[/path]@ref. Actual '$/.github/actions/dotnet-ci' Input string was not in a correct format

That is why I kept using ./ in the examples. Still, it is worth knowing: the workflow lockfile tool we will see in post 13 cannot lock local ./ actions, so it offers an option that rewrites them to $/.

If you are going to share the action with other repositories, move it into its own repository and version it. The layout GitHub recommends: a full semver tag like v1.2.0 for every release, plus a v1 major tag that users follow and that is moved forward on every compatible release; v2 for breaking changes. If immutable releases, GA (General Availability) since October 2025, are enabled, release tags are locked; in that case you keep v1 as a plain Git tag, not a GitHub release, and move it forward. If you want a shared starting point across the organization, put a .yml plus a .properties.json with the same name (name and description required) into the workflow-templates/ folder of the organization's .github repository; templates show up on the "New workflow" screen in the Actions tab. A template is copied, not referenced; later changes do not reach the old copies. Reusable workflows for shared behavior, templates for a shared starting point.

YAML Anchors: In-File Duplication

Since September 2025, YAML anchors (&name) and aliases (*name) are supported in GitHub Actions workflow files:

name: anchors

on: workflow_dispatch

permissions:
  contents: read

jobs:
  deploy-staging:
    runs-on: ubuntu-latest
    environment: staging
    env: &deploy-env
      APP_URL: ${{ vars.APP_URL }}
      DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
    steps: &deploy-steps
      - uses: actions/checkout@v7
      - name: Deploy
        run: echo "Deploying to $APP_URL as job $GITHUB_JOB"

  deploy-production:
    needs: deploy-staging
    runs-on: ubuntu-latest
    environment: production
    env: *deploy-env
    steps: *deploy-steps

actionlint passed it cleanly, and act ran both jobs with the same steps:

[anchors/deploy-staging]   | Deploying to https://staging.bookstore.example as job deploy-staging
[anchors/deploy-staging] 🏁  Job succeeded
[anchors/deploy-production]   | Deploying to https://staging.bookstore.example as job deploy-production
[anchors/deploy-production] 🏁  Job succeeded

Production also printing the staging address comes from act's single variable pool; on GitHub, vars.APP_URL is resolved from each job's own environment. An anchor copies text, not the evaluated expression; evaluation happens separately in each job.

The second question: merge keys (<<) are not supported. When I tried to merge the same env with <<: *deploy-env and override APP_URL, actionlint said so explicitly, and act refused to start because it did not match the schema:

.github/workflows/merge.yml:17:7: GitHub Actions does not support YAML merge key "<<" [syntax-check]

Error: workflow is not valid. 'merge.yml': Line: 15 Column 5: Failed to match job-factory: Line: 8 Column 10: Expected a scalar got mapping

So with anchors you can only reuse an entire block; for "the same, but with this difference" you need reusable workflow inputs or environment variables. An anchor's scope is also a single file; for sharing across files there are the other two tools.

In Production

Call shared actions and reusable workflows by commit SHA (Secure Hash Algorithm), not by tag, especially if they come from another repository. The uses: lines inside a composite action are part of the caller's supply chain too; we discuss this in detail in post 13.

Treat a reusable workflow's inputs like a contract. Renaming an input breaks every workflow that calls it. Give new inputs a default, and wait for a major version before removing an old one.

Name things so you know where you are in the log. Give composite action steps a name:; reusable workflow job names appear combined with the caller's job name (deploy-staging / deploy). Unnamed steps make "which step failed" harder to answer when something breaks.

Measure debuggability. If tracing an error message to its source takes three repositories and four files, the abstraction is too deep. Keep the number of levels at two.

Common Pitfalls

Forgetting shell: in a composite action. A field that workflows don't need is mandatory here.

Using the secrets context in a composite action. Secrets have to come in as inputs.

Adding steps:, runs-on: or environment: to a job that calls a reusable workflow. The calling job can only take name, uses, with, secrets, strategy, needs, if, concurrency, permissions and the new cache-mode.

Not making the secret required: true in the called workflow. A forgotten secret does not error, it arrives empty; and the deploy is attempted without a token.

Mistaking a workflow template for "central configuration". A template is copied; put behavior you want to update into a reusable workflow.

Trying to build "almost identical" blocks with anchors. There are no merge keys; it's the whole block or nothing.

A Small Experiment

★☆☆ Add a boolean input called run-tests to the dotnet-ci composite action and make the test step conditional with if: inputs.run-tests == 'true'. Verify with act why inputs are compared as strings.

★★☆ Add a deploy-production job to pipeline.yml that calls the same reusable workflow with environment: production and runs only on v* tags. Instead of secrets: inherit, pass the secret by name and declare it required: true in the called workflow.

★★★ Call the deploy job with a matrix: strategy.matrix.target: [staging, qa] and with.environment: ${{ matrix.target }}. Which matrix run does the called workflow's deployed-files output come from? Compare the rule in the documentation with act's output.

We reduced duplication, but every uses: line we wrote in this post trusts a repository whose code isn't ours. In the next post we look at how that trust gets abused (the tj-actions case), SHA pinning, workflow lockfiles, the pitfalls of pull_request_target and protection against script injection.