4.2.6 Executable & Test Rules

Executable and test rules are the point where a custom target becomes something Bazel can launch, not just something Bazel can build. They extend the DefaultInfo contract from 4.2.5 DefaultInfo & Runfiles: the rule still declares files and actions during analysis, but it also names one output as the runtime entry point and supplies the runfiles and environment that entry point needs.1

Two Rule Flags, One Runtime Contract

An executable rule is declared with rule(..., executable = True), which makes its targets valid subjects of bazel run. A test rule is declared with rule(..., test = True), which makes its targets valid subjects of bazel test. Test rules are already executable, so setting both test = True and executable = True is unnecessary and discouraged. The Starlark rule symbol for a test rule must end in _test. Target names often do, but are not required to.2

The rule flag is only the first half of the contract. The implementation must also produce an executable output file and return it as DefaultInfo(executable = ...).3 Bazel uses that File as the program launched by bazel run or by the test runner. It is also added to the rule's default outputs and implicitly added to the rule's runfiles, so you do not need to duplicate it in files or runfiles just to make the launcher available.4

def _tool_impl(ctx):
    executable = ctx.actions.declare_file(ctx.label.name)

    ctx.actions.write(
        output = executable,
        content = "#!/usr/bin/env bash\nexec echo hello-from-$0\n",
        is_executable = True,
    )

    return [DefaultInfo(executable = executable)]

hello_tool = rule(
    implementation = _tool_impl,
    executable = True,
)

ctx.actions.write() needs is_executable = True because the output file must actually be executable when Bazel launches it. If the executable is produced by ctx.actions.run() or ctx.actions.run_shell(), the tool invoked by that action is responsible for setting the executable bit on the output.5 A realistic language binary rule follows the same shape: declare a binary output, link it, then return DefaultInfo(files = depset([executable]), executable = executable) so bazel build and bazel run agree on the public binary.6

extra

The Deprecated Shortcut

Executable rules still have a legacy ctx.outputs.executable predeclared output. Bazel can use it as the default executable if DefaultInfo(executable = ...) is omitted, but the official rules guide marks this mechanism as deprecated because it prevents the rule implementation from choosing the executable file name at analysis time.7

Test Rules Are Executables Under A Test Runner

A test rule's primary output is still a program. The difference is who launches it and what contract surrounds it. The Test Encyclopedia says test rules are analogous to binary rules: each must yield an executable program, plus runfiles and metadata needed by the test runner.8 From the runner's point of view, the test is a standalone process. Exit code 0 means pass, and any other normal result is a failure. Strings such as PASS or FAIL printed to stdout have no special meaning by themselves.9

def _smoke_test_impl(ctx):
    test = ctx.actions.declare_file(ctx.label.name)

    ctx.actions.write(
        output = test,
        content = """#!/usr/bin/env bash
set -euo pipefail
echo "smoke test for %s"
exit 0
""" % ctx.label,
        is_executable = True,
    )

    return [DefaultInfo(executable = test)]

smoke_test = rule(
    implementation = _smoke_test_impl,
    test = True,
)

This skeleton shows the shape, not a production-ready test runner. A real rule would pass the runfile path deliberately, handle platform differences, and keep the test executable's own command line stable. The important rule-authoring point is that test = True changes how Bazel treats the target at the CLI and test-runner boundary. It does not remove the need to produce a real executable and return it through DefaultInfo.10

extra

Portable Generated Launchers

The generated Bash scripts above are enough to demonstrate the executable-rule contract. A ruleset that must expose the same kind of wrapper on Linux, macOS, and Windows may instead finalize a small native launcher template.

hermetic-launcher is one implementation of that pattern: the rule supplies an entrypoint and runfiles-aware arguments, and the finalizer patches them into a platform template instead of generating separate Bash and Batch wrappers.11 This is an implementation option, not part of Bazel's executable-rule API. Prefer a normal language binary when the application already owns one. Use a launcher helper when the rule specifically needs to manufacture a portable entry point.

Runtime Inputs Are A Launch Boundary

4.2.5 DefaultInfo & Runfiles covers the general runfiles mechanics. For executable and test rules, keep one extra launch-specific consequence in view: the executable itself is added to its runfiles automatically, but data files, plugins, templates, certificates, or helper binaries opened after startup still need to be part of DefaultInfo(runfiles = ...).12

That means an action input is not enough. A file can be visible while building the launcher and still be missing when the launcher starts under bazel run or bazel test. Runtime files must be collected with ctx.runfiles(...) and propagated through DefaultInfo.13 If the rule aggregates runfiles from many dependencies, keep the graph-shaped data shallow with the merge_all() pattern rather than repeated pairwise merging, just as in 4.1.5 depset vs list.14

