Repository: qltysh/qlty-action
Pull request #67: Add `cli-version` input
Author: login: brynary
Created: 2025-04-18T01:53:06Z | Merged: 2025-04-18T02:40:48Z

Fixes #37

Linked issue #37: Allowing locking to a specific version of Qlty CLI.
The latest release of Qlty CLI has broken coverage upload for us.

Ideally I'd like to be able to go into our ci.yml, and easily adjust qltysh/qlty-action/coverage@main to specify to use version 468 of the client rather than the latest.  
Linked issue comments: user: login: brynary
created_at: 2025-04-08T16:19:48Z
body: Thanks, @tim-gq . Right now, our installer script does not allow specifying the version to install. It always installs the latest version.

So to support this we would need to do two things:

1. Enhance the installer script to support specifying a version
2. Add an optional `qlty-cli-version` input to the GitHub action

This sounds reasonable, however we will probably treat this as low priority relative to some other things we are working on. 
author_association: MEMBER
user: login: brynary
created_at: 2025-04-18T02:41:52Z
body: @tim-gq -- I realized that this GitHub Action doesn't use the shell script installer. Instead, it downloads from S3 directly. Therefore, this feature was not too complex.

I implemented it in #67 and it will be available in the next semver release v1.1.0
author_association: MEMBER

Diff:
diff --git a/coverage/README.md b/coverage/README.md
index 6e2e9d6..fc07596 100644
--- a/coverage/README.md
+++ b/coverage/README.md
@@ -86,6 +86,7 @@ The Qlty Cloud offers free plans, including for commercial projects, with no lim
 | `skip-errors`        | If coverage upload fails, do not fail the CI build occur                                                                                                                                                               | No       | `true`  |
 | `skip-missing-files` | Files not in the directory are skipped                                                                                                                                                                                 | No       | `false` |
 | `tag`                | Tag to associate with the coverage data                                                                                                                                                                                | No       | -       |
+| `cli-version`        | Specific version of the Qlty CLI to use (e.g., '1.0.1'). If not specified, will install the latest version.s                                                                                                           | No       |         |
 
 ---
 
diff --git a/coverage/action.yml b/coverage/action.yml
index da8c229..91e9ca3 100644
--- a/coverage/action.yml
+++ b/coverage/action.yml
@@ -61,6 +61,11 @@ inputs:
     description: "The total number of coverage uploads that qlty cloud should expect"
     required: false
 
+  cli-version:
+    description: "The version of the Qlty CLI to use (e.g., '1.2.3'). If not specified, the latest version will be used."
+    required: false
+    default: ""
+
 runs:
   using: node20
   main: dist/index.js
