Skip to content

[BUG] Environment-scoped secrets unreachable from reusable workflow without secrets: inherit, despite called job declaring environment #4453

@michaels-omneo-workers

Description

@michaels-omneo-workers

Describe the bug

A reusable workflow declares environment: ${{ inputs.environment_name }} on its job, expecting environment-scoped secrets to resolve via ${{ secrets.MY_SECRET }}. Per the official docs, this should be sufficient:

"If a called workflow needs to access environment secrets, the environment must be defined in the called workflow."

Empirically it isn't sufficient. Every env-scoped secret resolves to "" (empty string) at the called workflow's runtime unless the caller also adds secrets: inherit.

To reproduce

Caller workflow (.github/workflows/caller.yml):

on: workflow_dispatch
jobs:
  call-it:
    uses: ./.github/workflows/reusable.yml
    with:
      target_environment: my-env
    # NO secrets: inherit

Reusable workflow (.github/workflows/reusable.yml):

on:
  workflow_call:
    inputs:
      target_environment:
        required: true
        type: string
    # NO secrets: block declared

jobs:
  worker:
    runs-on: ubuntu-latest
    environment: ${{ inputs.target_environment }}
    steps:
      - name: Show secret length
        env:
          MY_SECRET: ${{ secrets.MY_SECRET }}
        run: |
          echo "MY_SECRET length: ${#MY_SECRET}"
          [ -z "$MY_SECRET" ] && echo "EMPTY" || echo "POPULATED"

Setup: create environment my-env on the repo. Set environment secret MY_SECRET to any non-empty value via UI or gh secret set MY_SECRET --env my-env --repo OWNER/REPO.

Trigger the caller via workflow_dispatch.

Expected behavior

MY_SECRET length: <non-zero> and POPULATED. The reusable's job binds to my-env, and ${{ secrets.MY_SECRET }} resolves from that environment's scope.

Actual behavior

MY_SECRET length: 0 and EMPTY. The step's env: block in the runner log renders:

env:
  MY_SECRET:

(blank after the colon — not *** mask).

The env binding takes effect for things like environment protection rules and vars, but secrets resolve as if the job had no env binding.

Workaround

Add secrets: inherit to the caller:

jobs:
  call-it:
    uses: ./.github/workflows/reusable.yml
    with:
      target_environment: my-env
    secrets: inherit   # <-- this fixes it

After this, MY_SECRET resolves correctly to its env-scoped value.

Why this is confusing

secrets: inherit is documented as passing the caller's secrets to the called workflow. But in this reproduction the caller has no environment binding — so the caller's own scope doesn't include MY_SECRET either. Yet adding inherit somehow enables the resolution. This suggests the inherit mechanism is doing more than just secret transfer; it may be elevating the called workflow's resolution context. Whatever the mechanism, the docs don't describe it.

Real-world impact

A production CI/CD pipeline shipped a matrix of ~12 tenants with per-tenant GitHub Environments holding per-tenant deploy secrets. The reusable workflow pattern (called with environment_name: ${{ matrix.tenant }}) was thought to scope secrets per tenant. It silently didn't — every secret resolved empty.

The failure was masked for over a week because legacy in-cluster Kubernetes Secrets continued providing the same values via envFrom on running pods (Kubernetes pods don't re-read Secret values after start). Workers only crashed on a kustomize-secretGenerator–built Secret whose name changes on every deploy (hash suffix), forcing pod restarts onto the broken value.

The official guidance led to a broken architecture. Either:

  • The docs need to call out the secrets: inherit requirement
  • OR the runner should make env binding actually sufficient

Runner Version and Platform

  • Runner: ubuntu-latest (whatever GHA-hosted version)
  • gh CLI 2.91.0 — but issue is independent of the CLI; UI-uploaded secrets behave the same

What's not working

Environment-scoped secrets in reusable workflows.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions