Skip to content
Latchkey LogoLatchkey home

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 crosses the workflow_call boundary from a caller into a called workflow
Three things cross the call and two do not. The two that do not are the two people reach for first.

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.

Quoted from the title of actions/runner#2372
Unrecognized named-value: 'env'. Located at position 1 within expression

The 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.

.github/workflows/ci.yml and build.yml (illustrative)
# 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.

.github/workflows/build.yml and ci.yml (illustrative)
# build.yml
on:
  workflow_call:
    inputs:
      app_env:
        type: string
        required: true

# ci.yml
jobs:
  call:
    uses: ./.github/workflows/build.yml
    with:
      app_env: staging

Use 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.

.github/workflows/build.yml (illustrative)
    steps:
      - run: ./deploy.sh --env "${{ vars.APP_ENV }}"

Publish a computed value as a job output first

  1. Compute the value in an ordinary job in the caller and write it to $GITHUB_OUTPUT.
  2. Map it to a job output with an outputs block on that job.
  3. Add needs on the calling job so the needs context resolves.
  4. Pass it through with, reading needs.<job>.outputs.<name>, which is a context that key accepts.
.github/workflows/ci.yml (illustrative)
  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.

.github/workflows/build.yml (illustrative)
# build.yml
on:
  workflow_call:
env:
  NODE_ENV: production

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - run: npm run build

The 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.

actions/runner, src/Sdk/WorkflowParser/workflow-v1.0.json, abridged
"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.

.github/workflows/ci.yml (illustrative)
jobs:
  call:
    uses: ./.github/workflows/build.yml
    with:
      app_env: ${{ vars.APP_ENV }}      # allowed
      # app_env: ${{ env.APP_ENV }}     # rejected: env is not offered here

What 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 callerCrosses into the called workflowHow to get the value across
env at workflow levelnodeclare an input, pass a literal or a vars value
env on the calling jobthe key is not allowed at allsame as above
with, against declared inputsyesthis is the mechanism
secrets, named or inherityessecrets: inherit, or a named map
vars at repository or organization levelreadable on both sidesnothing to do
a value written to GITHUB_ENV by a stepnopublish 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 vars so 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?
Because it was never sent. The documented limitations for reusable workflows state that environment variables set in an 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?
No. Once the parser reads 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?
Because the context list for that key does not include 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?
Yes, but only when you send them. Use a named 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

A shared workflow is debugged across two repositories. Latchkey runs both at $0.0025/min. Start free → 30-day trial · No credit card