fix: do not run coverage reporter if already reported
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 0ff1125..997c093 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -65,3 +65,11 @@
               run: |
                   bazel --bazelrc=$GITHUB_WORKSPACE/.github/workflows/ci.bazelrc --bazelrc=.bazelrc test --config=${{ matrix.config }}  //...
                   ls $(bazel info output_base)/external | grep -v __links | grep -vz unused
+
+            - name: bazel coverage //...
+              env:
+                  # Bazelisk will download bazel to here
+                  XDG_CACHE_HOME: ~/.cache/bazel-repo
+              working-directory: ${{ matrix.folder }}
+              run: |
+                  bazel --bazelrc=$GITHUB_WORKSPACE/.github/workflows/ci.bazelrc --bazelrc=.bazelrc coverage --config=${{ matrix.config }} --instrument_test_targets //...
diff --git a/js/private/coverage/bundle/c8.js b/js/private/coverage/bundle/c8.js
index 2a3ac65..86b70b0 100644
--- a/js/private/coverage/bundle/c8.js
+++ b/js/private/coverage/bundle/c8.js
@@ -2,6 +2,18 @@
 import fs from 'fs'
 import path from 'path'
 
+// bazel will create the COVERAGE_OUTPUT_FILE whilst setting up the sandbox.
+// therefore, should be doing a file size check rather than presence.
+try {
+    const stats = fs.statSync(process.env.COVERAGE_OUTPUT_FILE)
+    if (stats.size != 0) {
+        // early exit here does not affect the outcome of the tests.
+        // bazel will only execute _lcov_merger when tests pass.
+        process.exit(0)
+    }
+    // in case file doesn't exist or some other error is thrown, just ignore it.
+} catch {}
+
 const include = fs
     .readFileSync(process.env.COVERAGE_MANIFEST)
     .toString('utf8')
diff --git a/js/private/coverage/coverage.js b/js/private/coverage/coverage.js
index 3d606e5..82476c4 100755
--- a/js/private/coverage/coverage.js
+++ b/js/private/coverage/coverage.js
@@ -10983,6 +10983,18 @@
 
 var Report = report;
 
+// bazel will create the COVERAGE_OUTPUT_FILE whilst setting up the sandbox.
+// therefore, should be doing a file size check rather than presence.
+try {
+    const stats = require$$0__default$1["default"].statSync(process.env.COVERAGE_OUTPUT_FILE);
+    if (stats.size != 0) {
+        // early exit here does not affect the outcome of the tests.
+        // bazel will only execute _lcov_merger when tests pass.
+        process.exit(0);
+    }
+    // in case file doesn't exist or some other error is thrown, just ignore it.
+} catch {}
+
 const include = require$$0__default$1["default"]
     .readFileSync(process.env.COVERAGE_MANIFEST)
     .toString('utf8')
diff --git a/js/private/test/coverage/BUILD.bazel b/js/private/test/coverage/BUILD.bazel
new file mode 100644
index 0000000..a422fcc
--- /dev/null
+++ b/js/private/test/coverage/BUILD.bazel
@@ -0,0 +1,59 @@
+load(":test.bzl", "coverage_fail_test", "coverage_pass_test")
+load("//js/private/coverage:merger.bzl", "coverage_merger")
+
+FAIL_CMD = """\
+echo "require('fs').writeFileSync(process.env.COVERAGE_OUTPUT_FILE, '# no coverage');" >> $@
+cat $(location //js/private/coverage:coverage.js) >> $@
+echo "process.on('exit', () => require('assert').equal(require('fs').readFileSync(process.env.COVERAGE_OUTPUT_FILE).toString(), '# no coverage'))" >> $@
+"""
+
+genrule(
+    name = "fail_merger_entrypoint",
+    srcs = ["//js/private/coverage:coverage.js"],
+    outs = ["fail_merger_entrypoint.js"],
+    cmd = FAIL_CMD,
+)
+
+coverage_merger(
+    name = "fail_merger",
+    entry_point = ":fail_merger_entrypoint.js",
+    visibility = ["//visibility:public"],
+)
+
+coverage_fail_test(
+    name = "fail",
+    data = ["lib.js"],
+    enable_runfiles = select({
+        "@aspect_rules_js//js/private:enable_runfiles": True,
+        "//conditions:default": False,
+    }),
+    entry_point = "lib.js",
+)
+
+PASS_CMD = """\
+cat $(location //js/private/coverage:coverage.js) >> $@
+echo "process.on('exit', () => require('assert').equal(require('fs').readFileSync(process.env.COVERAGE_OUTPUT_FILE).toString().split('\\n')[1], 'SF:js/private/test/coverage/lib.js'))" >> $@
+"""
+
+genrule(
+    name = "pass_merger_entrypoint",
+    srcs = ["//js/private/coverage:coverage.js"],
+    outs = ["pass_merger_entrypoint.js"],
+    cmd = PASS_CMD,
+)
+
+coverage_merger(
+    name = "pass_merger",
+    entry_point = ":pass_merger_entrypoint.js",
+    visibility = ["//visibility:public"],
+)
+
+coverage_pass_test(
+    name = "pass",
+    data = ["lib.js"],
+    enable_runfiles = select({
+        "@aspect_rules_js//js/private:enable_runfiles": True,
+        "//conditions:default": False,
+    }),
+    entry_point = "lib.js",
+)
diff --git a/js/private/test/coverage/lib.js b/js/private/test/coverage/lib.js
new file mode 100644
index 0000000..a20bd7b
--- /dev/null
+++ b/js/private/test/coverage/lib.js
@@ -0,0 +1,13 @@
+if (true) {
+    itstrue()
+} else {
+    itsfalse()
+}
+
+function itstrue() {
+    console.log('its true')
+}
+
+function itsfalse() {
+    console.log('its false')
+}
diff --git a/js/private/test/coverage/test.bzl b/js/private/test/coverage/test.bzl
new file mode 100644
index 0000000..258a55b
--- /dev/null
+++ b/js/private/test/coverage/test.bzl
@@ -0,0 +1,27 @@
+load("//js/private:js_binary.bzl", "js_binary_lib")
+
+coverage_fail_test = rule(
+    implementation = js_binary_lib.implementation,
+    attrs = dict(js_binary_lib.attrs, **{
+        "_lcov_merger": attr.label(
+            executable = True,
+            default = Label("//js/private/test/coverage:fail_merger"),
+            cfg = "exec",
+        ),
+    }),
+    test = True,
+    toolchains = js_binary_lib.toolchains,
+)
+
+coverage_pass_test = rule(
+    implementation = js_binary_lib.implementation,
+    attrs = dict(js_binary_lib.attrs, **{
+        "_lcov_merger": attr.label(
+            executable = True,
+            default = Label("//js/private/test/coverage:pass_merger"),
+            cfg = "exec",
+        ),
+    }),
+    test = True,
+    toolchains = js_binary_lib.toolchains,
+)