A small bash CLI that wraps the Heroku CLI to run commands against every app in a pipeline stage.
Replaces the older Elixir version of this tool.
- macOS or Linux
bash(any version that ships with the OS is fine)- The
herokuCLI, installed and authenticated (heroku login) jq— only needed for thedeploy-slugcommand
curl -fsSL https://raw.githubusercontent.com/DefactoSoftware/heroku-scripts/main/install.sh | shOr manually:
curl -fsSL https://raw.githubusercontent.com/DefactoSoftware/heroku-scripts/main/bin/heroku-scripts -o ~/.local/bin/heroku-scripts
chmod +x ~/.local/bin/heroku-scriptsThe heroku CLI must be authenticated. heroku-scripts picks credentials in
this order:
HEROKU_API_KEY— if already set, it is used as-is, soHEROKU_API_KEY=… heroku-scripts …always works.- 1Password — if
HEROKU_SCRIPTS_OP_REFholds a 1Password secret reference andHEROKU_API_KEYis not set, the key is read once via the 1Password CLI (op) and reused for every call. - Otherwise the heroku CLI's own stored login (
heroku login) is used.
If your heroku credentials are brokered by 1Password (shell plugin / desktop
app), every heroku call triggers an interactive approval and account
selector. pipeline-cmd runs heroku in parallel, backgrounded subshells with
no controlling terminal, so those prompts can't be answered and the run stalls.
Resolving the key once, up front sidesteps this: 1Password approves a single
time and the exported key flows to every child process.
Store your Heroku API key in 1Password, then point the script at it (add this to your shell profile):
export HEROKU_SCRIPTS_OP_REF="op://Private/Heroku/credential"Get the reference from the 1Password app (right-click a field → Copy Secret
Reference) or op item get "Heroku" --format json.
Prefer not to configure the script? Use op run instead — no env var needed:
HEROKU_API_KEY="op://Private/Heroku/credential" op run -- heroku-scripts apps my-pipe stagingheroku-scripts apps <pipeline> <stage>
heroku-scripts pipeline-cmd <pipeline> <stage> "<heroku command>" [--concurrency=N] [--retries=N] [--no-stream] [-a] [--table|--csv]
heroku-scripts config-replace <pipeline> <stage> <VAR> <old-value> <new-value> [--concurrency=N] [--dry-run] [-a|--all] [--table|--csv] [--no-stream]
heroku-scripts pipeline-sql <pipeline> <stage> ("<sql>" | --file=<path>) [--concurrency=N] [-a|--all] [--table|--csv]
heroku-scripts pipeline-task <pipeline> <stage> <MixTask> [--concurrency=N]
heroku-scripts promote <app> <to-team> <pipeline> [--dry-run] [--yes]
heroku-scripts deploy-slug <target-app> [--from=<source-app>] [--dry-run] [--yes]Run heroku-scripts help for the full command list.
List every app in the staging stage of the my-pipe pipeline:
heroku-scripts apps my-pipe stagingSet a config var on every staging app:
heroku-scripts pipeline-cmd my-pipe staging "config:set EMAIL_SENDER=noreply@example.com"pipeline-cmd prints one record per app. On a terminal it renders an aligned
table; when the output is piped or redirected it switches to CSV
(appname;output) so it stays easy to parse and grep. Force either with
--table or --csv.
# on a terminal
appname | output
---------------+-----------------------------------------
my-app | EMAIL_SENDER: noreply@example.com
my-app-worker | EMAIL_SENDER: noreply@example.com
# piped
appname;output
my-app;EMAIL_SENDER: noreply@example.com
my-app-worker;EMAIL_SENDER: noreply@example.com
Records stream out as each app finishes (in completion order), so output
appears progressively instead of all at once at the end — table mode included,
since the column width comes from the app list. Pass --no-stream to
withhold output until every app finishes and print it sorted by app name —
useful for reproducible, diff-friendly output.
Apps whose output is empty (e.g. config:get for a var that isn't set) are
skipped by default, and a count of skipped apps is printed to stderr. Pass
-a/--all to include them:
heroku-scripts pipeline-cmd my-pipe production "config:get ADFS_METADATA_URL"
# ...only apps that have the var...
# 18 app(s) with empty output skipped (use -a/--all to include them)The output field is the app's raw combined heroku output, so it may span multiple lines and contain semicolons. Treat the stream as something to read or grep, not as strict CSV.
ps:exec-style commands occasionally fail with a transient connection error
even though the dyno is up — Heroku's exec-manager rejects the credential
handshake, the SSH tunnel drops mid-session, or keepalives time out. These
show up more under parallel load and succeed on a plain re-run. Pass
--retries=N to re-run an app up to N extra times (with increasing backoff)
when its output matches one of those known-transient errors:
heroku-scripts pipeline-cmd my-pipe production 'ps:exec bin/rails runner "Some.task"' --retries=3Genuine command failures — anything that doesn't match the known connection
errors — are never retried, and a persistently failing app still emits its
last error as its record. The default is --retries=0 (unchanged behavior).
One caveat: a mid-session drop can happen after the remote command started
running, so only use --retries with commands that are safe to run twice.
Running SQL through pipeline-cmd works — pipeline-cmd my-pipe production 'pg:psql -c "select count(*) from users"' — but reads badly: each app's
output is psql's own aligned table (header, dashes, rows, (N rows) footer),
and that multi-line blob gets squeezed into pipeline-cmd's appname | output
layout. pipeline-sql runs a single query against the default database
(DATABASE_URL) of every app in the stage and prints ONE table with the app
name as the first column:
heroku-scripts pipeline-sql my-pipe production "select count(*) as users, max(inserted_at) as newest from users"# on a terminal
appname | users | newest
--------------+-------+--------------------------
my-app | 1204 | 2026-09-22 14:03:11.51239
my-app-worker | 1204 | 2026-09-22 14:03:11.51239
my-other-app | 87 | 2026-09-19 09:12:40.00417
# piped
appname;users;newest
my-app;1204;2026-09-22 14:03:11.51239
my-app-worker;1204;2026-09-22 14:03:11.51239
my-other-app;87;2026-09-19 09:12:40.00417
As with pipeline-cmd, the format is a table on a terminal and CSV when
piped; force either with --table or --csv. Longer queries can come from a
file instead of the command line:
heroku-scripts pipeline-sql my-pipe production --file=reports/orphaned-invoices.sqlInline SQL that starts with a -- comment line would be mistaken for an
option; put -- (end of options) in front of it:
heroku-scripts pipeline-sql my-pipe production -- "-- locked accounts
select id, email from users where locked"Either way the SQL is expected to be one statement that returns a result
set. Statements that return nothing (an UPDATE, a DO block) are
reported as an error row rather than silently succeeding, and several
statements in one file are not supported.
The output is buffered, not streamed: the merged header and every column
width depend on all apps' results, so nothing is printed until the last app
finishes, and the rows come out sorted by app name. There are no
--stream/--no-stream flags. --concurrency=N works as in pipeline-cmd.
Apps whose query returns no rows are skipped, with a count on stderr; pass
-a/--all to give each of them one row with empty cells instead:
heroku-scripts pipeline-sql my-pipe production "select id, email from users where locked" -a
# appname;id;email
# my-app;42;someone@example.com
# my-app-worker;;Apps whose query fails — a SQL error, or heroku itself failing (no
database attached, no access) — get an error row in place of data, spanning
the data columns, with psql's psql:<file>:<line>: prefix and heroku's
Error: psql exited with code N trailer removed so only the message remains.
The other apps are unaffected:
appname | id | name
----------+----+------
app-one | 1 | alice
app-one | 2 | bob
app-three | ERROR: relation "users" does not exist
| LINE 1: select * from users
| ^
The merged header is the first app's (in name order). If a later app returns
different columns — schema drift between apps — its rows are still printed,
but a warning like pipeline-sql: app-two: columns differ from app-one (id, mail vs id, email) goes to stderr, since its cells will sit under the wrong
column names.
Only the query result goes into the table. Anything psql says on the side
during a successful query — a NOTICE from a RAISE NOTICE, a WARNING,
an identifier-truncation notice — is forwarded to stderr, one line each,
prefixed with the app name (pipeline-sql: my-app: NOTICE: ...), so it is
neither lost nor mistaken for data.
Two caveats. Cells are tab-separated internally and rows newline-separated,
so a value that itself contains a tab or newline will break its row — like
pipeline-cmd's CSV, this output is for reading and grepping, not strict
parsing. And because heroku pg:psql offers no way to pass psql's -X/-q
flags, your ~/.psqlrc is still read: pipeline-sql neutralises its output
and formatting settings (\timing, \pset changes, \o redirection,
\echo chatter) from inside the query script, but a psqlrc that errors out
or changes \set ON_ERROR_STOP semantics will still get in the way.
config-replace is a guarded config:set: it reads each app's current value
first and only writes on apps where that value is exactly the one you expect.
heroku-scripts config-replace my-pipe production SMTP_HOST smtp.old.example smtp.new.exampleApps that don't have the var at all are skipped (with a count on stderr, or a
skipped: SMTP_HOST not set record when you pass -a/--all), and apps whose
value is something else entirely are left untouched but reported, so drift
stays visible. One blind spot: config:get prints the same empty line for an
unset var and one set to the empty string, so a var set to "" is treated as
not set.
appname;output
my-app;SMTP_HOST: smtp.new.example
my-app-worker;skipped: SMTP_HOST is "smtp.other.example" (expected "smtp.old.example")
Pass --dry-run to see what would change without setting anything — it still
reads every app's current value, so it needs credentials like a real run:
heroku-scripts config-replace my-pipe production SMTP_HOST smtp.old.example smtp.new.example --dry-run
# my-app;would set SMTP_HOST=smtp.new.example (currently smtp.old.example)--concurrency, --no-stream, and --table/--csv behave exactly as in
pipeline-cmd.
Run a mix task on every production app, four at a time:
heroku-scripts pipeline-task my-pipe production GiveRaiseToPeople --concurrency=4Move an app and its -staging sibling to another team and pipeline:
heroku-scripts promote my-app my-team my-pipepromote runs destructive, largely irreversible operations, so it prints what
it will do and asks for confirmation first. Pass --dry-run to preview the
exact heroku commands, or --yes to skip the prompt.
deploy-slug releases an existing slug to an app — a deploy without a build,
the same mechanism the detroit CI slug
pipeline
uses. It looks up the slug the source app is currently running (config-var
changes and rollbacks reuse their code's slug, so this is the newest
slug-carrying release, not just the last code deploy) and creates a release
with it on the target app:
heroku-scripts deploy-slug detroit-new-customer --from=detroit-productionWithout --from, it scans every detroit-* app your credential can see and
picks the most recently built slug among the ones currently running:
heroku-scripts deploy-slug detroit-new-customerRanking is by slug build time, not release time, on purpose: some detroit
apps opt out of auto deploys, so a plain config:set on a stale app creates
a newer release of older code — a latest-release comparison would pick
the wrong slug.
Before releasing anything it prints the slug, its commit, where it came from,
and what the target currently runs (including a warning when the target
already runs that exact slug — releasing it again only restarts dynos), then
asks for confirmation. Pass --dry-run to preview the API call without
running it, or --yes to skip the prompt. This command needs jq, and both
lookups and the release go through your normal heroku CLI credentials.
Static analysis runs through ShellCheck and the test suite through bats; both run in CI on every push:
shellcheck bin/heroku-scripts install.sh
bats testThe tests stub the heroku CLI on PATH, so they never touch a real account.