Maximum of twenty unique reusable workflows limit

Juraj SimonJuraj Simon
10 min read

You’ll hit this limit of max. 20 reusable workflows pretty soon on monorepos or when migrating legacy pipelines from systems like GitLab or Azure Pipelines.

The problem

The issue is that developers are often accustomed to having a single starting point for a large pipeline, divided into many files, that manages everything. For example, in a monorepo, it might be convenient to have one main workflow/pipeline, like all.yaml, which runs inside app1.yaml and app2.yaml, etc.

💡
GitHub actions (GA) are happier if files and workflows are organized a bit differently. Everything starts with an event (usually push or pull_request) that triggers one or more workflows. So ideally, you would have many workflows handling one or two events that later on will call many reusable workflows. Also, in the case of monorepos, I’d say it is better to split workflows additionally per project/directory to keep things clear. The disadvantage of this is (very) low readability. All workflows have to be in one single .github/workflows directory (no subdirectories are supported), so you have to be creative with file names.

GA supports this “single-startpoint” approach, with their reusable workflows feature. But soon enough you’ll hit the limit of max. 20 reusable workflows:

You can call a maximum of 20 unique reusable workflows from a single workflow file. This limit includes any trees of nested reusable workflows that may be called starting from your top-level caller workflow file.

Here is an example workflow. Imagine you have a monorepo with 20+ libraries/projects and each one of those has its own workflow for some reason.

name: Too many workflows

on:
  workflow_dispatch:
    inputs: {}

# Example: monorepo with 21 libraries 
# where every lib has its own unique workflow
jobs:
  library-1:
    name: Library 1
    uses: ./.github/workflows/library-1.yaml
    secrets: inherit
  library-2:
    name: Library 2
    uses: ./.github/workflows/library-2.yaml
    secrets: inherit
#  ...
  library-21:
    name: Library 21
    uses: ./.github/workflows/library-21.yaml
    secrets: inherit

Notice that every library has its own workflow unique file (it would not count if you used a single parametrised workflow file many times).

If you try to run this workflow, you’ll get an error saying

Invalid workflow file: .github/workflows/too-many-sub-workflows.yaml#L1
too many workflows are referenced, total: 21, limit: 20

Solutions

There are many ways around this limit. The two most common ones I’ve tried were:

  • Not using resuable workflows at all, keep one single workflow YAML file instead.

  • Switch reusable workflows to dispatchable ones and dispatch them with workflow_dispatch event instead.

One big YAML workflow file

This is pretty straightforward and can be a solution for smaller projects. There are a few limitations, though:

  • readability: Reading through large YAML is not pleasant at all. Even good editors like PHPStorm or VSCode can struggle when reading a huge structured file.

  • 512Kb max. workflow file size: I tested this experimentally because I didn’t find it in GA docs. If your workflow file is bigger, you’ll get this error message on the workflow run page: Workflow file too large.

  • you can’t use YAML anchors: to make the workflow smaller and more readable. Here is an example of a workflow file with anchors. But you can’t call or dispatch it. Anchors are not allowed. People are asking for this feature all the time, but GitHub is not addressing it.

So if you care about your mental health, this is probably not the best option.

Dispatching workflows

This is a decent solution with some benefits:

  • Workflows can be in separate files for better readability

  • There is no limit (that I’m aware of) on how many workflows you can dispatch in one run

There are also some problems too:

  • You need 2x more runners to handle this. One that will dispatch the workflow and one that will execute it.

  • The dispatcher job will use GitHub rest API to pull the status of the dispatched workflow. In some situations it can eat up a big portion out of your hourly API limit.

  • Dispatching a workflow is more complex (more code) than using reusable workflows.

  • Dispatched workflows have a limited number of input parameters (only 10) that you can use.

  • The total length of all inputs together can be max. 65,535 characters.

  • The dispatched workflow has no connection whatsoever to the dispatcher. Navigating between dispatching and dispatched workflows is a nightmare (close to impossible) with the native GitHub UI.

  • Dispatchable workflow will be possible to manually call from GitHub UI (might be a problem to somebody).

In reality, you’ll end up with a combination of reusable and normal workflows.

In this post, I’ll focus on how to simply convert a reusable workflow into a dispatchable one, which I think is the easiest way to work around the reusable workflows count limit.

