4.4.3 Action Execution Contract

recommended

A production action is a contract between a rule, Bazel's scheduler, and the machine that will eventually run the tool. The ordinary action shape from 4.2.2 Actions still applies: declared inputs, command, declared outputs. At this level, the question becomes sharper: which files are ordinary inputs, which files are executable tools, what environment is allowed, how will users recognize the action in logs, and what execution requirements must Bazel respect?

Treat Tools as Inputs

From Bazel's point of view, a compiler, formatter, linker, code generator, or helper script is not background state. It is part of the action's input set. The rule-writing docs call this out explicitly: rule authors must consider not only user-provided inputs, but also the tools and libraries required to execute the action.1

That is why production rules usually separate user data from implementation tools:

my_rule = rule(
    implementation = _my_rule_impl,
    attrs = {
        "srcs": attr.label_list(allow_files = [".schema"]),
        "_compiler": attr.label(
            default = "//tools:schema_compiler",
            executable = True,
            cfg = "exec",
        ),
    },
)

The public srcs attribute is part of the rule's API. The private _compiler attribute is an implementation detail, but it is still a dependency in the configured graph. executable = True makes it available through ctx.executable._compiler, and cfg = "exec" builds the tool for the execution configuration rather than the target configuration.2 When the action calls that tool, pass it as the action executable or in tools, not as an undeclared path found through PATH. ctx.actions.run() treats tools as executable inputs and can make their runfiles available to the action.3

Toolchains are the scalable version of the same idea. A private executable attr is often enough for one repository-local helper. A language ruleset that needs platform-aware compiler selection should move that decision into toolchain resolution in 4.6.1 Platform Model for Rule Authors.

Keep the Command Line Structured

The command line is also part of the action contract. If it grows with transitive dependencies, build it with ctx.actions.args() instead of flattening depsets into lists during analysis. Args objects keep depsets lazy until the execution phase, can transform values into strings, and can spill long command lines into param files when needed.4 That is the production version of 4.2.3 Args & Command Lines: the rule author chooses a stable spelling that the tool accepts, while Bazel keeps enough structure to avoid unnecessary analysis memory.

def _schema_path(file):
    return file.short_path

def _my_rule_impl(ctx):
    out = ctx.actions.declare_file(ctx.label.name + ".json")

    args = ctx.actions.args()
    args.add("--out", out)
    args.add_all(ctx.files.srcs, before_each = "--src", map_each = _schema_path)
    args.use_param_file("@%s")

    ctx.actions.run(
        executable = ctx.executable._compiler,
        inputs = ctx.files.srcs,
        outputs = [out],
        arguments = [args],
        mnemonic = "SchemaCompile",
        progress_message = "Compiling schema %{label}",
    )

Prefer ctx.actions.run() for ordinary tool execution. Use ctx.actions.run_shell() only when the shell itself is part of what the rule promises. The shell form is a real API, but it makes quoting, platform differences, shell argument indexing, and environment inheritance part of your design surface.5

Make Environment a Deliberate Choice

use_default_shell_env defaults to False for run() and run_shell() actions.6 That default is valuable. If an action silently inherits host variables such as PATH, HOME, or language-specific caches, those values become hidden inputs. Several mechanisms let host environment leak into action execution — use_default_shell_env = True, --action_env, and non-strict action environments — and inherited environment is what makes shared cache behavior unreliable across developer machines.7 This is the rule-author side of 2.3 Hermeticity & Sandboxing.

Use env when the tool needs explicit variables:

ctx.actions.run(
    executable = ctx.executable._compiler,
    inputs = ctx.files.srcs,
    outputs = [out],
    arguments = [args],
    env = {
        "LC_ALL": "C",
        "SCHEMA_CACHE": "unused",
    },
    mnemonic = "SchemaCompile",
)

When use_default_shell_env = False, env is just the explicit environment the rule adds to the action. If you deliberately set use_default_shell_env = True and also pass env, matching keys in env override values from the default shell environment.8 That is a useful escape hatch, not a reason to inherit by default. A good production rule documents every environment variable it requires and keeps host pass-through out of the normal path.

Give the Action a Stable Identity

The mnemonic is the one-word action kind: Javac, CppCompile, GoLink, SchemaCompile.9 It is not just decoration. Users see it in progress output, profiles, execution logs, strategy flags, and bazel aquery. Pick a name that describes the tool-level work and keep it stable across releases.

progress_message is the per-action sentence users see while a build runs. The action API supports %{label}, %{input}, and %{output} substitutions and recommends those patterns over fully static strings because they are more efficient.10 A useful progress message tells the user what target or output is being worked on without dumping the whole command line.

Those choices pay off when someone debugs the action graph in 5.2.3 bazel aquery — Action Graph. If twenty unrelated actions all use RunTool, users cannot route strategies cleanly or tell which rule produced a slow action. If the mnemonic and progress message are stable, performance profiles and failure logs become part of the rule's usable interface.

