diff --git a/scripts/deploy-selfhost-image.sh b/scripts/deploy-selfhost-image.sh index acac51ca3c..c3b9b87fe0 100755 --- a/scripts/deploy-selfhost-image.sh +++ b/scripts/deploy-selfhost-image.sh @@ -108,7 +108,13 @@ services: build: !reset null YAML -mapfile -t compose_args < <(compose_file_args) +# #7765: capture via a checked assignment so compose_file_args's `exit 1` on a missing compose file +# actually aborts this script -- `mapfile < <(compose_file_args)` ran it in a subshell whose non-zero +# exit was swallowed (mapfile itself returns 0), leaving compose_args empty/truncated. +if ! compose_args_raw="$(compose_file_args)"; then + exit 1 +fi +mapfile -t compose_args <<< "$compose_args_raw" compose_args+=(-f "$override_file") echo "selfhost image deploy: ensuring secret placeholder files exist" diff --git a/scripts/deploy-selfhost-prebuilt.sh b/scripts/deploy-selfhost-prebuilt.sh index 225ef34539..0080c1c20f 100755 --- a/scripts/deploy-selfhost-prebuilt.sh +++ b/scripts/deploy-selfhost-prebuilt.sh @@ -104,7 +104,13 @@ services: LOOPOVER_VERSION: "\${SENTRY_RELEASE}" YAML - mapfile -t compose_args < <(compose_file_args) + # #7765: capture via a checked assignment so compose_file_args's `exit 1` on a missing compose file + # actually aborts this script -- `mapfile < <(compose_file_args)` ran it in a subshell whose non-zero + # exit was swallowed (mapfile itself returns 0), leaving compose_args empty/truncated. + if ! compose_args_raw="$(compose_file_args)"; then + exit 1 + fi + mapfile -t compose_args <<< "$compose_args_raw" compose_args+=(-f "$override_file") echo "selfhost deploy: building $SERVICE runtime-prebuilt image" diff --git a/scripts/selfhost-post-update-check.sh b/scripts/selfhost-post-update-check.sh index f697d909bd..4ec4895b53 100755 --- a/scripts/selfhost-post-update-check.sh +++ b/scripts/selfhost-post-update-check.sh @@ -22,7 +22,13 @@ require_cmd docker require_cmd curl docker compose version >/dev/null -mapfile -t compose_args < <(compose_file_args) +# #7765: capture via a checked assignment so compose_file_args's `exit 1` on a missing compose file +# actually aborts this script -- `mapfile < <(compose_file_args)` ran it in a subshell whose non-zero +# exit was swallowed (mapfile itself returns 0), leaving compose_args empty/truncated. +if ! compose_args_raw="$(compose_file_args)"; then + exit 1 +fi +mapfile -t compose_args <<< "$compose_args_raw" container_id="$(docker compose "${compose_args[@]}" ps -q "$SERVICE" 2>/dev/null || true)" if [ -z "$container_id" ]; then diff --git a/scripts/selfhost-post-update-regression-gate.sh b/scripts/selfhost-post-update-regression-gate.sh index 10d1024528..788dee6be9 100755 --- a/scripts/selfhost-post-update-regression-gate.sh +++ b/scripts/selfhost-post-update-regression-gate.sh @@ -41,7 +41,13 @@ if [[ ! "$THRESHOLD" =~ ^[0-9]+$ ]]; then THRESHOLD=5 fi -mapfile -t compose_args < <(compose_file_args) +# #7765: capture via a checked assignment so compose_file_args's `exit 1` on a missing compose file +# actually aborts this script -- `mapfile < <(compose_file_args)` ran it in a subshell whose non-zero +# exit was swallowed (mapfile itself returns 0), leaving compose_args empty/truncated. +if ! compose_args_raw="$(compose_file_args)"; then + exit 1 +fi +mapfile -t compose_args <<< "$compose_args_raw" container_id="$(docker compose "${compose_args[@]}" ps -q "$SERVICE" 2>/dev/null || true)" if [ -z "$container_id" ]; then diff --git a/test/unit/selfhost-deploy-common.test.ts b/test/unit/selfhost-deploy-common.test.ts index c359483feb..61a6719483 100644 --- a/test/unit/selfhost-deploy-common.test.ts +++ b/test/unit/selfhost-deploy-common.test.ts @@ -175,3 +175,55 @@ describe("env_put (#7766 -- atomic write + mode preservation)", () => { } }); }); + +describe("compose_file_args exit propagation (#7765)", () => { + // The exact idiom the callers (deploy-selfhost-image.sh etc.) now use to consume compose_file_args. + // Under the old `mapfile -t compose_args < <(compose_file_args)` the function ran in a subshell whose + // `exit 1` on a missing file was swallowed (mapfile returns 0), so the caller continued with an + // empty/truncated -f arg list. The checked assignment must instead abort before REACHED_END. + const CONSUMER = ` +set -euo pipefail +. "${libPath.replace(/\\/g, "/")}" +if ! compose_args_raw="$(compose_file_args)"; then + exit 1 +fi +mapfile -t compose_args <<< "$compose_args_raw" +printf 'REACHED_END args=[%s]\\n' "\${compose_args[*]}" +`; + + function runConsumer(env: Record = {}) { + const dir = mkdtempSync(join(tmpdir(), "loopover-compose-args-")); + try { + // Give the default-branch a real docker-compose.yml so the happy path has a file to find. + writeFileSync(join(dir, "docker-compose.yml"), "services: {}\n"); + writeFileSync(join(dir, "base.yml"), "services: {}\n"); + return spawnSync("bash", ["-c", CONSUMER], { + cwd: dir, + encoding: "utf8", + env: { ...process.env, ...env }, + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } + + it("continues with the -f args when every compose file exists", () => { + const result = runConsumer(); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("REACHED_END args=[-f docker-compose.yml]"); + }); + + it("aborts (never reaching the consumer) when the sole compose file is missing", () => { + const result = runConsumer({ SELFHOST_COMPOSE_FILES: "does-not-exist.yml" }); + expect(result.status).not.toBe(0); + expect(result.stdout).not.toContain("REACHED_END"); + expect(result.stderr).toContain("compose file not found: does-not-exist.yml"); + }); + + it("aborts instead of continuing with a TRUNCATED arg list when a later compose file is missing", () => { + const result = runConsumer({ SELFHOST_COMPOSE_FILES: "base.yml missing.yml" }); + expect(result.status).not.toBe(0); + expect(result.stdout).not.toContain("REACHED_END"); + expect(result.stderr).toContain("compose file not found: missing.yml"); + }); +});