diff --git a/diff/tests/BUILD.bazel b/diff/tests/BUILD.bazel
index 7521b6d..e47f9ac 100644
--- a/diff/tests/BUILD.bazel
+++ b/diff/tests/BUILD.bazel
@@ -1,6 +1,6 @@
load("@bazel_lib//lib:copy_to_directory.bzl", "copy_to_directory")
load("@bazel_skylib//lib:partial.bzl", "partial")
-load("@bazel_skylib//rules:build_test.bzl", "build_test")
+load("//tools/test:build_test.bzl", "build_test")
load("@bazel_skylib//rules:write_file.bzl", "write_file")
load("//diff:defs.bzl", "cmp", "diff", "diff3", "sdiff")
diff --git a/e2e/diffutils_from_source/BUILD.bazel b/e2e/diffutils_from_source/BUILD.bazel
index 8411c42..f3eff47 100644
--- a/e2e/diffutils_from_source/BUILD.bazel
+++ b/e2e/diffutils_from_source/BUILD.bazel
@@ -3,7 +3,7 @@ Add a basic smoke-test target below.
"""
load("@bazel_skylib//lib:partial.bzl", "partial")
-load("@bazel_skylib//rules:build_test.bzl", "build_test")
+load("@diff.bzl//tools/test:build_test.bzl", "build_test")
load("@bazel_skylib//rules:write_file.bzl", "write_file")
load("@diff.bzl", "diff")
diff --git a/e2e/smoke/BUILD.bazel b/e2e/smoke/BUILD.bazel
index 12438f3..fe84ca4 100644
--- a/e2e/smoke/BUILD.bazel
+++ b/e2e/smoke/BUILD.bazel
@@ -3,7 +3,7 @@ Add a basic smoke-test target below.
"""
load("@bazel_skylib//lib:partial.bzl", "partial")
-load("@bazel_skylib//rules:build_test.bzl", "build_test")
+load("@diff.bzl//tools/test:build_test.bzl", "build_test")
load("@bazel_skylib//rules:write_file.bzl", "write_file")
load("@diff.bzl", "diff")
diff --git a/e2e/validate_override/BUILD.bazel b/e2e/validate_override/BUILD.bazel
index b8f7059..0312397 100644
--- a/e2e/validate_override/BUILD.bazel
+++ b/e2e/validate_override/BUILD.bazel
@@ -1,5 +1,5 @@
load("@bazel_skylib//lib:partial.bzl", "partial")
-load("@bazel_skylib//rules:build_test.bzl", "build_test")
+load("@diff.bzl//tools/test:build_test.bzl", "build_test")
load("@bazel_skylib//rules:write_file.bzl", "write_file")
load("@diff.bzl", "cmp", "diff")
diff --git a/tools/test/BUILD.bazel b/tools/test/BUILD.bazel
new file mode 100644
index 0000000..b57ec53
--- /dev/null
+++ b/tools/test/BUILD.bazel
@@ -0,0 +1,8 @@
+package(default_visibility = ["//visibility:public"])
+
+exports_files([
+ "build_test.bzl",
+ "build_test.sh",
+ "emit-test-xml.sh",
+ "run_with_xml.sh",
+])
diff --git a/tools/test/build_test.bzl b/tools/test/build_test.bzl
new file mode 100644
index 0000000..4985630
--- /dev/null
+++ b/tools/test/build_test.bzl
@@ -0,0 +1,145 @@
+"""A test verifying other targets build as part of a `bazel test`.
+
+Like @bazel_skylib//rules:build_test, but the test runner writes JUnit XML to
+$XML_OUTPUT_FILE so tests work under Bazel 9 (bazelbuild/bazel#28111).
+"""
+
+load("@bazel_skylib//lib:new_sets.bzl", "sets")
+
+def _build_test_impl(ctx):
+ is_windows = ctx.target_platform_has_constraint(ctx.attr._windows_constraint[platform_common.ConstraintValueInfo])
+ if is_windows:
+ fail("build_test with XML output is not supported on Windows")
+
+ emit_xml_link = ctx.actions.declare_file(ctx.label.name + ".emit-test-xml.sh")
+ ctx.actions.symlink(
+ output = emit_xml_link,
+ target_file = ctx.file._emit_test_xml,
+ is_executable = True,
+ )
+
+ executable = ctx.actions.declare_file(ctx.label.name + ".sh")
+ ctx.actions.write(
+ output = executable,
+ is_executable = True,
+ content = """#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)"
+LOG="${{TEST_TMPDIR:-${{TMPDIR:-/tmp}}}}/test.log"
+touch "$LOG"
+
+START=$SECONDS
+EXIT_CODE=0
+DURATION=$((SECONDS - START))
+
+if [[ -n "${{XML_OUTPUT_FILE:-}}" ]]; then
+ "${{SCRIPT_DIR}}/{emit_xml_name}" "$LOG" "$XML_OUTPUT_FILE" "$DURATION" "$EXIT_CODE"
+fi
+
+exit "$EXIT_CODE"
+""".format(emit_xml_name = emit_xml_link.basename),
+ )
+
+ return [DefaultInfo(
+ executable = executable,
+ runfiles = ctx.runfiles(
+ files = ctx.files.data + [emit_xml_link],
+ ),
+ )]
+
+_build_test = rule(
+ implementation = _build_test_impl,
+ attrs = {
+ "data": attr.label_list(allow_files = True),
+ "_emit_test_xml": attr.label(
+ default = "//tools/test:emit-test-xml.sh",
+ allow_single_file = True,
+ ),
+ "_windows_constraint": attr.label(default = "@platforms//os:windows"),
+ },
+ test = True,
+)
+
+_GENRULE_ATTRS = [
+ "compatible_with",
+ "exec_compatible_with",
+ "restricted_to",
+ "tags",
+ "target_compatible_with",
+]
+
+def build_test(name, targets, **kwargs):
+ """Test rule checking that other targets build.
+
+ This works not by an instance of this test failing, but instead by
+ the targets it depends on failing to build, and hence failing
+ the attempt to run this test.
+
+ Typical usage:
+
+ ```
+ load("@diff.bzl//tools/test:build_test.bzl", "build_test")
+ build_test(
+ name = "my_build_test",
+ targets = [
+ "//some/package:rule",
+ ],
+ )
+ ```
+
+ Args:
+ name: The name of the test rule.
+ targets: A list of targets to ensure build.
+ **kwargs: The [common attributes for tests](https://bazel.build/reference/be/common-definitions#common-attributes-tests).
+ """
+ if len(targets) == 0:
+ fail("targets must be non-empty", "targets")
+ if kwargs.get("data", None):
+ fail("data is not supported on a build_test()", "data")
+
+ # Remove any duplicate test targets.
+ targets = sets.to_list(sets.make(targets))
+
+ # Use a genrule to ensure the targets are built (works because it forces
+ # the outputs of the other rules on as data for the genrule)
+
+ # Split into batches to hopefully avoid things becoming so large they are
+ # too much for a remote execution set up.
+ batch_size = max(1, len(targets) // 100)
+
+ # Pull a few args over from the test to the genrule.
+ genrule_args = {k: kwargs.get(k) for k in _GENRULE_ATTRS if k in kwargs}
+
+ # Only the test target should be used to determine whether or not the deps
+ # are built. Tagging the genrule targets as manual accomplishes this by
+ # preventing them from being picked up by recursive build patterns (`//...`).
+ genrule_tags = genrule_args.pop("tags", [])
+ if "manual" not in genrule_tags:
+ genrule_tags = genrule_tags + ["manual"]
+
+ # Pass an output from the genrules as data to a shell test to bundle
+ # it all up in a test.
+ test_data = []
+
+ for idx, batch in enumerate([targets[i:i + batch_size] for i in range(0, len(targets), batch_size)]):
+ full_name = "{name}_{idx}__deps".format(name = name, idx = idx)
+ test_data.append(full_name)
+ native.genrule(
+ name = full_name,
+ srcs = batch,
+ outs = [full_name + ".out"],
+ testonly = 1,
+ visibility = ["//visibility:private"],
+ cmd = "touch $@",
+ cmd_bat = "type nul > $@",
+ tags = genrule_tags,
+ **genrule_args
+ )
+
+ _build_test(
+ name = name,
+ data = test_data,
+ size = kwargs.pop("size", "small"), # Default to small for test size
+ **kwargs
+ )
diff --git a/tools/test/build_test.sh b/tools/test/build_test.sh
new file mode 100755
index 0000000..016848a
--- /dev/null
+++ b/tools/test/build_test.sh
@@ -0,0 +1,20 @@
+#!/usr/bin/env bash
+
+# Test entrypoint for build_test. Bazel builds data dependencies before this
+# script runs; on success we emit JUnit XML to $XML_OUTPUT_FILE.
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+LOG="${TEST_TMPDIR:-${TMPDIR:-/tmp}}/test.log"
+touch "$LOG"
+
+START=$SECONDS
+EXIT_CODE=0
+DURATION=$((SECONDS - START))
+
+if [[ -n "${XML_OUTPUT_FILE:-}" ]]; then
+ "${SCRIPT_DIR}/emit-test-xml.sh" "$LOG" "$XML_OUTPUT_FILE" "$DURATION" "$EXIT_CODE"
+fi
+
+exit "$EXIT_CODE"
diff --git a/tools/test/emit-test-xml.sh b/tools/test/emit-test-xml.sh
new file mode 100755
index 0000000..9b73b01
--- /dev/null
+++ b/tools/test/emit-test-xml.sh
@@ -0,0 +1,132 @@
+#!/usr/bin/env bash
+
+# Copyright 2018 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# Adapted from tools/test/generate-xml.sh in github.com/bazelbuild/bazel.
+
+set -euo pipefail
+
+TEST_LOG="$1"
+XML_OUTPUT_FILE="$2"
+DURATION_IN_SECONDS="$3"
+EXIT_CODE="$4"
+
+function encode_stream {
+ # Replace invalid XML characters and invalid sequence in CDATA
+ # We do this in four steps:
+ #
+ # 1. Add a single whitespace character to the end of every line
+ #
+ # 2. Replace every sequence of legal characters followed by an illegal
+ # character *or* followed by a legal character at the end of the line with
+ # the same sequence of legal characters followed by a question mark
+ # character (replacing the illegal or last character). Since this will
+ # always replace the last character in a line with a question mark, we
+ # make sure to append a whitespace in step #1.
+ #
+ # A character is legal if it is a valid UTF-8 character that is allowed in
+ # an XML file (this excludes a few control codes, but otherwise allows
+ # most UTF-8 characters).
+ #
+ # We can't use sed in UTF-8 mode, because it would fail on the first
+ # illegal character. Instead, we have to match legal characters by their
+ # 8-bit binary sequences, and also switch sed to an 8-bit mode.
+ #
+ # The legal UTF codepoint ranges are 9,a,d,20-d7ff,e000-fffd,10000-10ffff,
+ # which results in the following 8-bit binary UTF-8 matchers:
+ # [\x9\xa\xd\x20-\x7f] <--- (9,A,D,20-7F)
+ # [\xc0-\xdf][\x80-\xbf] <--- (0080-07FF)
+ # [\xe0-\xec][\x80-\xbf][\x80-\xbf] <--- (0800-CFFF)
+ # [\xed][\x80-\x9f][\x80-\xbf] <--- (D000-D7FF)
+ # [\xee][\x80-\xbf][\x80-\xbf] <--- (E000-EFFF)
+ # [\xef][\x80-\xbe][\x80-\xbf] <--- (F000-FFEF)
+ # [\xef][\xbf][\x80-\xbd] <--- (FFF0-FFFD)
+ # [\xf0-\xf7][\x80-\xbf][\x80-\xbf][\x80-\xbf] <--- (010000-10FFFF)
+ #
+ # We omit \xa and \xd below since sed already splits the input into lines.
+ #
+ # 3. Remove the last character in the line, which we expect to be a
+ # question mark (that was originally added as a whitespace in step #1).
+ #
+ # 4. Replace the string ']]>' with ']]>]]' to prevent escaping the
+ # surrounding CDATA block.
+ #
+ # Sed supports the necessary operations as of version 4.4, but not in all
+ # earlier versions. Specifically, we have found that sed 4.1.5 is not 8-bit
+ # safe even when set to an 8-bit locale.
+ #
+ # OSX sed does not support escape sequences (\xhh), use echo as workaround.
+ #
+ # Alternatives considered:
+ # Perl - We originally used Perl, but wanted to avoid the dependency.
+ # Recent versions of Perl now error on invalid utf-8 characters.
+ # tr - tr only replaces single-byte sequences, so cannot handle utf-8.
+ LC_ALL=C sed -E \
+ -e 's/.*/& /g' \
+ -e 's/(('\
+"$(echo -e '[\x9\x20-\x7f]')|"\
+"$(echo -e '[\xc0-\xdf][\x80-\xbf]')|"\
+"$(echo -e '[\xe0-\xec][\x80-\xbf][\x80-\xbf]')|"\
+"$(echo -e '[\xed][\x80-\x9f][\x80-\xbf]')|"\
+"$(echo -e '[\xee-\xef][\x80-\xbf][\x80-\xbf]')|"\
+"$(echo -e '[\xf0][\x80-\x8f][\x80-\xbf][\x80-\xbf]')"\
+')*)./\1?/g' \
+ -e 's/(.*)\?/\1/g' \
+ -e 's|]]>|]]>]]|g'
+}
+
+function encode_as_xml {
+ if [ -f "$1" ]; then
+ cat "$1" | encode_stream
+ fi
+}
+
+# For testing, we allow calling this script with "-", in which case we only
+# perform the encoding step. We intentionally ignore the rest of the parameters.
+if [ "$TEST_LOG" == "-" ]; then
+ encode_stream
+ exit 0
+fi
+
+test_name="${TEST_BINARY#./}"
+test_name="${test_name#../}"
+errors=0
+error_msg=""
+if (( $EXIT_CODE != 0 )); then
+ errors=1
+ error_msg=""
+fi
+
+# Ensure that test shards have unique names in the xml output.
+if [[ -n "${TEST_TOTAL_SHARDS+x}" ]] && ((TEST_TOTAL_SHARDS != 0)); then
+ ((shard_num=TEST_SHARD_INDEX+1))
+ test_name="${test_name}"_shard_"${shard_num}"/"${TEST_TOTAL_SHARDS}"
+fi
+
+FAILED=0
+ENCODED_LOG="$(encode_as_xml "${TEST_LOG}")" || FAILED=$?
+cat >"${XML_OUTPUT_FILE}" <
+
+
+ ${error_msg}
+
+Generated test.log (if the file is not UTF-8, then this may be unreadable):
+
+
+
+
+EOF
+exit "$FAILED"
diff --git a/tools/test/run_with_xml.sh b/tools/test/run_with_xml.sh
new file mode 100755
index 0000000..8de4e60
--- /dev/null
+++ b/tools/test/run_with_xml.sh
@@ -0,0 +1,22 @@
+#!/usr/bin/env bash
+
+# Runs a command, captures stdout/stderr to a log file, and emits JUnit XML to
+# $XML_OUTPUT_FILE before exiting with the command's exit code.
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+LOG="${TEST_TMPDIR:-${TMPDIR:-/tmp}}/test.log"
+
+START=$SECONDS
+set +e
+"$@" >"$LOG" 2>&1
+EXIT_CODE=$?
+set -e
+DURATION=$((SECONDS - START))
+
+if [[ -n "${XML_OUTPUT_FILE:-}" ]]; then
+ "${SCRIPT_DIR}/emit-test-xml.sh" "$LOG" "$XML_OUTPUT_FILE" "$DURATION" "$EXIT_CODE"
+fi
+
+exit "$EXIT_CODE"