Use Execution Requirements Sparingly

execution_requirements is the per-action dictionary for special scheduling information.11 It is where a rule says "this action needs a non-default execution behavior." Some requirements affect sandboxing, some caching, some remote execution, and some worker support. The action API points rule authors to Bazel's common tags for useful keys, while worker docs use the same field for supports-workers and related worker protocol declarations.12

ExecutionInfo is the provider-level counterpart used especially around test execution. Its constructor takes a requirements dict and an exec_group name, so a rule can expose special execution requirements through a provider boundary rather than only on one action declaration.13 Keep the two ideas separate: action parameters describe an individual spawned action. Providers describe information the target returns to Bazel or downstream consumers.

Tests and genrule targets can also use documented requirement keywords in their tags. For other rules, do not assume a target tag is an action contract: the current --incompatible_allow_tags_propagation default copies target tags into action execution info, while disabling that flag leaves only requirements the rule or provider declares explicitly.14 Arbitrary propagated strings still have no built-in meaning unless an executor or strategy recognizes them.

Common built-in keys relevant to action portability include the following. This is not an exhaustive registry, and arbitrary labels still have no meaning unless an executor or strategy recognizes them:

ConcernKeysContract
local sandboxno-sandboxDo not sandbox local execution. Caching and remote execution remain separate decisions.
cachingno-cache, no-remote-cache, no-remote-cache-uploadDisable all action caching, only remote-cache use, or only remote-cache uploads.
placementno-remote-exec, no-remote, no-localDisable remote execution, disable both remote execution and remote caching, or disable local execution.
network/rootrequires-network, block-network, requires-fakerootAsk a supporting sandbox to allow or block networking, or run as uid 0 on Linux.
worker capabilitiessupports-workers, supports-multiplex-workers, supports-worker-cancellation, supports-multiplex-sandboxingValue "1" advertises a supported worker mode, but strategy selection still decides whether to use it.
worker protocolrequires-worker-protocolSelects the required "json" or "proto" worker protocol.
worker-pool identityworker-key-mnemonicSupplies a mnemonic override so compatible actions can reuse the same worker process.
path normalizationsupports-path-mappingAdvertise that paths can be mapped when Bazel enables that optimization.

The source-level constants make the independent axes explicit: no-remote-cache does not disable remote execution, no-remote-exec does not disable remote caching, and no-remote combines the two.15 The legacy local key means local execution without sandboxing and is deprecated. Replace it with the narrow key or combination that states the actual requirement.15 Network and fakeroot keys are conditional on a sandbox and host that can enforce them, so they are not portable capability guarantees.15

Override by mnemonic without changing the rule

Operators can add or remove execution-info keys with --modify_execution_info=<mnemonic-regex>=[+-]<key>. Multiple clauses and repeated flags accumulate in order, so a later matching clause can undo an earlier one.16

bazel build //... \
  --modify_execution_info='SchemaCompile=+no-remote-exec'

This applies only to actions that support execution info. It is useful for diagnosing an executor problem, enforcing a temporary repository policy, or testing whether a ruleset's requirement is wrong. It is not a substitute for fixing a rule whose action inherently needs the key. Inspect the action's stable mnemonic with aquery, keep broad regular expressions out of shared defaults, and remember that changing execution info can affect output reuse.16

That power cuts both ways. A rule that sets "no-sandbox" because a tool reads undeclared host files has encoded a hermeticity bug as policy. A rule that disables remote caching for every action makes remote cache adoption harder for every downstream user. Remote caching docs show no-remote-cache as the target-level way to exclude a target from using the remote cache. Action-level requirements should be just as intentional.17

Use execution requirements when the action's nature really demands them:

Requirement kindGood reasonBad reason
sandbox escapeThe tool must use a host service that cannot be modeled yetThe rule forgot to declare an input
cache escapeThe output is intentionally non-reusableThe tool is nondeterministic and nobody investigated why
remote escapeThe action needs a local hardware device or license serverThe rule assumes local paths instead of declaring tools
worker supportThe tool implements Bazel's worker protocolThe tool is merely slow to start

Do not confuse a hermetic tool binary with a hermetic action. The private claude.bzl in rules_claude declares prompt inputs and outputs but also inherits the default shell environment, calls a networked model, and switches local-auth actions to local execution.18 That is this ruleset's explicit contract, not a Bazel default. The general review move is to inspect every boundary together: pinning the CLI does not make service responses deterministic, turn credentials into declared inputs, or make an interactive host-auth workflow safe for remote caching.

Persistent-worker requirements belong in 4.4.4 Persistent Workers for Rule Authors. This article's narrower rule is: do not set scheduling tags as folklore. Treat them as part of the action's public behavior, because users will build .bazelrc strategies, remote execution policy, and debugging habits around them.

Check the Contract Before Shipping

