From ce591c1a2596d86fa684291fec81fb3c66cac367 Mon Sep 17 00:00:00 2001 From: Maarten Jacobs Date: Fri, 14 Aug 2026 11:54:11 +0200 Subject: [PATCH] config-replace: add guarded config:set across a pipeline stage Sets to on every app in a stage, but only where the current value is exactly . Apps without the var are skipped (counted on stderr, or recorded with -a/--all); apps whose value differs are left untouched but reported, so drift stays visible. --dry-run previews the writes. The chunked-parallel/streaming skeleton of pipeline-cmd moves into a shared run_pipeline_workers helper; both commands now differ only in their per-app worker. Two hazards are handled explicitly: a failed config:get is surfaced as an error record instead of having its error text compared against , and a failed config:set is captured before strip_heroku_noise so pipefail can't turn it into a bogus "var not set" skip. Co-Authored-By: Claude Fable 5 --- README.md | 34 +++++ bin/heroku-scripts | 278 +++++++++++++++++++++++++++++---------- test/heroku-scripts.bats | 141 ++++++++++++++++++++ 3 files changed, 384 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index 1939104..4c450d1 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ HEROKU_API_KEY="op://Private/Heroku/credential" op run -- heroku-scripts apps my ```sh heroku-scripts apps heroku-scripts pipeline-cmd "" [--concurrency=N] [--retries=N] [--no-stream] [-a] [--table|--csv] +heroku-scripts config-replace [--concurrency=N] [--dry-run] [-a|--all] [--table|--csv] [--no-stream] heroku-scripts pipeline-task [--concurrency=N] heroku-scripts promote [--dry-run] [--yes] ``` @@ -146,6 +147,39 @@ 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. +### Replace a config var's value across a stage, only where it currently matches + +`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. + +```sh +heroku-scripts config-replace my-pipe production SMTP_HOST smtp.old.example smtp.new.example +``` + +Apps 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: + +```sh +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: ```sh diff --git a/bin/heroku-scripts b/bin/heroku-scripts index d9508ab..d44fdc9 100755 --- a/bin/heroku-scripts +++ b/bin/heroku-scripts @@ -39,6 +39,18 @@ Commands: contain newlines and semicolons, so the CSV is for reading/grepping, not parsing as strict CSV. + config-replace [--concurrency=N] [--dry-run] [-a|--all] [--table|--csv] [--no-stream] + For every app in /, set to — but + only on apps whose current value is exactly . Apps without + are skipped (a count is reported on stderr; -a/--all emits a + "not set" record for them instead). Apps whose value differs are left + untouched but get a visible "skipped: ..." record so drift is not + silent. --dry-run reads every current value and reports what would + change without setting anything. Concurrency, streaming, and + table/CSV output behave as in pipeline-cmd. Note: 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. + pipeline-task [--concurrency=N] Run \`mix \` via \`heroku run\` against every app in /. Defaults to 3 concurrent runs. @@ -284,44 +296,20 @@ emit_record() { done <<< "$output" } -cmd_pipeline_cmd() { - [[ $# -ge 3 ]] || die "Usage: $SCRIPT_NAME pipeline-cmd \"\" [--concurrency=N] [--retries=N] [--no-stream] [-a] [--table|--csv]" - local pipeline="$1" stage="$2" command="$3" - shift 3 - - # Pull our own flags out before handing the rest to parse_concurrency (which - # only knows --concurrency). Streaming is the default; --no-stream buffers and - # emits everything sorted by app name at the end. Apps whose output is empty - # are skipped by default; -a/--all includes them. Output format defaults to a - # table on a terminal and CSV when piped; --table/--csv force one. - local stream=true include_empty=false format=auto retries=0 - local -a opts=() - while [[ $# -gt 0 ]]; do - case "$1" in - --stream) stream=true;; - --no-stream) stream=false;; - -a|--all) include_empty=true;; - --table) format=table;; - --csv) format=csv;; - --retries=*) retries="${1#*=}";; - *) opts+=("$1");; - esac - shift - done - - if [[ ! "$retries" =~ ^[0-9]+$ ]]; then - die "--retries must be a non-negative integer" - fi - - local concurrency=3 - if [[ ${#opts[@]} -gt 0 ]]; then - concurrency="$(parse_concurrency "${opts[@]}")" || exit 1 - fi - - resolve_heroku_api_key - - local apps - apps="$(require_apps "$pipeline" "$stage")" || exit 1 +# Shared skeleton for the commands that fan a per-app worker out across a +# pipeline stage: chunked parallelism, streaming or buffered record emission, +# table/CSV rendering, and the skipped-apps summary. pipeline-cmd and +# config-replace differ only in what they do per app, so that part comes in as +# a worker function name. Worker-specific parameters (the heroku command, the +# config var, ...) are the calling command's locals, which bash's dynamic +# scoping keeps visible inside the worker. +# +# The worker is called with one argument (the app name), prints the app's +# record on stdout, and returns non-zero to skip the app: no record is emitted +# and the app is counted in the stderr summary, rendered as +# " $skip_summary". +run_pipeline_workers() { + local worker="$1" apps="$2" concurrency="$3" stream="$4" format="$5" skip_summary="$6" # Resolve auto -> table on a terminal, CSV when piped. The table's left column # is sized from the (already known) app list, so table output still streams. @@ -345,9 +333,9 @@ cmd_pipeline_cmd() { # shellcheck disable=SC2064 trap "trap - INT TERM EXIT; kill \$(jobs -p) 2>/dev/null || true; rm -rf '$tmpdir'; exit 130" INT TERM - # Workers drop a marker file here for each app skipped due to empty output, so - # the parent can report a count after they all finish. A dotfile dir, so the - # --no-stream collection glob (`$tmpdir/*`) never picks it up. + # Workers drop a marker file here for each app they skip, so the parent can + # report a count after they all finish. A dotfile dir, so the --no-stream + # collection glob (`$tmpdir/*`) never picks it up. mkdir "$tmpdir/.skipped" if [[ "$render" == "table" ]]; then @@ -365,35 +353,37 @@ cmd_pipeline_cmd() { # heroku/psql in the subshell will happily read from the loop's heredoc # and consume the lines that the next `read` was supposed to see — apps # get skipped and their names get fed to heroku as stray positional args. - local in_flight=0 + local in_flight=0 app while IFS= read -r app <&3; do [[ -z "$app" ]] && continue ( # The subshell inherits `set -e`. We deliberately turn it off here so a - # non-zero exit from heroku (e.g. pg:psql against an app without a DB) - # is captured into the output column rather than killing the subshell - # before it can emit its record. + # non-zero exit from heroku inside the worker (e.g. pg:psql against an + # app without a DB) is captured into the output column rather than + # killing the subshell before it can emit its record. set +e - output="$(heroku_for_app_with_retries "$app" "$command" "$retries")" - if [[ -z "$output" && "$include_empty" != "true" ]]; then - # Empty output: record the skip for the summary and emit no record. - : > "$tmpdir/.skipped/$app" - elif [[ "$stream" == "true" ]]; then - # Print as soon as this app finishes. Serialize with an atomic mkdir - # lock so a record streams out in one piece (emit_record may write - # several lines for multi-line/table output) even when apps finish at - # the same time; contention is brief. Ignore SIGPIPE so that if the - # reader goes away (e.g. `| head`) the writes merely fail instead of - # killing the worker mid-record and leaving the lock held; emit_record - # breaks on the first failed write so the lock is released promptly. - trap '' PIPE - while ! mkdir "$tmpdir/.lock" 2>/dev/null; do sleep 0.05; done - emit_record "$render" "$width" "$app" "$output" - rmdir "$tmpdir/.lock" + if output="$("$worker" "$app")"; then + if [[ "$stream" == "true" ]]; then + # Print as soon as this app finishes. Serialize with an atomic mkdir + # lock so a record streams out in one piece (emit_record may write + # several lines for multi-line/table output) even when apps finish at + # the same time; contention is brief. Ignore SIGPIPE so that if the + # reader goes away (e.g. `| head`) the writes merely fail instead of + # killing the worker mid-record and leaving the lock held; emit_record + # breaks on the first failed write so the lock is released promptly. + trap '' PIPE + while ! mkdir "$tmpdir/.lock" 2>/dev/null; do sleep 0.05; done + emit_record "$render" "$width" "$app" "$output" + rmdir "$tmpdir/.lock" + else + # Buffered: store the raw output (filename is the app name) and + # format it sorted at the end. + printf '%s' "$output" > "$tmpdir/$app" + fi else - # Buffered: store the raw output (filename is the app name) and format - # it sorted at the end. - printf '%s' "$output" > "$tmpdir/$app" + # Non-zero from the worker means "skip this app": record it for the + # summary and emit no record. + : > "$tmpdir/.skipped/$app" fi ) 0 )); then - local msg="$skipped app(s) with empty output skipped (use -a/--all to include them)" + local msg="$skipped $skip_summary" if [[ -t 2 && -z "${NO_COLOR:-}" ]]; then printf '\n\033[2;3m%s\033[0m\n' "$msg" >&2 else @@ -437,6 +427,153 @@ cmd_pipeline_cmd() { fi } +# Per-app worker for pipeline-cmd: run the heroku command and use its combined +# output as the record. Empty output means the app has nothing to report (e.g. +# config:get for an unset var), so it is skipped unless -a/--all was passed. +# $command, $retries, and $include_empty are cmd_pipeline_cmd's locals. +pipeline_cmd_worker() { + local app="$1" output + output="$(heroku_for_app_with_retries "$app" "$command" "$retries")" + if [[ -z "$output" && "$include_empty" != "true" ]]; then + return 1 + fi + printf '%s' "$output" +} + +cmd_pipeline_cmd() { + [[ $# -ge 3 ]] || die "Usage: $SCRIPT_NAME pipeline-cmd \"\" [--concurrency=N] [--retries=N] [--no-stream] [-a] [--table|--csv]" + local pipeline="$1" stage="$2" command="$3" + shift 3 + + # Pull our own flags out before handing the rest to parse_concurrency (which + # only knows --concurrency). Streaming is the default; --no-stream buffers and + # emits everything sorted by app name at the end. Apps whose output is empty + # are skipped by default; -a/--all includes them. Output format defaults to a + # table on a terminal and CSV when piped; --table/--csv force one. + local stream=true include_empty=false format=auto retries=0 + local -a opts=() + while [[ $# -gt 0 ]]; do + case "$1" in + --stream) stream=true;; + --no-stream) stream=false;; + -a|--all) include_empty=true;; + --table) format=table;; + --csv) format=csv;; + --retries=*) retries="${1#*=}";; + *) opts+=("$1");; + esac + shift + done + + if [[ ! "$retries" =~ ^[0-9]+$ ]]; then + die "--retries must be a non-negative integer" + fi + + local concurrency=3 + if [[ ${#opts[@]} -gt 0 ]]; then + concurrency="$(parse_concurrency "${opts[@]}")" || exit 1 + fi + + resolve_heroku_api_key + + local apps + apps="$(require_apps "$pipeline" "$stage")" || exit 1 + + run_pipeline_workers pipeline_cmd_worker "$apps" "$concurrency" "$stream" "$format" \ + "app(s) with empty output skipped (use -a/--all to include them)" +} + +# Per-app worker for config-replace. Reads the current value with a real argv +# call — and the app are discrete values, so routing them through +# heroku_for_app's eval would invite word-splitting and glob expansion — and +# only writes when it is exactly $old_value. $var, $old_value, $new_value, +# $dry_run, and $include_unset are cmd_config_replace's locals. +config_replace_worker() { + local app="$1" current status + # Capture config:get's raw output and exit status before filtering — piping + # straight into strip_heroku_noise (which ends in `|| true`) would lose the + # status. A failed lookup (no access, app gone) must never have its error + # text compared against : in the pathological case a matching + # message would authorize a write to an app whose value was never read. + current="$(heroku config:get "$var" -a "$app" 2>&1)" + status=$? + current="$(strip_heroku_noise <<< "$current")" + if (( status != 0 )); then + printf 'error: %s' "$current" + return 0 + fi + # config:get prints an empty line when the var is unset — and also when it + # is set to the empty string; its output cannot tell the two apart, so an + # empty-string value is treated as not set. The command substitution + # collapses that line to an empty string. + if [[ -z "$current" ]]; then + if [[ "$include_unset" == "true" ]]; then + printf 'skipped: %s not set' "$var" + return 0 + fi + return 1 + fi + if [[ "$current" != "$old_value" ]]; then + # A different value is left alone, but visibly: a silent skip would hide + # apps that have drifted from the value the operator expected. + printf 'skipped: %s is "%s" (expected "%s")' "$var" "$current" "$old_value" + return 0 + fi + if [[ "$dry_run" == "true" ]]; then + printf 'would set %s=%s (currently %s)' "$var" "$new_value" "$old_value" + return 0 + fi + # argv form, never eval: the new value may contain spaces or shell + # metacharacters and must reach heroku as one word. Captured first rather + # than piped straight into strip_heroku_noise: under pipefail a failed + # config:set would make the pipeline — and so this worker — return non-zero, + # which the runner counts as a skip, silently misreporting a failed write as + # "var not set". Success or failure, the combined output is the record. + local result + result="$(heroku config:set "$var=$new_value" -a "$app" 2>&1)" + strip_heroku_noise <<< "$result" +} + +cmd_config_replace() { + [[ $# -ge 5 ]] || die "Usage: $SCRIPT_NAME config-replace [--concurrency=N] [--dry-run] [-a|--all] [--table|--csv] [--no-stream]" + local pipeline="$1" stage="$2" var="$3" old_value="$4" new_value="$5" + shift 5 + + # Same flag surface as pipeline-cmd, plus --dry-run (read every current + # value, write nothing). -a/--all here means "include apps that don't have + # set", recording them as "skipped: not set" instead of omitting + # them. + local stream=true include_unset=false format=auto dry_run=false + local -a opts=() + while [[ $# -gt 0 ]]; do + case "$1" in + --stream) stream=true;; + --no-stream) stream=false;; + -a|--all) include_unset=true;; + --table) format=table;; + --csv) format=csv;; + --dry-run) dry_run=true;; + *) opts+=("$1");; + esac + shift + done + + local concurrency=3 + if [[ ${#opts[@]} -gt 0 ]]; then + concurrency="$(parse_concurrency "${opts[@]}")" || exit 1 + fi + + # A dry run still reads every app's current value via config:get, so it + # needs credentials just like a real run. + resolve_heroku_api_key + + local apps + apps="$(require_apps "$pipeline" "$stage")" || exit 1 + + run_pipeline_workers config_replace_worker "$apps" "$concurrency" "$stream" "$format" \ + "app(s) without $var skipped (use -a/--all to include them)" +} + cmd_pipeline_task() { [[ $# -ge 3 ]] || die "Usage: $SCRIPT_NAME pipeline-task [--concurrency=N]" local pipeline="$1" stage="$2" task="$3" @@ -543,6 +680,9 @@ main() { pipeline-cmd) cmd_pipeline_cmd "$@" ;; + config-replace) + cmd_config_replace "$@" + ;; pipeline-task) cmd_pipeline_task "$@" ;; diff --git a/test/heroku-scripts.bats b/test/heroku-scripts.bats index 351abbd..793ce63 100644 --- a/test/heroku-scripts.bats +++ b/test/heroku-scripts.bats @@ -197,6 +197,147 @@ STUB [[ "$output" == *"No apps found"* ]] } +# heroku stub for config-replace: serves per-app config:get values (with +# spaces, to prove values survive as single argv words) and logs every +# config:set argv to ./set-calls (workers inherit the test's cwd), so tests +# can assert the exact call shape — or that no call happened at all. +_heroku_stub_config_values() { + cat > "$TESTDIR/bin/heroku" <<'STUB' +#!/usr/bin/env bash +if [[ "$1" == "pipelines:info" ]]; then + printf '=== %s\napp-match staging\napp-unset staging\napp-differs staging\n' "$2"; exit 0 +fi +app=""; prev="" +for a in "$@"; do [[ "$prev" == "-a" ]] && app="$a"; prev="$a"; done +if [[ "$1" == "config:get" ]]; then + # Real config:get prints an empty line when the var is unset. + case "$app" in + app-match) echo "old value";; + app-differs) echo "other value";; + app-unset) echo "";; + esac + exit 0 +fi +if [[ "$1" == "config:set" ]]; then + { printf 'SET'; for a in "$@"; do printf ' [%s]' "$a"; done; printf '\n'; } >> ./set-calls + echo "set-done-$app" + exit 0 +fi +echo "unexpected: $*" >&2 +exit 1 +STUB + chmod +x "$TESTDIR/bin/heroku" +} + +@test "config-replace sets the var where the value matches and uses config:set output as the record" { + _heroku_stub_config_values + run --separate-stderr "$SCRIPT" config-replace mypipe staging MY_VAR "old value" "new value" + [ "$status" -eq 0 ] + [[ "$output" == *"app-match;set-done-app-match"* ]] +} + +@test "config-replace passes VAR=value and -a app to config:set as separate argv words" { + _heroku_stub_config_values + run "$SCRIPT" config-replace mypipe staging MY_VAR "old value" "new value" + [ "$status" -eq 0 ] + # Exactly one config:set, with the space-containing value as ONE word. + [ "$(wc -l < set-calls | tr -d ' ')" = "1" ] + grep -qF "SET [config:set] [MY_VAR=new value] [-a] [app-match]" set-calls +} + +@test "config-replace skips apps without the var and reports a count on stderr" { + _heroku_stub_config_values + # File capture rather than `run`, matching the empty-output test above. + "$SCRIPT" config-replace mypipe staging MY_VAR "old value" "new value" >stdout.txt 2>stderr.txt + ! grep -q "app-unset" stdout.txt + [ -z "$(head -n 1 stderr.txt)" ] + grep -q "1 app(s) without MY_VAR skipped" stderr.txt + grep -q -- "-a/--all" stderr.txt +} + +@test "config-replace -a includes unset apps with a not-set record" { + _heroku_stub_config_values + run --separate-stderr "$SCRIPT" config-replace mypipe staging MY_VAR "old value" "new value" -a + [ "$status" -eq 0 ] + [[ "$output" == *"app-unset;skipped: MY_VAR not set"* ]] + [[ "$stderr" != *"skipped"* ]] +} + +@test "config-replace leaves a different value alone and emits a mismatch record" { + _heroku_stub_config_values + run "$SCRIPT" config-replace mypipe staging MY_VAR "old value" "new value" + [ "$status" -eq 0 ] + [[ "$output" == *'app-differs;skipped: MY_VAR is "other value" (expected "old value")'* ]] + ! grep -q "app-differs" set-calls +} + +@test "config-replace --dry-run reports would-set records and never calls config:set" { + _heroku_stub_config_values + run "$SCRIPT" config-replace mypipe staging MY_VAR "old value" "new value" --dry-run + [ "$status" -eq 0 ] + [[ "$output" == *"app-match;would set MY_VAR=new value (currently old value)"* ]] + # The stub logs every config:set; the file never existing proves none ran. + [ ! -e set-calls ] +} + +@test "config-replace rejects the wrong argument count" { + run "$SCRIPT" config-replace mypipe staging MY_VAR "old value" + [ "$status" -eq 1 ] + [[ "$output" == *"Usage:"* ]] +} + +@test "config-replace rejects a non-positive concurrency" { + run "$SCRIPT" config-replace mypipe staging MY_VAR old new --concurrency=0 + [ "$status" -eq 1 ] + [[ "$output" == *"positive integer"* ]] +} + +@test "config-replace surfaces a failed config:get as an error record and never writes" { + # A failed lookup must not have its error text compared against — + # here the message IS the old value, the worst case for that comparison. + cat > "$TESTDIR/bin/heroku" <<'STUB' +#!/usr/bin/env bash +if [[ "$1" == "pipelines:info" ]]; then + printf '=== %s\napp-geterr staging\n' "$2"; exit 0 +fi +if [[ "$1" == "config:get" ]]; then + echo "old value" >&2 + exit 1 +fi +if [[ "$1" == "config:set" ]]; then + : >> ./set-calls + exit 0 +fi +STUB + chmod +x "$TESTDIR/bin/heroku" + run "$SCRIPT" config-replace mypipe staging MY_VAR "old value" "new value" + [ "$status" -eq 0 ] + [[ "$output" == *"app-geterr;error: old value"* ]] + [ ! -e set-calls ] +} + +@test "config-replace surfaces a failed config:set as the app's record, not a skip" { + cat > "$TESTDIR/bin/heroku" <<'STUB' +#!/usr/bin/env bash +if [[ "$1" == "pipelines:info" ]]; then + printf '=== %s\napp-match staging\n' "$2"; exit 0 +fi +if [[ "$1" == "config:get" ]]; then + echo "old value" + exit 0 +fi +if [[ "$1" == "config:set" ]]; then + echo "Boom: rate limited" >&2 + exit 1 +fi +STUB + chmod +x "$TESTDIR/bin/heroku" + run --separate-stderr "$SCRIPT" config-replace mypipe staging MY_VAR "old value" "new value" + [ "$status" -eq 0 ] + [[ "$output" == *"app-match;Boom: rate limited"* ]] + [[ "$stderr" != *"skipped"* ]] +} + @test "promote --dry-run prints commands without running or prompting" { run "$SCRIPT" promote my-app team pipe --dry-run [ "$status" -eq 0 ]