Maximum of twenty unique reusable workflows limit
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.
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?
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.
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.
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) andworkflow_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.
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. 👋
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... :)