GitHub Actions · advanced
GitHub Actions secret not available: inspect the workflow trust boundary
A GitHub Actions workflow fails with a "secret not available" indicator because the triggering event, the workflow's permission scope, or the calling context cannot deliver the named secret at the point of evaluation. This playbook walks engineers through the trust boundary that governs secrets — event type, job scope, reusable workflow call, environment, and the expression evaluation context — and gives a decision tree that separates misconfiguration from deliberate restriction.
The symptoms
- •Workflow run page shows the job step failing with an error such as "secret not found", "context not found", or the masked placeholder value being passed as literal "***" through an expression.
- •Step that references ${{ secrets.NAME }} or env: mapping evaluates to an empty string in the logs where the action reports the value, but the secret name is correctly spelled.
- •The same workflow succeeds for pull_request_target from a fork after manual approval, but fails for pull_request from the same fork because secrets are unavailable.
- •A reusable workflow called via uses: org/repo/.github/workflows/x.yml receives an empty value for a secret the caller intended to forward through secrets: inherit or an explicit input.
- •Environment-scoped secrets resolve in main but resolve to empty on a protected branch or on a workflow_dispatch targeting a non-matching environment.
Likely causes
- •The triggering event is one that GitHub Actions does not expose secrets for, such as pull_request from a fork, or the workflow is run from an actor whose token lacks the required scope for that secret.
- •The secret is configured at the repository level but the workflow is executing in a different boundary: organization, environment, or a reusable workflow whose permissions filter out the secret.
- •Environment protection rules have not been satisfied (required reviewers, wait timer, deployment branch restriction) so the environment-scoped secrets are intentionally not injected.
- •Expressions evaluated outside the job context, such as in workflow-level if: or in composite action default inputs, cannot read secrets even when the step that follows can.
- •The workflow file references permissions: read-all or an explicit permissions: block that removes the scopes needed to deliver the secret to the runner, even though the secret itself exists.
First ten minutes
- 01Open the failed run and note the trigger event listed under "triggered by" — confirm whether it is push, pull_request, pull_request_target, workflow_dispatch, schedule, or workflow_call, because each has different secret availability.
- 02Compare the secret name used in ${{ secrets.NAME }} with the list under Repository Settings → Secrets and variables → Actions, and under Environments if the job targets one; record whether the name exists, where it lives, and any environment protection rule.
- 03Capture the job's permissions block from the workflow file and from the reusable caller if applicable, since GitHub issues tokens with the union of the workflow's requested scopes.
- 04Capture whether the failing expression is evaluated at the workflow level (top-level if), at the job level, or inside a step, because only the step and job-level contexts can read secrets.
- 05Reproduce the failure with a minimal workflow_dispatch run on the same ref using ACTIONS_STEP_DEBUG enabled, and note whether the secret is delivered or masked-empty in the expanded expression.
Evidence to collect
- •The run URL and the workflow file SHA actually executed, because secret availability is evaluated against the committed YAML at run time, not the local working copy.
- •The exact triggering event, sender, and ref recorded in the run header, used to decide whether secrets are exposed by GitHub's policy for that event.
- •The values field showing what GitHub actually delivered for the secret: masked "***" indicates the secret is present and was intentionally masked, while an empty string indicates the secret was not delivered.
- •The settings diff between repository secrets, organization secrets with repository access lists, and environment secrets with protection rules.
- •For reusable workflows, the secrets: block on the calling uses: line and the secrets: inherit flag, because explicit allow-lists on reusable calls filter out unreferenced secrets.
Where to look
- •The workflow trust boundary that matters here: the intersection of (a) the triggering event's allowed context for secrets, (b) the scope where the secret is defined, (c) the environment and its protection state, and (d) the permissions the workflow requested for the job's GITHUB_TOKEN.
- •Repository Settings → Secrets and variables → Actions, both the "Repository secrets" and "Environment secrets" tabs, where environment secrets override repository secrets when the job targets the environment.
- •The organization-level secret's "Repository access" list, which determines whether a repository can read a secret defined one level up.
- •The Environment's protection rules page: required reviewers, wait timer, and deployment branches, because unsatisfied rules cause GitHub to withhold environment secrets even when the run succeeds in other ways.
- •The reusable workflow contract at the calling uses: line, specifically whether secrets: inherit is set, which secrets are forwarded by name, and whether the called workflow declares its own secrets: input that filters them.
Diagnostic steps
- 01Classify the event against the published availability matrix: push, workflow_dispatch, workflow_call (when invoked by another workflow), and pull_request_target from a base-branch branch all expose secrets; pull_request from a fork does not.
- 02Confirm the secret's scope: organization secret with this repository not in its access list will appear in settings but will not be delivered; verify by opening the org secret and reviewing the repository list.
- 03Reproduce under ACTIONS_STEP_DEBUG and inspect the expanded expression log line for the step; a masked "***" means the secret was delivered but masked, while a literal empty string means it was not delivered at all.
- 04If the workflow calls a reusable workflow, add a temporary step in the called workflow that echoes a known reference value such as secrets.GITHUB_TOKEN (which is always present for non-fork events) to verify the call path itself is intact, then check the caller's secrets: forwarding.
- 05If the workflow targets an environment with protection rules, observe the run's "Waiting for approval" banner; unsatisfied protection rules explain empty environment secrets even when the job eventually runs.
- 06If the failing expression is in a top-level workflow if: or in a composite action default input, move it into a step where the secrets context is available, since those outer contexts cannot read secrets.
Common mistakes
- •Trusting a secret visible in the repository settings without checking the organization-level "Repository access" list, which can exclude the repo even when the name matches.
- •Reading secrets at the workflow top level (top-level if:) where GitHub's expression context does not include secrets, producing an empty string that looks like a typo.
- •Assuming an empty value means the secret is missing, when in fact it can mean the environment's protection rules were not satisfied and the runner never received it.
- •Forwarding secrets to a reusable workflow with secrets: inherit only on some callers, so the called workflow silently runs with a different trust boundary depending on the caller.
- •Adding a permissions: read-all or a narrow permissions: block that downscopes GITHUB_TOKEN, which both restricts API actions and affects how GitHub gates certain secret deliveries for sensitive actions.
Safe fixes
- •If the trigger is pull_request from a fork, restrict the workflow to safe contexts (no secret reads) or move the trusted logic to pull_request_target gated by an explicit allow-list of actor names and label triggers.
- •If the secret is organization-scoped, open the organization secret settings and add the repository to its access list, then re-run from the same ref to confirm the value is delivered.
- •If environment protection rules are blocking delivery, either satisfy the rule (required reviewers, wait timer elapsed, matching branch) or remove the environment scoping from the job and read the secret at repository scope instead.
- •If the failing expression is in a top-level if:, move the secret-dependent condition into a job-level if: or into a step that computes the value and gates subsequent steps with an outputs: contract.
- •If a reusable workflow is the consumer, decide between secrets: inherit (forward all caller secrets) and an explicit secrets: block that lists each forwarded secret by name; never assume the called workflow can read what the caller has.
Prove the fix
- 01Run the same workflow from the same triggering event and ref and confirm the masked "***" appears in the debug-expanded expression log for the secret, indicating delivery and masking.
- 02Run a workflow_dispatch on the same ref and confirm the action that uses the secret completes and the downstream artifact or API call records evidence that the secret was accepted.
- 03For environment-scoped secrets, observe the run page moving past the "Waiting for…" banner into active execution, with the environment name listed in the deployment summary.
- 04For reusable workflows, add a deliberately failing step that prints the secret name's existence (not the value) and confirm it resolves to a non-empty masked value on the called job.
- 05After the fix, introduce a regression test: a scheduled or workflow_dispatch job that asserts the secret reference is non-empty and writes an annotated failure to the run summary if it is empty.
Prevention and next steps
- •Treat secret availability as part of the workflow's contract: document which events each workflow supports and refuse pull_request from forks when secrets are required.
- •Keep a single source of truth for secrets, prefer environment-scoped secrets for production, and mirror them in a README that names the environment and the expected protection rules.
- •Add a lint step or policy check that flags top-level secret reads and reusable workflow calls without secrets: inherit where forwarding is implied.
- •Use reusable workflows with explicit secrets: blocks rather than implicit forwarding, so the trust boundary is visible in code review rather than inferred from settings.
Safe commands and checks
gh run list --workflow=<workflow-file-or-id> --limit 5 gh run view <run-id> --json event,headBranch,headSha,displayTitle,status,conclusion gh run view <run-id> --log 2>&1 | sed -n '1,200p' gh workflow view <workflow-file-or-id> --yaml gh secret list --repo <owner>/<repo>