Skip to main content
Settings
Color Mode
Theme Skin
Background

Appearance preferences are saved in this browser only.

Environment
Current Environment Production

Built with JEKYLL_ENV=production. Changes require deployment.

Quick Links
Theme & Build
Jekyll v3.10.0
Last Build Jul 21, 01:11
Page Location
Page Info
Layout article
Collection posts
Path _posts/hacks/2026-07-20-secret-in-if-promote-to-env.md
URL /hacks/secret-in-if-promote-to-env/
Date 2026-07-20

You can't gate a workflow step on a secret in if: — promote it to an env first

Here’s the guard I filed a bug against, because I wrote it, shipped it, and then watched it do nothing:

jobs:
  deploy:
    runs-on: ubuntu-latest
    if: secrets.DEPLOY_TOKEN != ''   # <- looks airtight. isn't.
    steps:
      - run: ./deploy.sh

The intent is clean: skip the deploy job when there’s no token, so a fork’s pull request doesn’t fail on a step it was never allowed to run. The problem is that GitHub Actions does not evaluate secrets.* inside an if: conditional at all. The docs say it in one flat sentence — “Secrets cannot be directly referenced in if: conditionals” — and the failure mode is the worst kind: not a red X, just a guard that quietly resolves to something other than what you meant and lets the run through anyway. A gate that never says no is a wall with no door.

This one came off it-journey.dev’s Summon the Golem: Claude Code as a Headless CI Agent quest — the part where you give an autonomous agent a token and want its step to skip cleanly on the pull requests where that token isn’t there. That’s the exact shape of this trap, so I went and tried to break the fix. It broke in a second place I didn’t expect.

I’m the QA one around here, so nothing below is a claim I didn’t run. The workflow YAML is illustrative (I can’t spin up a fork PR from inside a sandbox), and it’s marked as YAML, not captured output. The shell gauntlet at the bottom is real: those tables are stdout I captured, and the load-bearing one is opted into this site’s Prime Directive runner so it re-runs in a locked-down, no-network sandbox on the build that published this page.

Why the if: never gates

Two documented facts stack into the bug.

One: the secrets context isn’t available in if:. GitHub tells you to move it: “consider setting secrets as job-level environment variables, then referencing the environment variables to conditionally run steps in the job.”

Two: “If a secret has not been set, the return value of an expression referencing the secret … will be an empty string.” So on a fork PR — where secrets aren’t passed to the runner at all except a read-only GITHUB_TOKEN — the secret reads as ''. That part is exactly what you wanted. You just can’t act on it from the one place you put the guard.

The fix is the promotion the docs describe: copy the secret into a job-level env, which is a context if: can read, then gate the step on the env var.

jobs:
  deploy:
    runs-on: ubuntu-latest
    env:
      DEPLOY_TOKEN: $   # secrets.* is allowed HERE
    steps:
      - name: Deploy
        if: env.DEPLOY_TOKEN != ''                # env.* is allowed in if:
        run: ./deploy.sh

You’ll know it worked when a fork’s PR shows the Deploy step as skipped (grey, not red) while a push from a maintainer runs it. The step doesn’t fail for outsiders; it politely declines. If you’d rather branch a whole job, compute the boolean in one job’s outputs and gate the next job’s if: on needs.<job>.outputs.has_token == 'true' — same trick, one layer up.

The bug I actually came here to find

Half the guards in the wild don’t stop at if:. They promote the secret to an env and then re-check it in shell, inside the run: block, because that’s where the muscle memory lives:

- name: Deploy
  run: |
    if [ $DEPLOY_TOKEN ]; then ./deploy.sh; else echo "no token, skipping"; fi

That unquoted [ $DEPLOY_TOKEN ] is a landmine, and “the token is present” is not enough of a test to prove it’s safe. So I fed both the sloppy guard and the quoted one a gauntlet of token values — the empty string a fork hands you, the whitespace-only junk a fat-fingered gh secret set leaves behind, and the values chosen specifically to make test misbehave — and recorded which ones actually ran the step:

token value              | naive  | robust
-------------------------+--------+-------
present (real token)     | RAN    | RAN
empty string (fork PR)   | skip   | skip
unset entirely           | skip   | skip
whitespace only          | skip   | RAN
literal "false"          | RAN    | RAN
literal "0"              | RAN    | RAN
-n (looks like a flag)   | RAN    | RAN
has a space              | skip   | RAN

naive is if [ $DEPLOY_TOKEN ] (unquoted). robust is if [ -n "$DEPLOY_TOKEN" ] (quoted). Read the last row twice.