Runtime lookup remains the program's responsibility. On Linux and macOS, Bazel commonly materializes runfiles as a symlink tree beside the binary. On Windows it commonly uses a manifest file instead. Bzlmod repository mappings add another reason not to hard-code physical paths. The durable advice is to use a language runfiles library and pass its env_vars() result to subprocesses that also need runfiles.15

Environment Is Part Of The Rule's Promise

If the launched program needs environment variables derived by the rule, return RunEnvironmentInfo. Its environment field sets explicit key-value pairs for bazel run and bazel test, while inherited_environment asks Bazel to pass selected variables from the caller's shell.16 The latter is especially sensitive for tests: the provider docs warn that inherited variables make an otherwise hermetic test depend on outside state and can accidentally expose sensitive information.17 The deeper environment design tradeoffs continue in 4.4.2 RunEnvironmentInfo.

return [
    DefaultInfo(executable = executable, runfiles = runfiles),
    RunEnvironmentInfo(
        environment = {
            "MY_RULE_MODE": ctx.attr.mode,
        },
    ),
]

Keep a separate mental slot for executable dependencies. attr.label(executable = True) means a dependency target can be used as a tool through ctx.executable.<name>, and Bazel requires a cfg value on that attribute so the rule author decides whether the tool is built for the execution or target platform.18 That is not the same as declaring the current rule executable with rule(executable = True). The first makes a dependency runnable by an action. The second makes this rule's target runnable by the user.

For a complete paired implementation, inspect the mini-ruleset's glyph_binary and glyph_test. The binary returns an executable and runfiles. The test creates its runner, merges the binary's runfiles, and supplies one explicit launch variable.

key takeaway

An executable or test rule has three runtime obligations: declare the rule kind with executable = True or test = True, return exactly the launched file through DefaultInfo(executable = ...), and put every runtime file or environment variable in the providers Bazel uses when launching the target.

Do not confuse "the action had the file as an input" with "the launched program can find the file." Build inputs belong to actions. Runtime inputs belong to runfiles.

Check your understanding · 4 questions

1.What is the modern preferred contract for making a custom rule target runnable with bazel run?

Select one answer

2.Which statements about custom test rules are correct?

Select all that apply

3.When should a rule author use RunEnvironmentInfo(inherited_environment = [...]) for a test rule?

Select one answer

4.True or false: runtime contracts for executable and test rules.

Choose True or False for each sentence

The file returned through DefaultInfo(executable = ...) is also added to the target's default outputs and runfiles.
DefaultInfo(runfiles = ...) is where a rule exposes runtime files to bazel run or bazel test.
RunEnvironmentInfo(environment = ...) declares rule-owned environment variables for launched targets.
attr.label(executable = True) declares that the current rule target can be run by bazel run.
ctx.actions.write(..., is_executable = True) is needed when that action writes the launcher script directly.
ctx.outputs.executable is the preferred modern way to choose an executable file name at analysis time.
0 of 4 answered

Footnotes

  1. Rules — executable and test rules are launched by bazel run / bazel test and return an executable through DefaultInfo.

  2. .bzl filesrule() parameters test and executable, test-rule naming restriction, and test rules being executable.

  3. Rules — executable and test rules must produce an executable output file.

  4. DefaultInfoexecutable, runfiles, and files_to_run fields.

  5. actionswrite(..., is_executable = True) and action output responsibilities.

  6. Writing Bazel rules: simple binary rulego_binary rule declaration and DefaultInfo(executable = executable) implementation.

  7. Rules — deprecated ctx.outputs.executable behavior.

  8. Test encyclopedia — test rules are analogous to binary rules and must yield an executable plus runtime metadata.

  9. Test encyclopedia — test runner pass/fail semantics based on process exit code.

  10. Rulestest = True creates a test rule whose target can be invoked by bazel test.

  11. hermetic-launcher repository map and its low-level rule API — deterministic native templates, runfiles-aware arguments, and the public rule-author builder contract.

  12. DefaultInforunfiles describes files needed when the target is run.

  13. Writing Bazel rules: data and runfiles — collecting direct and transitive runfiles and returning them in DefaultInfo.

  14. runfilesmerge_all() guidance for combining many runfiles objects.

  15. Runfiles and where to find them — symlink tree vs manifest, Bzlmod repository mappings, and subprocess env_vars() propagation.

  16. RunEnvironmentInfo — explicit and inherited runtime environment variables.

  17. RunEnvironmentInfo — hermeticity and sensitive-information caution for inherited environment.

  18. attrattr.label(executable = True) access through ctx.executable and required cfg.