DEV Community

Cover image for GitHub Actions reusable workflow env not inherited by the called file
Kay
Kay

Posted on Originally published at latchkey.dev

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




## 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
Enter fullscreen mode Exit fullscreen mode

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 }}
Enter fullscreen mode Exit fullscreen mode

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




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

## 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 []
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

Permissions cross too, with a rule of their own: the documentation notes that the token permissions passed from the caller "can be only downgraded (not elevated) by the called workflow".

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.

FAQ

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.

References

Top comments (0)