diff --git a/coverage/dist/index.js b/coverage/dist/index.js
index 59e5097..abc7fcc 100644
--- a/coverage/dist/index.js
+++ b/coverage/dist/index.js
@@ -69276,23 +69276,26 @@ var StubbedOutput = class {
 // src/installer.ts
 var DOWNLOAD_EVENT = "download";
 var Installer = class _Installer {
-  constructor(os4, output, toolCache) {
+  constructor(os4, output, toolCache, version) {
     __publicField(this, "_os");
     __publicField(this, "_output");
     __publicField(this, "_tc");
     __publicField(this, "_emitter", new import_node_events.default());
+    __publicField(this, "_version");
     this._os = os4;
     this._output = output;
     this._tc = toolCache;
+    this._version = version;
   }
-  static create() {
-    return new _Installer(import_os.default, core, tc);
+  static create(version) {
+    return new _Installer(import_os.default, core, tc, version);
   }
-  static createNull() {
+  static createNull(version, raiseDownloadError) {
     return new _Installer(
       new StubbedOperatingSystem(),
       new StubbedOutput(),
-      new StubbedToolCache()
+      new StubbedToolCache(raiseDownloadError),
+      version
     );
   }
   trackOutput() {
@@ -69316,13 +69319,14 @@ var Installer = class _Installer {
       );
       return;
     }
-    const downloadUrl = `https://qlty-releases.s3.amazonaws.com/qlty/latest/qlty-${platformArch}.tar.xz`;
+    const versionPath = this._version ? `v${this._version}` : "latest";
+    const downloadUrl = `https://qlty-releases.s3.amazonaws.com/qlty/${versionPath}/qlty-${platformArch}.tar.xz`;
     const tarPath = await this._tc.downloadTool(downloadUrl);
     const extractedFolder = await this._tc.extractTar(tarPath, void 0, "x");
     const cachedPath = await this._tc.cacheDir(
       extractedFolder,
       "qlty",
-      "latest"
+      versionPath
     );
     this._emitter.emit(DOWNLOAD_EVENT, downloadUrl);
     const binPath = `${cachedPath}/qlty-${platformArch}`;
@@ -69344,12 +69348,18 @@ var StubbedOperatingSystem = class {
   }
 };
 var StubbedToolCache = class {
-  constructor() {
+  constructor(raiseError) {
     __publicField(this, "downloads", []);
+    __publicField(this, "raiseError", false);
+    this.raiseError = raiseError || false;
   }
   async downloadTool(url2) {
-    this.downloads.push(url2);
-    return `downloaded[${url2}]`;
+    if (this.raiseError) {
+      throw new Error("download error");
+    } else {
+      this.downloads.push(url2);
+      return `downloaded[${url2}]`;
+    }
   }
   async extractTar(file, _dest, _options) {
     return `extracted[${file} dest=${_dest} options=${_options}]`;
@@ -73437,7 +73447,8 @@ var settingsParser = z.object({
     return isNaN(num) ? void 0 : num;
   }),
   oidc: z.boolean(),
-  verbose: z.boolean()
+  verbose: z.boolean(),
+  cliVersion: z.string().transform((val) => val === "" ? void 0 : val)
 });
 var OIDC_AUDIENCE = "https://qlty.sh";
 var COVERAGE_TOKEN_REGEX = /^(qltcp_|qltcw_)[a-zA-Z0-9]{10,}$/;
@@ -73463,7 +73474,8 @@ var Settings = class _Settings {
         oidc: input.getBooleanInput("oidc"),
         coverageToken: input.getInput("coverage-token"),
         token: input.getInput("token"),
-        verbose: input.getBooleanInput("verbose")
+        verbose: input.getBooleanInput("verbose"),
+        cliVersion: input.getInput("cli-version")
       }),
       input,
       fs
@@ -73502,6 +73514,12 @@ var Settings = class _Settings {
       }
     }
   }
+  getVersion() {
+    if (!this._data.cliVersion) {
+      return void 0;
+    }
+    return this._data.cliVersion.startsWith("v") ? this._data.cliVersion.substring(1) : this._data.cliVersion;
+  }
   async getFiles() {
     const patterns = this._data.files.split(",").map((file) => file.trim()).filter(Boolean);
     const expandedFiles = await this._fs.globPatterns(patterns.join("\n"));
@@ -73527,8 +73545,8 @@ var FileSystem = class _FileSystem {
   static create() {
     return new _FileSystem();
   }
-  static createNull() {
-    return new StubbedFileSystem();
+  static createNull(results) {
+    return new StubbedFileSystem(results);
   }
   async globPatterns(patterns) {
     const globber = await glob.create(patterns);
@@ -73536,8 +73554,16 @@ var FileSystem = class _FileSystem {
   }
 };
 var StubbedFileSystem = class {
+  constructor(results = void 0) {
+    __publicField(this, "results", []);
+    this.results = results;
+  }
   async globPatterns(patterns) {
-    return patterns.split("\n").map((pattern) => pattern.trim()).filter(Boolean);
+    if (this.results) {
+      return this.results;
+    } else {
+      return patterns.split("\n").map((pattern) => pattern.trim()).filter(Boolean);
+    }
   }
 };
 var StubbedInputProvider = class {
@@ -73554,7 +73580,8 @@ var StubbedInputProvider = class {
       oidc: data.oidc || false,
       "coverage-token": data["coverage-token"] || "",
       token: data.token || "",
-      verbose: data.verbose || false
+      verbose: data.verbose || false,
+      "cli-version": data["cli-version"] || ""
     };
   }
   getInput(name, _options) {
@@ -73596,7 +73623,7 @@ var CoverageAction = class _CoverageAction {
     output = actionsCore,
     context: context3 = actionsGithub.context,
     executor = actionsExec,
-    installer = Installer.create(),
+    installer,
     settings = Settings.create()
   } = {}) {
     __publicField(this, "_output");
@@ -73608,21 +73635,21 @@ var CoverageAction = class _CoverageAction {
     this._output = output;
     this._context = context3;
     this._executor = executor;
-    this._installer = installer;
+    this._installer = installer || Installer.create(settings.getVersion());
     this._settings = settings;
   }
   static createNull({
     output = new StubbedOutput(),
     context: context3 = new StubbedActionContext(),
     executor = new StubbedCommandExecutor(),
-    installer = Installer.createNull(),
+    installer,
     settings = Settings.createNull()
   } = {}) {
     return new _CoverageAction({
       output,
       context: context3,
       executor,
-      installer,
+      installer: installer || Installer.createNull(settings.getVersion()),
       settings
     });
   }
@@ -73630,7 +73657,15 @@ var CoverageAction = class _CoverageAction {
     if (!this.validate()) {
       return;
     }
-    await this._installer.install();
+    try {
+      await this._installer.install();
+    } catch (error) {
+      const errorMessage = error instanceof Error ? `: ${error.message}.` : ".";
+      this.warnOrThrow([
+        `Error installing Qlty CLI${errorMessage} Please check the action's inputs. If you are using a 'cli-version', make sure it is correct.`
+      ]);
+      return;
+    }
     let uploadArgs = await this.buildArgs();
     const files = await this._settings.getFiles();
     if (files.length === 0) {
diff --git a/coverage/src/action.ts b/coverage/src/action.ts
index 35e43cc..05aa93b 100644
--- a/coverage/src/action.ts
+++ b/coverage/src/action.ts
@@ -23,7 +23,7 @@ export class CoverageAction {
     output = new StubbedOutput(),
     context = new StubbedActionContext(),
     executor = new StubbedCommandExecutor(),
-    installer = Installer.createNull(),
+    installer,
     settings = Settings.createNull(),
   }: {
     output?: ActionOutput;
@@ -36,7 +36,7 @@ export class CoverageAction {
       output,
       context,
       executor,
-      installer,
+      installer: installer || Installer.createNull(settings.getVersion()),
       settings,
     });
   }
@@ -45,7 +45,7 @@ export class CoverageAction {
     output = actionsCore,
     context = actionsGithub.context,
     executor = actionsExec,
-    installer = Installer.create(),
+    installer,
     settings = Settings.create(),
   }: {
     output?: ActionOutput;
@@ -57,7 +57,7 @@ export class CoverageAction {
     this._output = output;
     this._context = context;
     this._executor = executor;
-    this._installer = installer;
+    this._installer = installer || Installer.create(settings.getVersion());
     this._settings = settings;
   }
 
@@ -66,7 +66,15 @@ export class CoverageAction {
       return;
     }
 
-    await this._installer.install();
+    try {
+      await this._installer.install();
+    } catch (error) {
+      const errorMessage = error instanceof Error ? `: ${error.message}.` : ".";
+      this.warnOrThrow([
+        `Error installing Qlty CLI${errorMessage} Please check the action's inputs. If you are using a 'cli-version', make sure it is correct.`,
+      ]);
+      return;
+    }
 
     let uploadArgs = await this.buildArgs();
     const files = await this._settings.getFiles();
diff --git a/coverage/src/installer.ts b/coverage/src/installer.ts
index a4112cc..99a46bf 100644
--- a/coverage/src/installer.ts
+++ b/coverage/src/installer.ts
@@ -12,23 +12,31 @@ export class Installer {
   private _output: ActionOutput;
   private _tc: ToolCache;
   private _emitter: EventEmitter = new EventEmitter();
+  private _version: string | undefined;
 
-  static create(): Installer {
-    return new Installer(os, core, tc);
+  static create(version?: string): Installer {
+    return new Installer(os, core, tc, version);
   }
 
-  static createNull(): Installer {
+  static createNull(version?: string, raiseDownloadError?: boolean): Installer {
     return new Installer(
       new StubbedOperatingSystem(),
       new StubbedOutput(),
-      new StubbedToolCache(),
+      new StubbedToolCache(raiseDownloadError),
+      version,
     );
   }
 
-  constructor(os: OperatingSystem, output: ActionOutput, toolCache: ToolCache) {
+  constructor(
+    os: OperatingSystem,
+    output: ActionOutput,
+    toolCache: ToolCache,
+    version?: string,
+  ) {
     this._os = os;
     this._output = output;
     this._tc = toolCache;
+    this._version = version;
   }
 
   trackOutput() {
@@ -56,13 +64,15 @@ export class Installer {
       return;
     }
 
-    const downloadUrl = `https://qlty-releases.s3.amazonaws.com/qlty/latest/qlty-${platformArch}.tar.xz`;
+    const versionPath = this._version ? `v${this._version}` : "latest";
+
+    const downloadUrl = `https://qlty-releases.s3.amazonaws.com/qlty/${versionPath}/qlty-${platformArch}.tar.xz`;
     const tarPath = await this._tc.downloadTool(downloadUrl);
     const extractedFolder = await this._tc.extractTar(tarPath, undefined, "x");
     const cachedPath = await this._tc.cacheDir(
       extractedFolder,
       "qlty",
-      "latest",
+      versionPath,
     );
     this._emitter.emit(DOWNLOAD_EVENT, downloadUrl);
     const binPath = `${cachedPath}/qlty-${platformArch}`;
@@ -101,10 +111,19 @@ export class StubbedOperatingSystem implements OperatingSystem {
 
 export class StubbedToolCache implements ToolCache {
   downloads: string[] = [];
+  raiseError: boolean = false;
+
+  constructor(raiseError?: boolean) {
+    this.raiseError = raiseError || false;
+  }
 
   async downloadTool(url: string): Promise<string> {
-    this.downloads.push(url);
-    return `downloaded[${url}]`;
+    if (this.raiseError) {
+      throw new Error("download error");
+    } else {
+      this.downloads.push(url);
+      return `downloaded[${url}]`;
+    }
   }
 
   async extractTar(
diff --git a/coverage/src/settings.ts b/coverage/src/settings.ts
index 7a555fe..3fd5097 100644
--- a/coverage/src/settings.ts
+++ b/coverage/src/settings.ts
@@ -24,6 +24,7 @@ interface ActionInputKeys {
   token: string;
   "coverage-token": string;
   verbose: boolean;
+  "cli-version": string;
 }
 
 const settingsParser = z.object({
@@ -42,6 +43,7 @@ const settingsParser = z.object({
   }),
   oidc: z.boolean(),
   verbose: z.boolean(),
+  cliVersion: z.string().transform((val) => (val === "" ? undefined : val)),
 });
 
 export type SettingsOutput = z.output<typeof settingsParser>;
@@ -71,6 +73,7 @@ export class Settings {
         coverageToken: input.getInput("coverage-token"),
         token: input.getInput("token"),
         verbose: input.getBooleanInput("verbose"),
+        cliVersion: input.getInput("cli-version"),
       }),
       input,
       fs,
@@ -127,6 +130,17 @@ export class Settings {
     }
   }
 
+  getVersion(): string | undefined {
+    if (!this._data.cliVersion) {
+      return undefined;
+    }
+
+    // Format version string (remove 'v' prefix if present)
+    return this._data.cliVersion.startsWith("v")
+      ? this._data.cliVersion.substring(1)
+      : this._data.cliVersion;
+  }
+
   async getFiles() {
     const patterns: string[] = this._data.files
       .split(",")
@@ -161,8 +175,8 @@ export class FileSystem {
     return new FileSystem();
   }
 
-  static createNull() {
-    return new StubbedFileSystem();
+  static createNull(results?: string[]) {
+    return new StubbedFileSystem(results);
   }
 
   async globPatterns(patterns: string): Promise<string[]> {
@@ -172,11 +186,21 @@ export class FileSystem {
 }
 
 export class StubbedFileSystem implements FileSystem {
+  private results: string[] | undefined = [];
+
+  constructor(results: string[] | undefined = undefined) {
+    this.results = results;
+  }
+
   async globPatterns(patterns: string): Promise<string[]> {
-    return patterns
-      .split("\n")
-      .map((pattern) => pattern.trim())
-      .filter(Boolean);
+    if (this.results) {
+      return this.results;
+    } else {
+      return patterns
+        .split("\n")
+        .map((pattern) => pattern.trim())
+        .filter(Boolean);
+    }
   }
 }
 
@@ -196,6 +220,7 @@ export class StubbedInputProvider implements InputProvider {
       "coverage-token": data["coverage-token"] || "",
       token: data.token || "",
       verbose: data.verbose || false,
+      "cli-version": data["cli-version"] || "",
     };
   }
 
diff --git a/coverage/tests/action.test.ts b/coverage/tests/action.test.ts
index 60bd0b2..09ed1ad 100644
--- a/coverage/tests/action.test.ts
+++ b/coverage/tests/action.test.ts
@@ -1,5 +1,6 @@
 import { CoverageAction, StubbedActionContext } from "src/action";
-import { Settings } from "src/settings";
+import { Installer } from "src/installer";
+import { Settings, StubbedFileSystem } from "src/settings";
 import { StubbedCommandExecutor } from "src/util/exec";
 import { StubbedOutput } from "src/util/output";
 
@@ -174,6 +175,43 @@ describe("CoverageAction", () => {
         "Error uploading coverage. Output from the Qlty CLI follows:",
       );
     });
+
+    test("handles installer error when skip-errors is true", async () => {
+      const { output, action } = createTrackedAction({
+        settings: Settings.createNull({
+          "coverage-token": "qltcp_1234567890",
+          files: "info.lcov",
+          "skip-errors": true,
+        }),
+        installer: Installer.createNull(undefined, true),
+      });
+
+      await action.run();
+      expect(output.warnings).toEqual([
+        "Error installing Qlty CLI: download error. Please check the action's inputs. If you are using a 'cli-version', make sure it is correct.",
+      ]);
+    });
+
+    test("handles empty files array with spaces in the files input", async () => {
+      const { output, action } = createTrackedAction({
+        settings: Settings.createNull(
+          {
+            "coverage-token": "qltcp_1234567890",
+            files: "file1.lcov file2.lcov",
+            "skip-errors": true,
+          },
+          new StubbedFileSystem([]),
+        ),
+      });
+
+      await action.run();
+      expect(output.warnings).toEqual([
+        "No code coverage data files were found. Please check the action's inputs.",
+        "NOTE: To specify multiple files, use a comma or newline separated list NOT spaces.",
+        "If you are using a pattern, make sure it is correct.",
+        "If you are using a file, make sure it exists.",
+      ]);
+    });
   });
 
   function createTrackedAction({
@@ -181,12 +219,14 @@ describe("CoverageAction", () => {
     context = new StubbedActionContext(),
     executor = new StubbedCommandExecutor(),
     output = new StubbedOutput(),
+    installer = Installer.createNull(undefined),
   } = {}) {
     const action = CoverageAction.createNull({
       output,
       settings,
       context,
       executor,
+      installer,
     });
     const commands = action.trackOutput();
     return { commands, action, output };
diff --git a/coverage/tests/installer.test.ts b/coverage/tests/installer.test.ts
index c85903c..ad9bc4f 100644
--- a/coverage/tests/installer.test.ts
+++ b/coverage/tests/installer.test.ts
@@ -46,6 +46,17 @@ describe("Installer", () => {
     ]);
   });
 
+  test("installs specific version", async () => {
+    const { downloads, installer } = createTrackedInstaller({
+      os: new StubbedOperatingSystem("linux", "x64"),
+      version: "1.2.3",
+    });
+    await installer.install();
+    expect(downloads.clear()).toEqual([
+      "https://qlty-releases.s3.amazonaws.com/qlty/v1.2.3/qlty-x86_64-unknown-linux-gnu.tar.xz",
+    ]);
+  });
+
   test("rejects unknown OS", async () => {
     const { output, installer } = createTrackedInstaller({
       os: new StubbedOperatingSystem("unknown", "unknown"),
@@ -68,8 +79,14 @@ describe("Installer", () => {
     toolCache = new StubbedToolCache(),
     os = new StubbedOperatingSystem(),
     output = new StubbedOutput(),
+    version = undefined,
+  }: {
+    toolCache?: StubbedToolCache;
+    os?: StubbedOperatingSystem;
+    output?: StubbedOutput;
+    version?: string;
   } = {}) {
-    const installer = new Installer(os, output, toolCache);
+    const installer = new Installer(os, output, toolCache, version);
     const downloads = installer.trackOutput();
     return { installer, downloads, output };
   }
diff --git a/coverage/tests/settings.test.ts b/coverage/tests/settings.test.ts
index 434f481..0fefa9e 100644
--- a/coverage/tests/settings.test.ts
+++ b/coverage/tests/settings.test.ts
@@ -13,6 +13,7 @@ describe("Settings", () => {
       "total-parts-count": "10",
       oidc: true,
       verbose: true,
+      "cli-version": "1.2.3",
     });
 
     expect(settings.input).toMatchObject({
@@ -26,6 +27,7 @@ describe("Settings", () => {
       totalPartsCount: 10,
       oidc: true,
       verbose: true,
+      cliVersion: "1.2.3",
     });
   });
 
@@ -187,4 +189,27 @@ describe("Settings", () => {
       );
     });
   });
+
+  describe("getVersion", () => {
+    test("returns undefined for empty version", () => {
+      const settings = Settings.createNull({
+        "cli-version": "",
+      });
+      expect(settings.getVersion()).toBeUndefined();
+    });
+
+    test("returns version without v prefix", () => {
+      const settings = Settings.createNull({
+        "cli-version": "1.2.3",
+      });
+      expect(settings.getVersion()).toEqual("1.2.3");
+    });
+
+    test("strips v prefix from version", () => {
+      const settings = Settings.createNull({
+        "cli-version": "v1.2.3",
+      });
+      expect(settings.getVersion()).toEqual("1.2.3");
+    });
+  });
 });


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: 

Copilot reviewed 8 out of 8 changed files in this pull request and generated no comments.




state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-18T01:58:57Z
user: login: qltysh[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-18T02:04:15Z
user: login: qltysh[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-18T02:33:55Z

Review comments:
diff_hunk: @@ -66,7 +66,15 @@ export class CoverageAction {
       return;
     }
 
-    await this._installer.install();
+    try {
+      await this._installer.install();
+    } catch (error) {
path: coverage/src/action.ts
user: login: qltysh[bot]
body: 'error' is defined but never used\. Allowed unused caught errors must match /^\_/u\. <i>[eslint:@typescript-eslint/no-unused-vars]</i><a href="#issue-df03cfc5b8b6580be1c1d0fb22042584"></a>
created_at: 2025-04-18T02:04:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -174,19 +175,58 @@ describe("CoverageAction", () => {
         "Error uploading coverage. Output from the Qlty CLI follows:",
       );
     });
+
+    test("handles installer error when skip-errors is true", async () => {
+      const { output, action } = createTrackedAction({
+        settings: Settings.createNull({
+          "coverage-token": "qltcp_1234567890",
+          files: "info.lcov",
+          "skip-errors": true,
+        }),
+        installer: Installer.createNull(undefined, true),
+      });
+
+      await action.run();
+      expect(output.warnings).toEqual([
+        "Error installing Qlty CLI: download error. Please check the action's inputs. If you are using a 'cli-version', make sure it is correct.",
+      ]);
+    });
+
+    test("handles empty files array with spaces in the files input", async () => {
+      const { output, action } = createTrackedAction({
+        settings: Settings.createNull(
+          {
+            "coverage-token": "qltcp_1234567890",
+            files: "file1.lcov file2.lcov",
+            "skip-errors": true,
+          },
+          new StubbedFileSystem([]),
+        ),
+      });
+
+      await action.run();
+      expect(output.warnings).toEqual([
+        "No code coverage data files were found. Please check the action's inputs.",
+        "NOTE: To specify multiple files, use a comma or newline separated list NOT spaces.",
path: coverage/tests/action.test.ts
user: login: qltysh[bot]
body: "NOTE: To specify multiple files, use a comma or newline separated list NOT spaces\.", <i>[ripgrep:NOTE]</i><a href="#issue-4d9f450e0822665b22225764d6c46548"></a>
created_at: 2025-04-18T02:33:55Z
author_association: CONTRIBUTOR

PR comments:
user: login: qltysh[bot]
created_at: 2025-04-18T01:53:17Z
body: <p><strong>Diff Coverage:</strong> The code coverage on the diff in this pull request is <strong>96.3%</strong>.</p>
<p><strong>Total Coverage:</strong> This PR will increase coverage by <strong>0.92%</strong>.</p>

  <details>
  <summary>File Coverage Changes</summary>

  | Path  | File Coverage Δ | Indirect |
  | ------------- | ------------- | ------------- |
 | coverage/src/action.ts | 1.8 |  |
 | coverage/src/installer.ts | 0.4 |  |
 | coverage/src/settings.ts | 0.8 |  |
</details>

<details>
  <summary>🛟 Help</summary>
  <ul>
    <li>
      <p>
        <strong>Diff Coverage:</strong> Coverage for added or modified lines of code (excludes deleted files). <a href="https://qlty.sh/d/diff-coverage">
          Learn more.
          </a>
      </p>
    </li>
    <li>
      <p>
        <strong>Total Coverage:</strong> Coverage for the whole repository, calculated as the sum of all File Coverage.
        <a href="https://qlty.sh/d/total-coverage">
           Learn more.
        </a>
      </p>
    </li>
    <li>
      <p>
        <strong>File Coverage:</strong> Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)
      </p>
      <ul>
        <li>
        <p>
          <strong>Indirect Changes:</strong> Changes to File Coverage for files that were not modified in this PR. <a href="https://qlty.sh/d/indirect-coverage-changes">
            Learn more.
            </a>
        </p>
      </li>
      </ul>
    </li>
  </ul>
</details>
  

<a href="#coverage-summary-comment"></a>
author_association: CONTRIBUTOR
user: login: qltysh[bot]
created_at: 2025-04-18T01:53:17Z
body: <p><strong>Diff Coverage:</strong> The code coverage on the diff in this pull request is <strong>90.0%</strong>.</p>
<p><strong>Total Coverage:</strong> This PR will increase coverage by <strong>0.16%</strong>.</p>

  <details>
  <summary>File Coverage Changes</summary>

  | Path  | File Coverage Δ | Indirect |
  | ------------- | ------------- | ------------- |
 | coverage/src/installer.ts | 0.2 |  |
 | coverage/src/settings.ts | 0.4 |  |
</details>

<details>
  <summary>🛟 Help</summary>
  <ul>
    <li>
      <p>
        <strong>Diff Coverage:</strong> Coverage for added or modified lines of code (excludes deleted files). <a href="https://qlty.sh/d/diff-coverage">
          Learn more.
          </a>
      </p>
    </li>
    <li>
      <p>
        <strong>Total Coverage:</strong> Coverage for the whole repository, calculated as the sum of all File Coverage.
        <a href="https://qlty.sh/d/total-coverage">
           Learn more.
        </a>
      </p>
    </li>
    <li>
      <p>
        <strong>File Coverage:</strong> Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)
      </p>
      <ul>
        <li>
        <p>
          <strong>Indirect Changes:</strong> Changes to File Coverage for files that were not modified in this PR. <a href="https://qlty.sh/d/indirect-coverage-changes">
            Learn more.
            </a>
        </p>
      </li>
      </ul>
    </li>
  </ul>
</details>
  

<a href="#coverage-summary-comment"></a>
author_association: CONTRIBUTOR
user: login: qltysh[bot]
created_at: 2025-04-18T02:04:13Z
body: All good ✅

<a href="#analysis-summary-comment"></a>
author_association: CONTRIBUTOR