GitHub Actions reusable workflow env not inherited by the called file
GitHub Actions reusable workflow env not inherited is documented behavior rather than a bug, and the restriction is stricter than most people expect: the calling job cannot even declare an env block, because the schema shape for a job with uses has no such key. Values cross that boundary as inputs, as secrets or as organization variables, and as nothing else.

What this error means
The called workflow reads a variable the caller set and gets an empty string. The same env block works perfectly for the caller's own jobs, which is what makes this read as a scoping subtlety rather than a hard boundary. If you then try the obvious repairs, they fail in two different ways: adding env to the calling job is rejected when the file is parsed, and passing the value through with using an env expression is rejected as an unrecognized named-value. Neither message mentions reusable workflows.
Unrecognized named-value: 'env'. Located at position 1 within expressionThe caller and the callee, and where the value stops
These files are written for this page and have never been run. The caller sets a workflow-level env and calls a reusable workflow; the reusable workflow reads the variable and finds nothing. Both files are valid, both jobs are green, and the value is simply not there.
The documentation states it in the limitations for reusable workflows: "Any environment variables set in an env context defined at the workflow level in the caller workflow are not propagated to the called workflow." The reverse is listed on the next line: variables set in the called workflow are not accessible in the caller's env context either, and outputs are the way back.
# ci.yml, the caller
name: ci
on: push
env:
APP_ENV: staging
jobs:
call:
uses: ./.github/workflows/build.yml
# build.yml, the called workflow
on:
workflow_call:
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo "env is [${{ env.APP_ENV }}]" # prints: env is []Common causes
Expecting workflow-level env to propagate
The commonest cause and the most reasonable expectation, because env does reach every job and every step inside the file that declares it. The boundary is the call, not the file, and the documentation lists the behavior among the limitations of reusable workflows rather than among the rules for env, which is why it is easy to miss.
Treating a called workflow like a composite action
A composite action runs inside the calling job and shares its environment, so habits formed there transfer badly. A called workflow runs as its own jobs, on its own runners, with a fresh environment, so there is no shared process for a variable to survive in.
Passing the value with an env expression
Reaching for with: key: ${{ env.VALUE }} on the calling job. The context list for that key does not include env, so this fails as an unrecognized named-value rather than as an empty string, and the message names the context instead of the boundary.
Computing the value in a step of the caller
Writing to $GITHUB_ENV in one job and expecting a later calling job to see it. Environment changes do not cross jobs at all, reusable workflow or not, and the call is a job rather than a step, so there is nowhere for a step-scoped value to be read from.
How to fix it
Declare an input and pass the value explicitly
Give the reusable workflow a typed input and pass it from the caller. This is the documented mechanism, it is readable at the call site, and it makes the contract between the two files visible to anyone editing either one.
# build.yml
on:
workflow_call:
inputs:
app_env:
type: string
required: true
# ci.yml
jobs:
call:
uses: ./.github/workflows/build.yml
with:
app_env: stagingUse a variable for anything shared across workflows
Repository and organization variables are readable in both files without being passed, which removes the plumbing entirely. The documentation makes the same recommendation for reusing values across workflows, and it is the right answer for things like an environment name, a region or a registry host.
steps:
- run: ./deploy.sh --env "${{ vars.APP_ENV }}"Publish a computed value as a job output first
- Compute the value in an ordinary job in the caller and write it to
$GITHUB_OUTPUT. - Map it to a job output with an
outputsblock on that job. - Add
needson the calling job so theneedscontext resolves. - Pass it through
with, readingneeds.<job>.outputs.<name>, which is a context that key accepts.
call:
needs: prepare
uses: ./.github/workflows/build.yml
with:
app_env: ${{ needs.prepare.outputs.app_env }}Set static values inside the called workflow
When the value never varies by caller, stop passing it. An env block in the called workflow is perfectly ordinary and applies to its own jobs and steps, and it keeps the input list short enough that the ones that remain are the ones that genuinely differ.
# build.yml
on:
workflow_call:
env:
NODE_ENV: production
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: npm run buildThe calling job has a different set of keys
A job in the schema is one of two shapes. The first has runs-on and steps and everything that goes with them. The second, which is the one selected as soon as the parser reads a uses key, has exactly nine properties: name, uses, with, secrets, needs, if, permissions, concurrency and strategy. The documentation publishes the same list under "Supported keywords for jobs that call a reusable workflow".
The consequence is mechanical. The template reader narrows the candidate shapes as it reads keys, so uses commits the job to the second shape, and the next key that shape does not know is reported with the unexpected-value message. An env block on a calling job is not ignored and does not silently do nothing: the file is rejected before the run starts.
"workflow-job": {
"mapping": {
"properties": {
"name": { "type": "string-strategy-context" },
"uses": { "type": "non-empty-string", "required": true },
"with": "workflow-job-with",
"secrets": "workflow-job-secrets",
"needs": "needs",
"if": "job-if",
"permissions": "permissions",
"concurrency": "job-concurrency",
"strategy": "strategy"
}
}
}The second repair that does not work either
Having lost the env block, the natural next move is to pass the value through with, reading it from the caller's environment. That is rejected too, and this is the one worth knowing because the message says nothing about reusable workflows. The value type for jobs.<job_id>.with.<input_id> allows only the github, inputs, vars, needs, strategy and matrix contexts, and the documented availability table agrees. env is not among them.
This was reported as actions/runner#2372, whose title is the message itself: "Unrecognized named-value: 'env'. Located at position 1 within expression" when used in reusable workflow jobs. So the value has to come from somewhere a calling job is allowed to read: a literal, an input of the caller, a repository or organization variable, or an output of an earlier job.
jobs:
call:
uses: ./.github/workflows/build.yml
with:
app_env: ${{ vars.APP_ENV }} # allowed
# app_env: ${{ env.APP_ENV }} # rejected: env is not offered hereWhat crosses the boundary
Three things cross and two do not. Reading the table once is usually enough to stop reaching for the two that do not, and it also explains why vars is the quiet winner for configuration: it is not passed at all, it is simply readable on both sides.
The last row is worth its own sentence. The documentation notes that reusable workflows "are called directly within a job, and not from within a job step", so there is no step in the caller that could write to GITHUB_ENV for the call to pick up. A value computed at runtime therefore has to be published as a job output first, and read through needs when the call is made.
| Set in the caller | Crosses into the called workflow | How to get the value across |
|---|---|---|
env at workflow level | no | declare an input, pass a literal or a vars value |
env on the calling job | the key is not allowed at all | same as above |
with, against declared inputs | yes | this is the mechanism |
secrets, named or inherit | yes | secrets: inherit, or a named map |
vars at repository or organization level | readable on both sides | nothing to do |
a value written to GITHUB_ENV by a step | no | publish it as a job output, pass it via needs |
Why there is no recorded run on this page
Half of this page is about a file that never starts, so for that half there is no job, no runner and no log: the env block on a calling job is rejected during parsing, as is the with expression that reads it. The other half is about an absence, where a variable that did not cross the boundary reads as an empty string, and an empty string in a log is indistinguishable from a variable that was never set, misspelled, or set to nothing on purpose. A recorded run would therefore show either nothing at all or a blank where a value should be, and neither would be evidence for which of those it was. The schema and the documented limitation, both quoted above, say it without ambiguity.
How to prevent it
- Treat a reusable workflow as a separate file with a declared interface, not as an include.
- Keep cross-workflow configuration in
varsso neither side has to pass it. - Give every input a
description, so the contract is readable at the call site. - Remember that a calling job accepts only nine keys, and check that list before adding one.
Frequently asked questions
Why is env empty inside a called workflow?
env context at the workflow level in the caller are not propagated to the called workflow. The called workflow runs as its own jobs with a fresh environment, so there is nothing to inherit.Can I add an env block to a job that calls a reusable workflow?
uses, the job is matched against a shape whose only properties are name, uses, with, secrets, needs, if, permissions, concurrency and strategy. Any other key, including env, is reported as an unexpected value and the file is rejected.Why does with: ${{ env.X }} fail on a calling job?
env. Only github, inputs, vars, needs, strategy and matrix are offered there, so the expression parser reports an unrecognized named-value. Pass a literal, a vars value, or an output read through needs instead.Do secrets cross the workflow_call boundary?
secrets map, or secrets: inherit to pass everything the caller can see. Secrets reach only directly called workflows, so in a chain of three, the middle workflow has to pass them on explicitly.Related guides
References
- GitHub Actions: reusing workflow configurations, limitations and supported keywords
- GitHub Actions: reuse workflows, inputs and secrets
- GitHub Actions: contexts reference and context availability
- actions/runner#2372: unrecognized named-value env in a calling job
- actions/runner: workflow-v1.0.json, the two shapes a job can take
- GitHub Actions documentation