fizz.today

GitHub Actions silently injects org secrets as empty strings on the free plan

You can define an organization-level secret in GitHub. You can see it in the UI. You can query it via the API. Your workflow can reference it.

And GitHub Actions will inject it as an empty string.

No masking. No warning. No error. Just "".

On the GitHub Free plan for organizations, org-level secrets are not available to private repositories. GitHub doesn’t tell you this at any point — not when you create the secret, not when you assign it to repos, not when the workflow runs. The value silently resolves to nothing.

Finding this took hours of debugging followed by a lot of googling through obscure GitHub community forum threads. A GitHub staff member confirmed it buried in a discussion from 2022. The official docs do mention it — one line, buried in the “Creating secrets for an organization” section. Nothing in the UI. Nothing in the error output. Nothing at the point where you’d actually encounter the problem.

How I found it

Set up a cross-repo dispatch workflow. Repo A finishes a Docker build and fires repository_dispatch to Repo B to trigger a deployment. The dispatch needs a PAT because GITHUB_TOKEN can’t trigger workflows in other repos.

Created a fine-grained PAT scoped to the target repos. Stored it as an org-level secret named INFRA_DISPATCH_TOKEN, visible to the three repos that needed it. Verified with the API:

gh api orgs/d33pthinkers/actions/secrets/INFRA_DISPATCH_TOKEN/repositories
# Returns all three repos. Looks correct.

The workflow ran. ${{ secrets.INFRA_DISPATCH_TOKEN }} resolved to an empty string. Not masked (GitHub masks real secret values as ***). Not a “secret not found” error. Empty. The downstream gh api call got a 401. I spent an hour debugging the token before realizing the token was fine — the variable was empty before the API call even ran.

Debug step that proved it:

- name: Check token
  run: |
    if [ -z "$TOKEN" ]; then echo "TOKEN is empty"; else echo "TOKEN is set (masked)"; fi
  env:
    TOKEN: ${{ secrets.INFRA_DISPATCH_TOKEN }}

Output: TOKEN is empty.

The fix

Move the secret to the repository level. Same name, same value, different scope. Immediately worked:

TOKEN is set (masked)

GitHub, please

This is a product gap, not a pricing problem. Pricing tiers exist. We’re already paying for runner minutes — that’s not enough to unlock org secrets in private repos. Fine.

What’s not fine is silent degradation. The runner already knows the org plan level, the repo visibility, and that the secret won’t be injected. There’s a deliberate code path making this decision. That same code path could emit a warning or fail the step with a clear message. Instead, the failure surfaces downstream as a 401, where the causal link is opaque.

This is an observability gap. If a feature is gated by plan, surface that at evaluation time. Fail fast. Fail loud. Fail informative. One clear error message would save hours across the ecosystem.

I don’t mind paying for features. I mind paying with time.

#github-actions #ci-cd