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:
(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.
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:Empirically it isn't sufficient. Every env-scoped secret resolves to
""(empty string) at the called workflow's runtime unless the caller also addssecrets: inherit.To reproduce
Caller workflow (
.github/workflows/caller.yml):Reusable workflow (
.github/workflows/reusable.yml):Setup: create environment
my-envon the repo. Set environment secretMY_SECRETto any non-empty value via UI orgh secret set MY_SECRET --env my-env --repo OWNER/REPO.Trigger the caller via
workflow_dispatch.Expected behavior
MY_SECRET length: <non-zero>andPOPULATED. The reusable's job binds tomy-env, and${{ secrets.MY_SECRET }}resolves from that environment's scope.Actual behavior
MY_SECRET length: 0andEMPTY. The step'senv:block in the runner log renders:(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: inheritto the caller:After this,
MY_SECRETresolves correctly to its env-scoped value.Why this is confusing
secrets: inheritis 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 includeMY_SECRETeither. Yet addinginheritsomehow enables the resolution. This suggests theinheritmechanism 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
envFromon 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:
secrets: inheritrequirementRunner Version and Platform
ubuntu-latest(whatever GHA-hosted version)ghCLI 2.91.0 — but issue is independent of the CLI; UI-uploaded secrets behave the sameWhat's not working
Environment-scoped secrets in reusable workflows.