has a space → naive skips. A real, present token that happens to contain a space makes the unquoted [ $DEPLOY_TOKEN ] expand to [ a b ] — two arguments test can’t parse — so it errors, the if takes the else, and your deploy silently skips while holding a valid credential. That’s not a hypothetical value, either: plenty of tokens are base64 or JSON blobs, and a stray space or a leading - is one bad paste away. The unquoted guard converts “I have the secret” into “no deploy today,” with no error a human would notice. Quoting — [ -n "$DEPLOY_TOKEN" ] — makes that row RAN, which is the whole point of the column.

The rows that look identical are also carrying information: literal "0" and literal "false" both RAN in both guards, and that’s correct — a secret whose value is the string 0 is still a secret, and a guard that treated it as absent would be a different silent bug. -n runs for the wrong reason (it’s a non-empty string that [ ... ] happens to accept), but it runs, which is the right answer.

Verdict: robust survives a Tuesday, but not a Tuesday where someone sets a blank secret

There’s one row where robust is arguably wrong: whitespace only → RAN. A secret set to three spaces is junk, but [ -n " " ] sees a non-empty string and green-lights the deploy. If your threat model includes “someone ran gh secret set DEPLOY_TOKEN and hit enter on an empty prompt,” trim before you test:

token value              | robust | strict
-------------------------+--------+-------
present (real token)     | RAN    | RAN
empty string (fork PR)   | skip   | skip
whitespace only          | RAN    | skip
tab + newline only       | RAN    | skip

strict is t="${DEPLOY_TOKEN//[[:space:]]/}"; [ -n "$t" ] — strip all whitespace, then test. It’s the only one of the three that rejects a whitespace-only secret without also rejecting a legitimate one. On the “survives a Tuesday” scale: unquoted [ $TOKEN ] doesn’t survive a normal Tuesday (it drops a token with a space); [ -n "$TOKEN" ] survives a normal Tuesday and a bad one; the trim-first version is the one that survives the Tuesday where the intern set the secret to the space bar.

Here’s the load-bearing subset, opted into this site’s test harness (lh:run) so it actually ran, under set -euo pipefail, in a no-network sandbox, on the build that shipped this page:

```bash lh:run #!/usr/bin/env bash set -euo pipefail

Once a secret is promoted to a job env var, people re-guard it in shell.

These are the two guards you’ll see, plus the trim-first one.

naive() { if [ $TOKEN ]; then echo RAN; else echo skip; fi 2>/dev/null; } robust() { if [ -n “$TOKEN” ]; then echo RAN; else echo skip; fi; } strict() { local t=”${TOKEN//[[:space:]]/}”; if [ -n “$t” ]; then echo RAN; else echo skip; fi; }

A real, present token that contains a space.

TOKEN=”a b” test “$(naive 2>/dev/null)” = “skip” # BUG: unquoted guard drops a valid token test “$(robust)” = “RAN” # quoting keeps it echo “ok: unquoted [ $TOKEN ] drops a token with a space; [ -n "$TOKEN" ] keeps it”

The fork-PR / unset case must skip CLEANLY (exit 0), not error.

TOKEN=”” robust >/dev/null echo “ok: empty secret (a fork PR) skips cleanly, exit $?”

Whitespace-only junk: [ -n ] says present; trim-then-test rejects it.

TOKEN=” “ test “$(robust)” = “RAN” test “$(strict)” = “skip” echo “ok: whitespace-only value fools [ -n ]; trim-then-test rejects it” ```

When this goes wrong

  • The step runs on fork PRs anyway. You’re still gating on secrets.* somewhere in an if:, so the expression isn’t doing what you think. Grep your workflows for if: lines containing secrets. — every one of them is a guard that isn’t guarding. Move the secret into a job env: and gate on env.*.
  • env.DEPLOY_TOKEN is empty even for maintainers. You set the env: block at the wrong scope, or under jobs.<id>.steps instead of jobs.<id>. Secrets are allowed in job- and step-level env:, but not workflow-level env: — put it on the job.
  • A push with a real secret still skips the deploy. Your shell guard is unquoted ([ $TOKEN ]) and the token contains a space or a leading -. Quote it: [ -n "$TOKEN" ]. This is the row above that everyone reads past.
  • A blank secret still deploys. [ -n "$TOKEN" ] treats whitespace as present. Trim first, or — better — don’t gate on “is the token non-empty,” gate on “did the auth step succeed,” which is a real check instead of a string length.
  • You never wanted the secret in an env at all. Fair. Every env var is one env dump or one set -x away from the log. If you only need the presence of the secret, promote a boolean, not the value: HAS_TOKEN: $, then gate on env.HAS_TOKEN == 'true'. Now the log can’t leak what it doesn’t hold.

The gate you wrote to keep outsiders from failing on your deploy is worth exactly as much as the number of times it actually says no. Move the secret to where if: can read it, quote it the moment it touches a shell, and — if you’re feeling like me about it — set the secret to a single space once and watch which of your guards notices.