I’ll describe the rest of the problems and their solutions in the following articles in this series.

Workflow or repository dispatch?

💡
There is also a similar event repository_dispatch. It is possible to use it to solve this problem as well, but I think the solution will be unnecessarily complex.

To solve the reusable workflow limit problem described above, I’d always go with workflow_dispatch because:

  • It is a single-purpose event to run a single workflow in some repository.

  • It can define an optional ref, which is handy when you’re dispatching multiple workflows on the same branch (or pull request). All checkouts in dispatched workflows will switch to that ref automatically.

Problems with repository_dispatch:

  • More complex as it can define its own custom events (which might or might not be needed).

  • There are no inputs. Instead, data are passed as client payload in JSON format. This can fix max. inputs count limit (10), but it is more complex to set and read afterward in the dispatched workflow.

  • This JSON payload still has a maximum size limit of 65,535 characters.

  • You can’t define the ref that should be used (it always uses the default branch). You have to define it manually when sending data and then checkout it explicitly in the dispatched workflow.

I’ll get to this event later in the series and how I think it can be used better than for getting around max. reusable workflows limit.

Switch “calling” to “dispatching” in the top workflow

So let’s say my workflow (using many reusable workflows) is hitting the limit for some reason, and I just want to make it work again asap. Here is an example:

name: Workflow using reusable WFs

on:
  workflow_dispatch:
    inputs: {}

jobs:
  library-1:
    name: Library 1
    uses: ./.github/workflows/reusable-library-1.yaml
    secrets: inherit
    with:
        build: true
        test: true
  library-2:
    name: Library 2
    uses: ./.github/workflows/reusable-library-2.yaml
    secrets: inherit
    with:
      build: true
      test: true
  library-3:
    name: Library 3
    uses: ./.github/workflows/reusable-library-3.yaml
    secrets: inherit
    with:
      build: true
      test: true
#...another XY unique workflows
# full workflow here: https://github.com/roamingowl/gh-workflow-dispatch-example/blob/main/.github/workflows/using-reusable-workflows.yml

To switch it to dispatch, we can do it quickly with some extra GitHub action capable of dispatching. There are many of them in the GitHub actions marketplace. Pick whatever suits your needs.

💡
I’d recommend here picking up some action capable of waiting for the result of the dispatched workflow. It might come handy depending on your use case. Like actions/workflow-dispatch-and-wait, which is straightforward and can be easily extended to any specific needs.

By using the mentioned action, I end up with a noticeably bigger workflow:

name: Workflow using workflow_dispatch

on:
  workflow_dispatch:
    inputs: {}

permissions: # important!
  actions: write

jobs:
  library-1:
    runs-on: ubuntu-latest
    steps:
      - uses: the-actions-org/workflow-dispatch@v4
        with:
          workflow: library-1.yaml
          token: ${{ secrets.GITHUB_TOKEN }}
          wait-for-completion: true
          display-workflow-run-url: true
          inputs: |-
            {
              "build": "true",
              "test": "true"
            }
  library-2:
    runs-on: ubuntu-latest
    steps:
      - uses: the-actions-org/workflow-dispatch@v4
        with:
          workflow: library-2.yaml
          token: ${{ secrets.GITHUB_TOKEN }}
          wait-for-completion: true
          display-workflow-run-url: true
          inputs: |-
            {
              "build": "true",
              "test": "true"
            }
#...etc for another job. See full file here: 
# https://github.com/roamingowl/gh-workflow-dispatch-example/blob/main/.github/workflows/using-workflow-dispatch.yml

What happened here:

  • I now have to use two runners: one runner just to dispatch (and wait for) the actual workflow I want to run, and the second one to actually run the dispatched workflow. (runners needed x2)

  • JSON inputs: inputs of the dispatched workflow need to be defined as a JSON string (but you can still send only a maximum of 10 inputs).

  • Permissions: you need to request higher permissions with

permissions:
  actions: write

Otherwise you’ll get an error like this: Resource not accessible by integration - https://docs.github.com/rest/actions/workflows#create-a-workflow-dispatch-event.

Another option is to use PAT with this permission allowed. Then there is no need to use permissions section in the workflow.

💡
To mimic reusable workflow behavior, the dispatching action can also wait in loop for workflow completion if parameter wait-for-completion: true is set.