For a production rule, review each action with a short checklist:

  • Are all files the tool may read declared through inputs, tools, or toolchain-provided files?
  • Is the executable built for the execution side, not accidentally for the target side?
  • Is the command line structured with Args when it can grow?
  • Is the environment explicit and small?
  • Does the mnemonic help users route strategies and read profiles?
  • Are execution requirements justified by the action's real behavior?

Rule tests can inspect actions when a rule opts into Starlark testability. The Action API exposes fields such as args, argv, env, inputs, mnemonic, and outputs for assertions.19 Full rule testing is covered in 4.5 Rule Testing & Documentation, and sandbox internals come later in 5.8 Sandboxing, but the habit starts here: every action your rule registers should be explainable as a complete execution contract.

Apply the checklist to the mini-ruleset's GlyphCompile action: the tool comes from a toolchain, inputs and outputs are explicit, args remain structured, the mnemonic is stable, and worker requirements name a capability the adapter actually implements.

For a production regression corpus, the rules_ts e2e/test/ directory isolates action-contract failures by symptom: sandbox access, invalidation, execution strategy, third-party type inputs, and emitted outputs. Use its sandbox case or invalidation case as ruleset-specific executable evidence, not as a new Bazel API.20

key takeaway

A production action contract is the whole envelope around a command: declared inputs, declared tools, structured arguments, explicit environment, stable identity, and justified scheduling requirements.

If changing strategy, sandboxing, or remote execution changes the action's meaning, the rule probably hid part of the contract from Bazel.

Check your understanding · 4 questions

1.Which environment choice best preserves a production action contract?

Select one answer

2.Which statement best distinguishes execution_requirements from ExecutionInfo?

Select one answer

3.Which parts belong in a production action contract?

Select all that apply

4.True or false: action environment and scheduling choices.

Choose True or False for each sentence

Keeping use_default_shell_env false helps avoid hidden host-environment inputs.
execution_requirements should be used as a routine workaround for missing declared inputs.
A stable mnemonic helps users read profiles, aquery output, and strategy routing.
ctx.actions.args() is useful only for tiny fixed command lines.
ExecutionInfo lets a rule expose execution requirements at provider scope, not only on one action declaration.
Production rules should discover build tools through the host PATH instead of declaring executable attrs or toolchains.
no-remote-cache disables remote execution as well as remote-cache access.
no-remote combines the effects of no-remote-cache and no-remote-exec.
--modify_execution_info can add or remove requirement keys from matching action mnemonics without editing the rule.
A custom rule's target tags are guaranteed to reach its actions even when --incompatible_allow_tags_propagation is disabled.
0 of 4 answered

Footnotes

  1. Rules — rule authors must account for tools and libraries required to execute actions, not just user-provided inputs. ↩

  2. Rules — private executable attributes with cfg = "exec" model implicit build-time tools. ↩

  3. actions — run() accepts executable, tools, inputs, and outputs. Tools are executable inputs with runfiles support. ↩

  4. Args — memory-efficient command-line construction, lazy depset processing, and param-file support. ↩

  5. actions — run_shell() command and argument semantics, including shell variable access and Args flattening caveats. ↩

  6. actions — use_default_shell_env defaults to False for run() and run_shell(). ↩

  7. How to keep a Bazel project hermetic? — environment inheritance through use_default_shell_env, --action_env, and non-strict action env harms hermeticity and cache sharing. ↩

  8. actions — if default shell environment inheritance is enabled and env is also supplied, explicit env entries override inherited values. ↩

  9. actions — mnemonic is a one-word action description such as CppCompile or GoLink. ↩

  10. actions — progress_message supports %{label}, %{input}, and %{output} substitutions. ↩

  11. actions — execution_requirements supplies scheduling information for run() and run_shell() actions. ↩

  12. Creating Persistent Workers — worker-capable actions use execution requirements such as supports-workers. ↩

  13. ExecutionInfo — provider constructor fields for requirements and exec group. ↩

  14. Command-Line Reference — --incompatible_allow_tags_propagation currently defaults to true and controls whether target tags are copied to actions' execution requirements. ↩

  15. Bazel — core implementation, documentation, and regression corpus — Bazel 9.1.0 ExecutionRequirements.java constants and comments define the cache, placement, sandbox, network, worker, fakeroot, and path-mapping keys and deprecate local. ↩1 ↩2 ↩3

  16. Command-Line Reference — --modify_execution_info mnemonic-regex syntax, ordered add/remove clauses, accumulated repetitions, supported action kinds, and output-affecting classification. ↩1 ↩2

  17. Remote Caching — no-remote-cache excludes a target from remote-cache use. ↩

  18. rules_claude — Claude Code rules and hermetic toolchain — public rule and toolchain surfaces plus the ruleset-specific credential, shell-environment, local-execution, and sandbox boundaries. ↩

  19. Action — action introspection fields for rule testing include args, argv, env, inputs, mnemonic, and outputs. ↩

  20. rules_ts repository map — public TypeScript rule docs, focused examples, and symptom-oriented regression routes. ↩