diff --git a/BUILD b/BUILD index bb79832c2..843b7b814 100644 --- a/BUILD +++ b/BUILD @@ -17,6 +17,7 @@ package(default_visibility = ["//visibility:public"]) exports_files([ "default_conf.py.tpl", "pyproject.toml", + "bzl/run_docs_and_test.py", ]) docs( diff --git a/bzl/docs_and_test.bzl b/bzl/docs_and_test.bzl new file mode 100644 index 000000000..3a1cef3e9 --- /dev/null +++ b/bzl/docs_and_test.bzl @@ -0,0 +1,124 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Bazel macro that chains ``bazel test`` (or ``bazel coverage``) with a +docs target in one command. + +Usage in a consumer ``BUILD``:: + + load("@score_docs_as_code//:bzl/docs_and_test.bzl", "docs_and_test") + + docs_and_test( + name = "docs_full", + test_targets = ["//score/..."], + ) + +Generates two ``py_binary`` targets:: + + bazel run //:docs_full # tests/coverage, then //:docs + bazel run //:docs_full_preview # tests/coverage, then //:live_preview + +Bazel itself has no mechanism to make a build target depend on the +execution of a test, so the orchestration lives outside the dependency +graph in a small Python driver shipped with this module. +""" + +load("@rules_python//python:defs.bzl", "py_binary") + +_DEFAULT_DRIVER = Label("@score_docs_as_code//:bzl/run_docs_and_test.py") + +def _pipeline_binary(name, driver, test_targets, coverage, run_target, help_text): + py_binary( + name = name, + srcs = [driver], + main = driver, + args = [ + "--tests", + ",".join(test_targets), + "--coverage" if coverage else "--no-coverage", + "--docs", + run_target, + ], + tags = ["cli_help=%s:\nbazel run //:%s" % (help_text, name)], + ) + +def docs_and_test( + name, + test_targets, + coverage = True, + docs_target = "//:docs", + preview_target = "//:live_preview", + driver = None): + """Create ``py_binary`` targets that run tests, then a docs command. + + Two targets are generated: + + * ```` — runs tests, then ``docs_target``. + * ``_preview`` — runs tests, then ``preview_target`` + (typically ``//:live_preview``). + + Coverage is on by default. When ``coverage = True`` the pipeline uses + ``bazel coverage --combined_report=lcov`` on ``test_targets`` instead of + plain ``bazel test``; that runs the same tests with LLVM/GCC coverage + instrumentation and produces the aggregated LCOV at + ``bazel-out/_coverage/_coverage_report.dat`` in a single rebuild. + Targets without unit tests simply contribute no coverage data. Set + ``coverage = False`` to fall back to plain ``bazel test`` (faster on + first run, no LCOV). + + Extra Bazel CLI flags (e.g. ``--config=bl-x86_64-linux``) are not + hard-coded in the ``BUILD`` file. Pass them on the command line after + ``--``:: + + bazel run //:docs_full -- \\ + --test-flag=--config=bl-x86_64-linux + + ``--test-flag`` is repeatable and forwarded to whichever underlying + Bazel command runs (``test`` or ``coverage``). + + Args: + name: Base target name; invoke with ``bazel run //:`` or + ``bazel run //:_preview``. + test_targets: Bazel labels/patterns for the test/coverage step + (e.g. ``["//score/..."]``). Pass ``[]`` to skip. + coverage: If ``True`` (default), replace ``bazel test`` with + ``bazel coverage --combined_report=lcov`` so the docs build can + pick up per-source-file LCOV data. If ``False``, run plain + ``bazel test`` and produce no LCOV. + docs_target: Label of the docs binary to invoke via ``bazel run``. + Defaults to ``//:docs``. + preview_target: Label of the live-preview binary to invoke via + ``bazel run``. Defaults to ``//:live_preview``. Pass ``None`` to + skip generating the preview target. + driver: Label of the Python driver script. Defaults to the driver + shipped with ``score_docs_as_code``; only override when you want + to inject a custom driver. + """ + driver = driver or _DEFAULT_DRIVER + _pipeline_binary( + name = name, + driver = driver, + test_targets = test_targets, + coverage = coverage, + run_target = docs_target, + help_text = "Run tests, then build documentation", + ) + + if preview_target: + _pipeline_binary( + name = name + "_preview", + driver = driver, + test_targets = test_targets, + coverage = coverage, + run_target = preview_target, + help_text = "Run tests, then start the docs live preview", + ) diff --git a/bzl/run_docs_and_test.py b/bzl/run_docs_and_test.py new file mode 100644 index 000000000..ad59380cc --- /dev/null +++ b/bzl/run_docs_and_test.py @@ -0,0 +1,119 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Driver for the :bzl:`docs_and_test` macro. + +Runs either ``bazel test`` or ``bazel coverage`` on the configured targets, +then ``bazel run`` on the docs target. Aborts the pipeline on the first +non-zero exit code so a failing step does not silently ship stale docs. + +Extra Bazel CLI flags (typically ``--config=…``) are **not** baked into +the ``BUILD`` file; pass them at ``bazel run`` time after ``--``:: + + bazel run //:docs_full -- \\ + --test-flag=--config=bl-x86_64-linux + +``--test-flag`` is repeatable and forwarded to whichever underlying Bazel +command runs (``test`` or ``coverage``). + +The script must be invoked from the workspace root — ``bazel run`` sets +``BUILD_WORKSPACE_DIRECTORY`` accordingly, so we chdir there before +invoking any nested Bazel commands. +""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys + + +def _split(csv: str) -> list[str]: + return [x for x in csv.split(",") if x] + + +def _run(cmd: list[str]) -> None: + print(f">>> {' '.join(cmd)}", flush=True) + try: + result = subprocess.run(cmd, check=False) + except KeyboardInterrupt: + # Ctrl+C hits both us and the child via the process group. The child + # already exited with 130; propagate the same status without dumping + # a Python traceback so `docs_full_preview` behaves like a bare + # `bazel run //:live_preview`. + sys.exit(130) + if result.returncode != 0: + print( + f"!!! step failed with exit code {result.returncode}: {' '.join(cmd)}", + file=sys.stderr, + ) + sys.exit(result.returncode) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--tests", + default="", + help="Comma-separated Bazel labels/patterns for the test step. " + "Empty string skips the step entirely.", + ) + parser.add_argument( + "--coverage", + dest="coverage", + action=argparse.BooleanOptionalAction, + default=True, + help="Run 'bazel coverage --combined_report=lcov' instead of " + "'bazel test' on --tests. Default: True.", + ) + parser.add_argument( + "--docs", + required=True, + help="Bazel label of the docs binary to invoke via 'bazel run'.", + ) + parser.add_argument( + "--test-flag", + action="append", + default=[], + help="Extra CLI flag forwarded to the test/coverage step " + "(repeatable). Typically '--config=…'.", + ) + args = parser.parse_args() + + # `bazel run` sets BUILD_WORKSPACE_DIRECTORY to the workspace root; nested + # bazel invocations must run from there so they see MODULE.bazel etc. + workspace = os.environ.get("BUILD_WORKSPACE_DIRECTORY") + if workspace: + os.chdir(workspace) + + test_targets = _split(args.tests) + + if test_targets: + if args.coverage: + _run( + [ + "bazel", + "coverage", + "--combined_report=lcov", + *args.test_flag, + "--", + *test_targets, + ] + ) + else: + _run(["bazel", "test", *args.test_flag, "--", *test_targets]) + _run(["bazel", "run", args.docs]) + + +if __name__ == "__main__": + main()