secrets: inherit not working in reusable workflows
secrets: inherit not working in reusable workflows is two different failures wearing one name, and only one of them prints anything. Either the run is rejected before a job starts, which is loud and precise, or the called workflow reads an empty string and fails later on whatever it tried to authenticate to.

What this error means
Start by asking whether any job ran at all. If the run has zero jobs and a startup failure, GitHub rejected the workflow before scheduling it, and the message names the secret and the side it is missing from. That is the loud shape, and it is what you get from explicit forwarding, never from inherit. If jobs did run and a step failed deep inside the called workflow with a 401, a blank token or an empty environment variable, that is the quiet shape: the expression ${{ secrets.NAME }} resolved to an empty string and GitHub printed nothing about it, because a secret that was never passed is not an error in an expression. The two have opposite fixes, so the first thing to read is the run's job count, not the step log.
Invalid secret, GH_BOT_CLIENT_ID is not defined in the referenced workflow.
Invalid secret, GH_BOT_PRIVATE_KEY is not defined in the referenced workflow.The two ways to pass a secret, and what each one costs
A reusable workflow receives nothing by default. There are exactly two ways to give it a secret and they place the burden in different places, which is why swapping one for the other breaks a working pipeline. Everything in this table is from GitHub's workflow syntax reference and its reuse-workflows guide.
secrets: inherit | A named secrets: map | |
|---|---|---|
| Caller writes | One line | One line per secret |
Called workflow must declare it under on.workflow_call.secrets | No | Yes |
| What an undeclared name does | Nothing, it is still readable | Rejects the run at startup |
| What it sends | All secrets the caller has access to | Only the names you list |
| Survives a second hop on its own | No | No |
Common causes
The caller forwards nothing at all
The called workflow references secrets.SOMETHING and the calling job has neither a secrets: map nor secrets: inherit. Nothing is rejected, because a caller is allowed to pass no secrets. The expression resolves to an empty string wherever it appears and the first tool that needs it fails with its own message.
The caller switched to a named map and the callee never declared the names
This is the loud one, and it is what a move toward least privilege produces. GitHub validates that every name in the map is declared by the called workflow under on.workflow_call.secrets, and rejects the run with Invalid secret, NAME is not defined in the referenced workflow. The run ends as a startup failure with zero jobs, so there is no step log to open and local linters stay green because they do not resolve a remote reusable workflow.
The secret is an environment secret, which no caller can forward
Subtle, silent, and the one that survives review. An environment secret cannot be passed from a caller at all, and the list of permitted keywords is why: "When you call a reusable workflow, you can only use the following keywords in the job containing the call", and environment is not on it. So the caller has no environment to inherit from, whichever forwarding style it uses. The key belongs on a job inside the called workflow, which then reads the secret itself.
The chain is deeper than one call and a middle link forwards nothing
A > B > C where A inherits into B and B passes nothing into C. Both jobs run, so the run looks healthy, and only C is short a secret. In our experience this appears when a reusable workflow is refactored into two and the new middle layer is written by copying the old caller, which had no forwarding line because it did not need one.
How to fix it
Read the job count before the step log
- Open the run. If it shows a startup failure and no jobs, the caller named a secret the callee does not declare, and the message names it.
- If jobs ran and one step failed, the value arrived empty; the caller passed nothing, or passed something it did not itself have.
- Only then open the workflow files, and open the one the run told you to.
Declare the secrets the called workflow uses
Worth doing even if every caller uses inherit today, because it is what makes the explicit form possible and it documents the contract. A declaration marked required: false cannot change behavior for an existing caller under any forwarding style, so it is a safe addition to a shared workflow with callers you do not control.
on:
workflow_call:
secrets:
DEPLOY_TOKEN:
required: false
APP_PRIVATE_KEY:
required: falseForward at every level of a nested chain
Put a forwarding line on each calling job, not only the outermost one. inherit at every level is the blunt version and is fine inside one repository. Where a hop crosses into a repository you trust less, switch that hop to a named map and accept that the callee must declare the names.
Move the environment into the called workflow when the secret lives in one
If the value is stored on an environment, put the environment: key on the job inside the reusable workflow, never on the job that calls it. GitHub states the rule and its effect in one sentence: "If you include environment in the reusable workflow at the job level, the environment secret will be used, and not the secret passed from the caller workflow." If you want the secret without a deployment record against it, GitHub supports opting out of deployment creation while keeping the environment's secrets.
# the caller: a job that uses `uses:` may not carry an environment: key
jobs:
call-deploy:
uses: ./.github/workflows/deploy.yml
secrets: inherit
# deploy.yml, the called workflow: the environment goes on the job here
jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: production
deployment: false
steps:
- run: ./deploy.shWhat inherit actually inherits
The reference is one sentence and it is worth reading closely: "Use the inherit keyword to pass all the calling workflow's secrets to the called workflow. This includes all secrets the calling workflow has access to, namely organization, repository, and environment secrets. The inherit keyword can be used to pass secrets across repositories within the same organization, or across organizations within the same enterprise."
The load-bearing phrase is "the calling workflow has access to". inherit copies a set; it does not widen one, and for environment secrets that set is always empty. The same guide says why, in a warning: "Environment secrets cannot be passed from the caller workflow as on.workflow_call does not support the environment keyword. If you include environment in the reusable workflow at the job level, the environment secret will be used, and not the secret passed from the caller workflow." A job that calls a reusable workflow cannot carry an environment: key at all, so no forwarding style reaches an environment secret. The repair is on the other side of the call: environment secrets "are read when a job referencing the environment starts", and that job is the one inside the called workflow.
Inheritance stops at the first hop
A chain of reusable workflows does not inherit transitively, and the guide says so: "Secrets are only passed to directly called workflow, so in the workflow chain A > B > C, workflow C will only receive secrets from A if they have been passed from A to B, and then from B to C." Its own worked example is a caller that writes secrets: inherit # pass all secrets into B, and B forwarding a single named secret into C. "Any of the other secrets passed to workflow B are not available to workflow C."
So inherit on the outermost caller and nothing on the middle one is the shape that breaks, and it breaks quietly: B runs, C runs, and one step inside C authenticates with an empty string. Every level has to forward, and a level that forwards by name has to have that name declared by the level below it.
jobs:
a-calls-b:
uses: ./.github/workflows/b.yml
secrets: inherit
# in b.yml, forwarding onward is a second decision
jobs:
b-calls-c:
uses: ./.github/workflows/c.yml
secrets: inheritWhy there is no recorded run on this page
The loud shape never reaches a runner: GitHub validates the caller against the called workflow and refuses to schedule the run, so there is no job and no log to record. The quiet shape does reach a runner, but what it produces is an empty string, and whatever a job does with an empty string afterwards is that tool's failure, not this one. Recording either would teach a reader nothing they cannot read from their own run's job count. The startup failure above is quoted from a public pull request, and the workflows here are illustrative.
How to prevent it
- Declare every secret a reusable workflow reads under
on.workflow_call.secrets, so both calling styles work. - Keep
inheritfor calls inside one repository and name the secrets when a call leaves it. - Echo the length of a secret, never its value, in the first step of a called workflow while you are wiring one up.
- Treat a startup failure with zero jobs as a caller-and-callee mismatch, not as a broken runner.
Frequently asked questions
Does secrets: inherit require the called workflow to declare each secret?
secrets: inherit, "you can reference them even if they are not explicitly defined in the on key". A named secrets: map is the opposite: every name in it must be declared under on.workflow_call.secrets or the run is rejected before any job starts.Why do nested reusable workflows lose the inherited secrets?
Does secrets: inherit pass environment secrets too?
inherit passes, but the reuse-workflows guide carves the last one out: "Environment secrets cannot be passed from the caller workflow as on.workflow_call does not support the environment keyword." A calling job has no way to name an environment, so put environment: on a job inside the called workflow and that job reads the secret itself.Can secrets: inherit pass secrets to a workflow in another repository?
inherit keyword can be used to pass secrets across repositories within the same organization, or across organizations within the same enterprise." It sends everything the caller has, so for a call that leaves your repository a named map is usually the better trade.Related guides
References
- GitHub Actions: workflow syntax, jobs.<job_id>.secrets.inherit
- GitHub Actions: reuse workflows, passing secrets to nested workflows
- GitHub Actions: secrets reference, when Actions reads secrets
- GitHub Actions: reusing workflow configurations, supported keywords for a calling job
- LedgerHQ/ledger-live#22146: a caller rejected for secrets the callee never declared
- GitHub Actions documentation