Skip to content
GitHub
Integration Issues

gh secret set --body - silently sets every secret to the literal dash

gh secret set —body - silently sets every secret to the literal dash

Severity: P1 — blocked the production deploy; every GitHub Environment secret was corrupted.

Problem

The first cloud deploy’s migration step failed in CI:

failed to parse connection string: cannot parse `***`: failed to parse as DSN (invalid dsn)

DATABASE_URL resolved to a clean, valid connection string in 1Password (verified with op read), yet the Supabase CLI’s pgx parser rejected it. GitHub masks the secret value as ***, which hid the real value and sent the investigation chasing the format of the DSN.

Symptoms:

  • A secret that is provably correct at the source (op / local) is rejected by its CI consumer.
  • The error references a masked ***, so you can’t see what the consumer actually received.
  • Only secret-consuming steps fail. Variable-consuming steps in the same workflow are fine (this asymmetry is the tell).

Investigation

Three plausible-but-wrong hypotheses burned cycles first — each worth recognizing, because the real bug masquerades as all of them:

  1. Unencoded password characters (URI-reserved chars need percent-encoding) — ruled out: the DB password was alphanumeric.
  2. Trailing \r / whitespace from a CRLF paste into the op field. op read is captured with $(…), which strips trailing newlines but not a \r or spaces, so a stray byte can ride into a secret. Added a trim to resolve_value — a real robustness fix, but it did not fix this.
  3. IPv6 vs IPv4 — the Free-plan direct Supabase connection is IPv6-only and GitHub runners are IPv4-only, so migrations need the session pooler. Also a real, separate issue (fixed), but not this one.

The breakthrough was reading the gh secret set help:

-b, --body string The value for the secret (reads from standard input if not specified)

gh reads stdin only when --body is omitted. There is no - stdin sentinel. Proven with a throwaway variable:

Terminal window
printf 'STDIN_VALUE_42' | gh variable set ZZ_TMP --body -
gh variable get ZZ_TMP # → "-" (stdin ignored, body is the literal dash)
printf 'STDIN_VALUE_42' | gh variable set ZZ_TMP
gh variable get ZZ_TMP # → "STDIN_VALUE_42" (stdin read)

Root Cause

provision.sh pushed secrets with … | gh secret set "$key" --env "$env" --body -. Because --body was specified (as the literal -), gh used - as the value and discarded the piped stdin. Every secret — DATABASE_URL, SUPABASE_DB_URL, SUPABASE_SECRET_KEY, CLOUDFLARE_API_TOKEN — was set to the one-character string -.

pgx then reported cannot parse '-' as invalid dsn; GitHub masked the registered secret value - to ***, which is why the log looked like a DSN-format problem rather than “the value is a dash.” The variables in the same function used gh variable set … --body "$resolved" (the value passed correctly), so variables were intact — which is exactly why only the first secret-consuming step failed and it looked value-specific.

Solution

Omit --body so gh reads the piped value from stdin.

Code Changes

Terminal window
# Before (every secret becomes the literal "-")
printf '%s' "$resolved" | gh secret set "$key" --env "$ghenv" --body -
# After (gh reads the value from stdin)
printf '%s' "$resolved" | gh secret set "$key" --env "$ghenv"

Implementation Notes

  • Keep the printf '%s' (no trailing newline) + the pipe; only --body - was wrong.
  • Do not “fix” it by switching to --body "$resolved" — that puts the secret on the argv / process list. Stdin keeps it off.

Prevention

Best Practices

  • gh secret set / gh variable set read stdin only when --body is omitted. To pipe a value, pipe it and leave --body unset. Never write --body -gh has no stdin sentinel.
  • When a secret “is correct at the source but the consumer rejects it,” inspect what is actually stored, not just the source. Masking (***) hides a degenerate value like -. A throwaway gh variable set round-trip (variables are readable; secrets are not) reveals the true write semantics.

Warning Signs

  • A provisioning script where variables work but secrets don’t — suspect the secret-set call, not the values.
  • A parser error on a value that is provably valid upstream — suspect corruption in transit, and remember the masked *** may not be the value you think.

References

  • Fix: 834c2f5e (drop --body -); robustness trim 9e84ed6a (kept, addresses CRLF pastes).
  • Adjacent CI-deploy fixes from the same first-deploy shakeout: session-pooler DSN c500a90d, signing-key neutralization 5d223ad5, empty-variable skip 09be6393.