Migration scripts must name the target environment
On this page
Reject bash scripts that invoke a multi-env CLI (wrangler, gcloud, kubectl, vercel, flyctl, supabase, firebase, heroku) without an explicit env-scope flag - when the script also has a defaulted ${1:-...} env variable that resolves to empty.
Typical failure shape: a wrangler d1 execute call inside apply-add-country-column-migration.sh defaults SCOPE="" when the caller forgets to pass prod. wrangler d1 execute interprets no --remote as local, so the DDL only lands on local D1; production columns never exist. Every /api/content request 500s after deploy. Hours of debug + dirty prod data.
What this catches#
A multi-env CLI invocation in a shell script that has NO explicit env scope:
- A multi-env CLI invocation:
wrangler,gcloud,kubectl,vercel,flyctl,fly,supabase,firebase,heroku - The CLI line has NO explicit env flag (
--remote,--env,--project,--context,--account,--app,--namespace,--stage)
The invocation then silently routes to the CLI’s local / current-context environment - which is almost never what a migration / deploy script should do. A defaulted SCOPE="${1:-}" variable makes it worse (the label notes it), but the missing-flag invocation is the footgun.
What this does NOT catch (precision)#
wranglerdeploy commands. Only the data-plane subcommands (d1,kv,r2) have a local-vs-remote default.wrangler pages deploy,wrangler deploy(Workers),versions,tail, etc. always act on the remote - there is no local mode, so an env flag is meaningless. The frame skips wrangler lines whose subcommand isn’td1/kv/r2.- Validated scope variables. If the script validates a variable against env-flag literals before use - e.g.
if [[ "$SCOPE" != "--local" && "$SCOPE" != "--remote" ]]; then exit 1; fi- then passing$SCOPEto the CLI IS an explicit flag (indirect), and the frame treats it as handled. This is the exact safe pattern the Fix below recommends; the frame must not block its own recommended fix.
Fix#
Two changes, both required:
1. Make the env arg required, not defaulted.
# WRONG - bash defaults to empty when no arg passed
SCOPE="${1:-}"
# RIGHT - fail loudly if missing
SCOPE="${1:?usage: $0 <local|remote>}"
2. Pass an explicit env flag to the CLI.
# WRONG - wrangler defaults to local
wrangler d1 execute "$DB" --file=migration.sql
# RIGHT - explicit scope
wrangler d1 execute "$DB" --file=migration.sql --remote
For gcloud: --project="$PROJECT". For kubectl: --context="$CTX". For vercel: --prod or --scope=team-name. For flyctl: --app="$APP".
Suppressing intentional cases#
When a script legitimately defaults to local (e.g. a dev convenience script that runs against the local emulator), suppress at the file level:
#!/usr/bin/env bash
# appframes:disable database/migration-script-explicit-env
# (this script is dev-only; never run against production)
wrangler d1 execute "$DB" --file=fixture.sql
Generalizes to#
Any CLI with a local/remote or env-scoped default that’s easy to get wrong:
- Cloudflare
wrangler(D1, KV, R2, Pages, Workers) - defaults to local gcloud- picks active config project / region / accountkubectl- current context determines target clustervercel---prodis opt-in; defaults to previewflyctl/fly- current app fromfly.tomlor envsupabase- linked projectfirebase-firebase usedefaultheroku---appinferred from git remote
The frame fires on all of them when invoked from a shell script without an explicit env flag.
BLOCK rejects the push. Turn frames on per repo on the dashboard's Policy page - see choosing what the gate checks.
Source on GitHub Live demo How it works Questions: contact@nimblegate.com