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:
- Unencoded password characters (URI-reserved chars need percent-encoding) — ruled out: the DB password was alphanumeric.
- Trailing
\r/ whitespace from a CRLF paste into the op field.op readis captured with$(…), which strips trailing newlines but not a\ror spaces, so a stray byte can ride into a secret. Added a trim toresolve_value— a real robustness fix, but it did not fix this. - 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:
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_TMPgh 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
# 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 setread stdin only when--bodyis omitted. To pipe a value, pipe it and leave--bodyunset. Never write--body -—ghhas 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 throwawaygh variable setround-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 trim9e84ed6a(kept, addresses CRLF pastes). - Adjacent CI-deploy fixes from the same first-deploy shakeout: session-pooler DSN
c500a90d, signing-key neutralization5d223ad5, empty-variable skip09be6393.