Skip to content

    Back to articles

    6 min read

    Deeper workflow nesting needs a legible call graph

    More depth is not a design goal

    A reusable workflow should hide repeated implementation, not hide what a pipeline does. That distinction matters more as a workflow grows deeper: GitHub’s reusable workflow documentation allows up to ten connected workflow levels, including the caller, and a maximum of 50 unique reusable workflows from a single workflow file. Those limits make more compositions possible. They do not make a long chain easier to understand.

    I would use the extra depth to clarify ownership and reuse. A repository workflow should show the stages relevant to that repository. Shared workflows should own cohesive capabilities, such as building an artifact or deploying it, and call further workflows only where that removes real duplication. If a reviewer has to follow a chain of generic wrappers just to learn which environment receives a deployment, the abstraction has gone too far.

    The practical test I use is simple: can someone reviewing the caller identify its inputs, the permissions it grants, and the path from a change to deployment without reconstructing hidden behavior from several files? If not, the graph needs to be clearer before it needs another layer.

    Give every edge a reason

    A call graph is more useful when each edge corresponds to a boundary someone can explain. A top-level workflow might call a build workflow, then a deployment workflow. The build workflow might delegate a language-specific check to a shared workflow. That can be sensible if the language check has a clear owner and stable interface. It is less sensible if every file exists only to pass through a call to another file.

    For example, this caller makes its two main responsibilities and their ordering visible. The references are illustrative; a real repository would point to workflows it can access and maintain.

    name: Build and deploy
    
    on:
      push:
        branches: [main]
    
    permissions:
      contents: read
    
    jobs:
      build:
        uses: ./.github/workflows/build.yml
        with:
          runtime: dotnet
        permissions:
          contents: read
    
      deploy:
        needs: build
        uses: example-org/platform-ci/.github/workflows/deploy.yml@v3
        with:
          environment: production
        permissions:
          contents: read
          id-token: write

    The point is not that every pipeline should have exactly these two calls. It is that a call site should answer the first questions a reviewer has: what capability is invoked, what does it receive, what can it access, and when does it run? If the deployment target is hidden several layers away, make that path easy to follow in the shared workflow’s name, interface, and documentation.

    I would keep workflow names and inputs aligned with the work they perform. deploy.yml with an environment input is easier to reason about than common.yml with a string that changes meaning by caller. A small, explicit interface also makes it easier to identify which repositories depend on a shared workflow when that interface changes.

    Make permissions and inputs part of the graph

    A call graph that shows only file names is incomplete. Inputs determine what a called workflow does; permissions determine what it can do. Both belong in the review, at the call site and at the reusable workflow boundary.

    GitHub documents that permissions for GITHUB_TOKEN can be maintained or reduced as workflows are nested, but not elevated by a called workflow; it also notes that secrets are not automatically passed to reusable workflows. See GitHub’s guidance on reusing workflow configurations and passing secrets to reusable workflows. I take that as a reason to make the permission path deliberate, rather than scattering broad permissions across a chain and hoping the final job uses them safely.

    For each edge, I want to know which inputs cross it, whether any secret crosses it, and what token permissions are available downstream. A deploy workflow should not receive a powerful credential merely because an intermediate wrapper forwards every secret it was given. Conversely, a workflow that needs a permission should declare that need plainly enough that the caller and the reviewer can see it. The shape of the graph should help explain the security boundary, not obscure it.

    This is also where narrow inputs pay off. Passing a specific environment or artifact identifier communicates intent. Passing an ambiguous flag, or a bundle of loosely related values that downstream workflows interpret differently, turns the caller into a poor contract. When a shared workflow’s behavior depends on a choice, expose that choice as a named input and keep its meaning consistent. If a choice is only relevant to one consumer, it may belong in that consumer’s workflow instead of in a shared interface.

    Test the composition, not just the pieces

    A workflow can be valid in isolation and still be wrong in composition. A caller may pass an unexpected value, a nested workflow may be inaccessible to the initial caller, or a change to a shared workflow may alter what downstream repositories do. GitHub’s documentation notes that every workflow in a nested chain must be accessible to the caller, and that loops in the workflow tree are not permitted. Those constraints are worth checking alongside the YAML syntax, not after a change has already spread.

    I would test shared workflow changes against representative callers and make the supported interface explicit. That means checking more than whether the reusable workflow starts: verify its inputs, permissions, expected outputs, and the deployment path that results. A test caller should exercise the composition a real repository uses, rather than just invoke the deepest workflow directly. When a shared workflow is used across many repositories, I would also make its version reference and the process for adopting a breaking change visible. A reference that can move independently of a caller makes review and diagnosis harder; choose a pinning and update policy that fits the team’s release practice.

    GitHub provides a visualization graph for monitoring a workflow run. That is useful for following an actual run, but it is not a substitute for a legible design-time graph: the caller and shared workflow files still need to reveal ownership, inputs, permissions, and intent when someone reviews a change.

    Deeper nesting is useful when it gives a shared capability a clear owner and keeps callers concise without hiding their behavior. If another layer makes the deployment route or security boundary harder to explain, I would flatten it. The limit is not the number of files GitHub permits; it is whether a reviewer can trace the change.

    Drafted with AI assistance, reviewed and edited by Ali.