Convert a reusable workflow to a dispatchable one

The original main workflow from above was calling following reusable one:

name: "[R] Library 1"

on:
# reusable workflow reacts on workflow_call
  workflow_call:
    inputs:
        build:
            required: false
            type: boolean
            description: "Whether to build the project"
            default: true
        test:
            required: false
            type: boolean
            description: "Whether to test the project"
            default: true

jobs:
  test:
    name: Test
    runs-on: ubuntu-latest
    if: ${{ inputs.test }}
    steps:
      - uses: actions/checkout@v4
      - name: Fake test
        shell: bash
        run: echo "Bundling..."

  build:
    name: Build
    runs-on: ubuntu-latest
    if: ${{ inputs.build }}
    steps:
      - uses: actions/checkout@v4
      - name: Fake build
        shell: bash
        run: echo "Bundling..."

To convert it to a dispatchable one, you have to switch the event handler at the top from workflow_call to workflow_dispatch like this:

name: "Library 1"

on:
# dispatchable workflow reacts on workflow_dispatch. No workflow_call here
  workflow_dispatch:
    inputs:
      build:
        description: "Build the library"
        required: true
        type: boolean
        default: true
      test:
        description: "Run the tests"
        required: true
        type: boolean
        default: true
# ...the rest is the same as in original workflow

Things to notice here:

  • You can use both workflow_call (reusable workflows) and workflow_dispatch together. The workflow will be reusable and dispatchable at the same time. But I’d strongly oppose that. Workflows then become unnecessarily complex, and you’ll get into conditional hell with conditional jobs later.

  • As a side effect, all your dispatchable workflows will be possible to manually run from the GitHub UI.

Run view and logs

The resulting run chart/map looks OK but is not nearly as nice as the one with reusable workflows (I mean visually in GitHub UI):

Yes, by using dispatch action, we see only dispatchers and lost the overview of any dispatched workflows, their progress and logs.

Reusable run workflow for comparison here:

The difference is day and night.

Additionally:

  • The new workflow takes longer to complete because it internally polls the GitHub API to get the dispatched workflow status.

  • The poll interval can be configured by the wait-for-completion-interval input parameter of the action. But don’t set it too low, otherwise, it can quickly eat up through your GitHub API hourly limit for longer-running workflows (it polls the entire time the dispatched workflow runs if wait-for-completion: true is set).

Action actions/workflow-dispatch-and-wait offers small help to navigating to dispatched workflows. By enabling param display-workflow-run-url: true you’ll be able to see the direct URL of the dispatched workflow in the logs. For example:

By clicking on that link in

You can follow the running workflow here: https://github.com/roamingowl/gh-workflow-dispatch-example/actions/runs/11462284035

you’ll be redirected to the dispatched workflow run.

Without this link in the logs, you can find the dispatched workflow run only by going to the repository actions tab. From there, select the correct workflow by its name from the left sidebar and then choose the right run from the list.

💡
There is no mention about dispatcher workflow (where you came from) in the dispatched workflow. Dispatched and dispatcher workflows are not related/connected at all (by id, link or any data).

And that’s it. Now I have one main workflow that dispatches as many workflows inside as I want and will succeed only if all of them do too.

Conclusion

If you’re using reusable workflows and you hit the limit described above, you can easily switch to the hybrid approach of using reusable workflows for smaller things (like apps, libs or connected workflows) and dispatching to orchestrate them together. Example hybrid structure:

  • main workflow → dispatch

    • app1 workflow (dispatchable) → call

      • lint workflow (reusable)

      • build workflow (reusable)

      • deploy workflow (reusable)

    • lib1 workflow (dispatchable) → call

      • lint workflow (reusable)

      • unit tests workflow (reusable)

      • publish package (reusable)

With this approach, you can scale your workflow almost infinitely while still maintaining some code readability and at least partial clarity on the call/dispatch structure.

Up next

In the next article in the series, I’m returning to the issue of navigating between the dispatching and dispatched workflows. I’ll share some practical solutions that helped me improve it a little.

Happy coding. 👋

0
Subscribe to my newsletter

Read articles from Juraj Simon directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Juraj Simon
Juraj Simon

DevOps/FullStack node.js developer. Passionate about automation and efficiency. Learning how to write... :)