Skip to content

Commit a6aa9f2

Browse files
cherkanovartclaude
andauthored
fix(cli): exit with non-zero code on localization errors (#2026)
* fix(cli): exit with non-zero code when localization tasks fail Previously, partial localization errors (e.g., API timeout on one locale) were caught per-task and stored in results, but the process still exited with code 0. This caused CI/CD pipelines to silently pass on failures. Now sets process.exitCode = 1 when any task has error status, in both the `run` and deprecated `i18n` commands. Also fixes the `run` command to play the failure sound instead of success when there are errors. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: add changeset for CLI exit code fix Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: extract exit code logic into shared helper Addresses CodeRabbit review — tests now import and verify the actual production function instead of duplicating the logic. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2d676c8 commit a6aa9f2

File tree

5 files changed

+136
-1
lines changed

5 files changed

+136
-1
lines changed

.changeset/exit-code-on-errors.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"lingo.dev": patch
3+
---
4+
5+
Exit with non-zero code when localization tasks fail, so CI/CD pipelines correctly detect partial errors

packages/cli/src/cli/cmd/i18n.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,7 @@ export default new Command()
600600
await new Promise((resolve) => setTimeout(resolve, 50));
601601
} else {
602602
ora.warn("Localization completed with errors.");
603+
process.exitCode = 1;
603604
await trackEvent(email, "cmd.i18n.error", {
604605
flags,
605606
...aggregateErrorAnalytics(
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
2+
import { CmdRunTask, CmdRunTaskResult } from "./_types";
3+
import { applyRunExitCode } from "./exit-code";
4+
5+
/**
6+
* Tests that the CLI exits with non-zero code when localization tasks fail.
7+
* This prevents CI/CD pipelines from silently passing on partial errors.
8+
*/
9+
describe("run command - exit code on errors", () => {
10+
let originalExitCode: number | undefined;
11+
12+
beforeEach(() => {
13+
originalExitCode = process.exitCode;
14+
process.exitCode = undefined;
15+
});
16+
17+
afterEach(() => {
18+
process.exitCode = originalExitCode;
19+
});
20+
21+
function createTask(overrides?: Partial<CmdRunTask>): CmdRunTask {
22+
return {
23+
sourceLocale: "en",
24+
targetLocale: "es",
25+
bucketType: "json",
26+
bucketPathPattern: "[locale]/messages.json",
27+
injectLocale: [],
28+
lockedKeys: [],
29+
lockedPatterns: [],
30+
ignoredKeys: [],
31+
preservedKeys: [],
32+
localizableKeys: [],
33+
onlyKeys: [],
34+
...overrides,
35+
};
36+
}
37+
38+
it("should set exitCode=1 when any task has error status", () => {
39+
const results = new Map<CmdRunTask, CmdRunTaskResult>();
40+
results.set(createTask({ targetLocale: "es" }), { status: "success" });
41+
results.set(createTask({ targetLocale: "fr" }), {
42+
status: "error",
43+
error: new Error("API timeout"),
44+
});
45+
results.set(createTask({ targetLocale: "de" }), { status: "success" });
46+
47+
const hasErrors = applyRunExitCode(results);
48+
49+
expect(hasErrors).toBe(true);
50+
expect(process.exitCode).toBe(1);
51+
});
52+
53+
it("should NOT set exitCode when all tasks succeed", () => {
54+
const results = new Map<CmdRunTask, CmdRunTaskResult>();
55+
results.set(createTask({ targetLocale: "es" }), { status: "success" });
56+
results.set(createTask({ targetLocale: "fr" }), { status: "success" });
57+
58+
const hasErrors = applyRunExitCode(results);
59+
60+
expect(hasErrors).toBe(false);
61+
expect(process.exitCode).toBeUndefined();
62+
});
63+
64+
it("should NOT set exitCode when tasks are skipped (no errors)", () => {
65+
const results = new Map<CmdRunTask, CmdRunTaskResult>();
66+
results.set(createTask({ targetLocale: "es" }), { status: "skipped" });
67+
results.set(createTask({ targetLocale: "fr" }), { status: "success" });
68+
69+
const hasErrors = applyRunExitCode(results);
70+
71+
expect(hasErrors).toBe(false);
72+
expect(process.exitCode).toBeUndefined();
73+
});
74+
75+
it("should set exitCode=1 when all tasks fail", () => {
76+
const results = new Map<CmdRunTask, CmdRunTaskResult>();
77+
results.set(createTask({ targetLocale: "es" }), {
78+
status: "error",
79+
error: new Error("fail 1"),
80+
});
81+
results.set(createTask({ targetLocale: "fr" }), {
82+
status: "error",
83+
error: new Error("fail 2"),
84+
});
85+
86+
const hasErrors = applyRunExitCode(results);
87+
88+
expect(hasErrors).toBe(true);
89+
expect(process.exitCode).toBe(1);
90+
});
91+
92+
it("should set exitCode=1 even with mix of success, skipped, and error", () => {
93+
const results = new Map<CmdRunTask, CmdRunTaskResult>();
94+
results.set(createTask({ targetLocale: "es" }), { status: "success" });
95+
results.set(createTask({ targetLocale: "fr" }), { status: "skipped" });
96+
results.set(createTask({ targetLocale: "de" }), {
97+
status: "error",
98+
error: new Error("one failure"),
99+
});
100+
101+
const hasErrors = applyRunExitCode(results);
102+
103+
expect(hasErrors).toBe(true);
104+
expect(process.exitCode).toBe(1);
105+
});
106+
107+
it("should NOT set exitCode when results map is empty", () => {
108+
const results = new Map<CmdRunTask, CmdRunTaskResult>();
109+
110+
const hasErrors = applyRunExitCode(results);
111+
112+
expect(hasErrors).toBe(false);
113+
expect(process.exitCode).toBeUndefined();
114+
});
115+
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { CmdRunTask, CmdRunTaskResult } from "./_types";
2+
3+
export function applyRunExitCode(results: Map<CmdRunTask, CmdRunTaskResult>) {
4+
const hasErrors = Array.from(results.values()).some(
5+
(r) => r.status === "error",
6+
);
7+
if (hasErrors) {
8+
process.exitCode = 1;
9+
}
10+
return hasErrors;
11+
}

packages/cli/src/cli/cmd/run/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import execute from "./execute";
99
import watch from "./watch";
1010
import { CmdRunContext, flagsSchema } from "./_types";
1111
import frozen from "./frozen";
12+
import { applyRunExitCode } from "./exit-code";
1213
import {
1314
renderClear,
1415
renderSpacer,
@@ -163,9 +164,11 @@ export default new Command()
163164
await renderSummary(ctx.results);
164165
await renderSpacer();
165166

167+
const hasErrors = applyRunExitCode(ctx.results);
168+
166169
// Play sound after main tasks complete if sound flag is enabled
167170
if (ctx.flags.sound) {
168-
await playSound("success");
171+
await playSound(hasErrors ? "failure" : "success");
169172
}
170173

171174
// If watch mode is enabled, start watching for changes

0 commit comments

Comments
 (0)