Skip to content

Commit 2ac97ad

Browse files
fix(scripts): abort deploy when compose_file_args reports a missing file (#7862)
compose_file_args() exits 1 on a missing compose file, but all 4 callers consumed it via `mapfile -t compose_args < <(compose_file_args)`. The process substitution runs the function in a subshell, so its exit 1 only kills that subshell; mapfile itself returns 0, so set -e never fires and the caller kept going -- invoking `docker compose` with an empty or truncated -f set instead of aborting on a stale/mistyped compose path. Consume it via a checked command-substitution assignment (`if ! compose_args_raw="$(compose_file_args)"; then exit 1; fi`) at all 4 call sites, then split into the array with a here-string. This propagates the real exit code (including the truncated-partial-output case) regardless of set -e. compose_file_args's own logic is unchanged. Adds compose_file_args exit-propagation tests to selfhost-deploy-common.test.ts: happy path continues, a missing sole file aborts before the consumer, and a later missing file aborts instead of continuing with a truncated arg list. Closes #7765 Co-authored-by: jaytbarimbao-collab <300663773+jaytbarimbao-collab@users.noreply.github.com>
1 parent 80eb9f3 commit 2ac97ad

5 files changed

Lines changed: 80 additions & 4 deletions

scripts/deploy-selfhost-image.sh

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,13 @@ services:
108108
build: !reset null
109109
YAML
110110

111-
mapfile -t compose_args < <(compose_file_args)
111+
# #7765: capture via a checked assignment so compose_file_args's `exit 1` on a missing compose file
112+
# actually aborts this script -- `mapfile < <(compose_file_args)` ran it in a subshell whose non-zero
113+
# exit was swallowed (mapfile itself returns 0), leaving compose_args empty/truncated.
114+
if ! compose_args_raw="$(compose_file_args)"; then
115+
exit 1
116+
fi
117+
mapfile -t compose_args <<< "$compose_args_raw"
112118
compose_args+=(-f "$override_file")
113119

114120
echo "selfhost image deploy: ensuring secret placeholder files exist"

scripts/deploy-selfhost-prebuilt.sh

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,13 @@ services:
104104
LOOPOVER_VERSION: "\${SENTRY_RELEASE}"
105105
YAML
106106

107-
mapfile -t compose_args < <(compose_file_args)
107+
# #7765: capture via a checked assignment so compose_file_args's `exit 1` on a missing compose file
108+
# actually aborts this script -- `mapfile < <(compose_file_args)` ran it in a subshell whose non-zero
109+
# exit was swallowed (mapfile itself returns 0), leaving compose_args empty/truncated.
110+
if ! compose_args_raw="$(compose_file_args)"; then
111+
exit 1
112+
fi
113+
mapfile -t compose_args <<< "$compose_args_raw"
108114
compose_args+=(-f "$override_file")
109115

110116
echo "selfhost deploy: building $SERVICE runtime-prebuilt image"

scripts/selfhost-post-update-check.sh

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,13 @@ require_cmd docker
2222
require_cmd curl
2323
docker compose version >/dev/null
2424

25-
mapfile -t compose_args < <(compose_file_args)
25+
# #7765: capture via a checked assignment so compose_file_args's `exit 1` on a missing compose file
26+
# actually aborts this script -- `mapfile < <(compose_file_args)` ran it in a subshell whose non-zero
27+
# exit was swallowed (mapfile itself returns 0), leaving compose_args empty/truncated.
28+
if ! compose_args_raw="$(compose_file_args)"; then
29+
exit 1
30+
fi
31+
mapfile -t compose_args <<< "$compose_args_raw"
2632

2733
container_id="$(docker compose "${compose_args[@]}" ps -q "$SERVICE" 2>/dev/null || true)"
2834
if [ -z "$container_id" ]; then

scripts/selfhost-post-update-regression-gate.sh

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,13 @@ if [[ ! "$THRESHOLD" =~ ^[0-9]+$ ]]; then
4141
THRESHOLD=5
4242
fi
4343

44-
mapfile -t compose_args < <(compose_file_args)
44+
# #7765: capture via a checked assignment so compose_file_args's `exit 1` on a missing compose file
45+
# actually aborts this script -- `mapfile < <(compose_file_args)` ran it in a subshell whose non-zero
46+
# exit was swallowed (mapfile itself returns 0), leaving compose_args empty/truncated.
47+
if ! compose_args_raw="$(compose_file_args)"; then
48+
exit 1
49+
fi
50+
mapfile -t compose_args <<< "$compose_args_raw"
4551

4652
container_id="$(docker compose "${compose_args[@]}" ps -q "$SERVICE" 2>/dev/null || true)"
4753
if [ -z "$container_id" ]; then

test/unit/selfhost-deploy-common.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,3 +175,55 @@ describe("env_put (#7766 -- atomic write + mode preservation)", () => {
175175
}
176176
});
177177
});
178+
179+
describe("compose_file_args exit propagation (#7765)", () => {
180+
// The exact idiom the callers (deploy-selfhost-image.sh etc.) now use to consume compose_file_args.
181+
// Under the old `mapfile -t compose_args < <(compose_file_args)` the function ran in a subshell whose
182+
// `exit 1` on a missing file was swallowed (mapfile returns 0), so the caller continued with an
183+
// empty/truncated -f arg list. The checked assignment must instead abort before REACHED_END.
184+
const CONSUMER = `
185+
set -euo pipefail
186+
. "${libPath.replace(/\\/g, "/")}"
187+
if ! compose_args_raw="$(compose_file_args)"; then
188+
exit 1
189+
fi
190+
mapfile -t compose_args <<< "$compose_args_raw"
191+
printf 'REACHED_END args=[%s]\\n' "\${compose_args[*]}"
192+
`;
193+
194+
function runConsumer(env: Record<string, string> = {}) {
195+
const dir = mkdtempSync(join(tmpdir(), "loopover-compose-args-"));
196+
try {
197+
// Give the default-branch a real docker-compose.yml so the happy path has a file to find.
198+
writeFileSync(join(dir, "docker-compose.yml"), "services: {}\n");
199+
writeFileSync(join(dir, "base.yml"), "services: {}\n");
200+
return spawnSync("bash", ["-c", CONSUMER], {
201+
cwd: dir,
202+
encoding: "utf8",
203+
env: { ...process.env, ...env },
204+
});
205+
} finally {
206+
rmSync(dir, { recursive: true, force: true });
207+
}
208+
}
209+
210+
it("continues with the -f args when every compose file exists", () => {
211+
const result = runConsumer();
212+
expect(result.status, result.stderr).toBe(0);
213+
expect(result.stdout).toContain("REACHED_END args=[-f docker-compose.yml]");
214+
});
215+
216+
it("aborts (never reaching the consumer) when the sole compose file is missing", () => {
217+
const result = runConsumer({ SELFHOST_COMPOSE_FILES: "does-not-exist.yml" });
218+
expect(result.status).not.toBe(0);
219+
expect(result.stdout).not.toContain("REACHED_END");
220+
expect(result.stderr).toContain("compose file not found: does-not-exist.yml");
221+
});
222+
223+
it("aborts instead of continuing with a TRUNCATED arg list when a later compose file is missing", () => {
224+
const result = runConsumer({ SELFHOST_COMPOSE_FILES: "base.yml missing.yml" });
225+
expect(result.status).not.toBe(0);
226+
expect(result.stdout).not.toContain("REACHED_END");
227+
expect(result.stderr).toContain("compose file not found: missing.yml");
228+
});
229+
});

0 commit comments

Comments
 (0)