4.4.3 Action Execution Contract
recommendedA 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.
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.14
Use execution requirements when the action's nature really demands them:
| Requirement kind | Good reason | Bad reason |
|---|---|---|
| sandbox escape | The tool must use a host service that cannot be modeled yet | The rule forgot to declare an input |
| cache escape | The output is intentionally non-reusable | The tool is nondeterministic and nobody investigated why |
| remote escape | The action needs a local hardware device or license server | The rule assumes local paths instead of declaring tools |
| worker support | The tool implements Bazel's worker protocol | The 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.15 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
Argswhen 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.16 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.17
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
ExecutionInfo lets a rule expose execution requirements at provider scope, not only on one action declaration.PATH instead of declaring executable attrs or toolchains.Footnotes
-
Rules — rule authors must account for tools and libraries required to execute actions, not just user-provided inputs. ↩
-
Rules — private executable attributes with
cfg = "exec"model implicit build-time tools. ↩ -
actions —
run()acceptsexecutable,tools,inputs, andoutputs. Tools are executable inputs with runfiles support. ↩ -
Args — memory-efficient command-line construction, lazy depset processing, and param-file support. ↩
-
actions —
run_shell()command and argument semantics, including shell variable access andArgsflattening caveats. ↩ -
actions —
use_default_shell_envdefaults toFalseforrun()andrun_shell(). ↩ -
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. ↩ -
actions — if default shell environment inheritance is enabled and
envis also supplied, explicitenventries override inherited values. ↩ -
actions —
mnemonicis a one-word action description such asCppCompileorGoLink. ↩ -
actions —
progress_messagesupports%{label},%{input}, and%{output}substitutions. ↩ -
actions —
execution_requirementssupplies scheduling information forrun()andrun_shell()actions. ↩ -
Creating Persistent Workers — worker-capable actions use execution requirements such as
supports-workers. ↩ -
ExecutionInfo — provider constructor fields for requirements and exec group. ↩
-
Remote Caching —
no-remote-cacheexcludes a target from remote-cache use. ↩ -
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. ↩
-
Action — action introspection fields for rule testing include args, argv, env, inputs, mnemonic, and outputs. ↩
-
rules_ts repository map — public TypeScript rule docs, focused examples, and symptom-oriented regression routes. ↩