4.2 Custom Rules, Providers & Actions

The first temptation when writing a custom rule is to treat the rule function like a small build script: read these files, run that tool, write these outputs, and call it done. Bazel never lets the rule function run that way. my_rule = rule(implementation = _impl, attrs = {...}) registers a target kind, and _impl(ctx) runs during analysis. Inside _impl, the rule does not compile, copy, or read source contents. It validates attributes, declares output File objects, registers actions Bazel may execute later, and returns providers that downstream targets can read.

So read this section as the anatomy of a target contract. Every article answers one of five questions: what does the target accept, what work may Bazel schedule, what files does the target expose, what data can other rules consume, and where should invalid input fail?

The Contract Sheet

The first line of the contract is the rule declaration. 4.2.1 Rule Function defines the target kind: rule(), typed attrs, the analysis-only role of ctx, and the rule that every generated file must have exactly one generating action. 4.2.4 Error Handling & Validation is the same contract from the negative side: shape problems belong in attr.*, semantic invariants in fail(), provider-wide invariants in provider(init = ...), and artifact-content checks in execution-phase validation actions.

The second line is work. 4.2.2 Actions converts the rule contract into the action graph: declare File objects with ctx.actions.declare_file(), register one generating action per file with ctx.actions.run(), run_shell(), write(), expand_template(), or symlink(), and pick stable mnemonics that survive into logs and aquery. 4.2.3 Args & Command Lines keeps that work scalable: ctx.actions.args() preserves command lines as structured data, defers depset expansion until execution, supports param files for large argument vectors, and keeps top-level map_each callbacks from retaining unnecessary analysis data.

The third line is file-shaped output. 4.2.5 DefaultInfo & Runfiles is the universal handoff: files is the build contract, runfiles is the runtime contract, and executable is the launch contract. 4.2.6 Executable & Test Rules specializes that surface for bazel run and bazel test, including rule(executable = True), rule(test = True), generated launchers, executable bits, the difference between action inputs and runfiles, and RunEnvironmentInfo for environment derived by the rule. 4.2.9 OutputGroupInfo & Output Groups adds named optional outputs such as debug files, lint reports, IDE metadata, or _validation markers without inflating the default build.

The fourth line is data-shaped output. 4.2.7 Custom Provider Declaration gives the mechanic: declare a provider symbol with provider(doc = ..., fields = ...), document fields, return instances next to DefaultInfo, require provider compatibility on dependency attrs with forms such as attr.label_list(providers = [MyInfo]), and use init for invariants that must hold no matter which rule constructed the provider. 4.2.8 Provider-as-Interface Pattern gives the design rule: depend on what a target offers, not which rule kind produced it. Returning a broad provider such as CcInfo is a public compatibility statement. A narrower custom provider keeps unintended consumers out of the graph.

The Rule Author's Failure Mode

Most early custom-rule bugs collapse these lines into one another. Files that should be optional outputs become default outputs. Runtime data is treated like action input data. Debug artifacts are passed through output groups when another rule really needs a provider. Language providers are returned because they are convenient, not because the target means that language contract.

The input side has the same shape. Tool dependencies miss their configuration boundary, schema checks are duplicated with prose fail() messages, and transitive data is flattened into lists before the real consumer needs it. The cure is to name the surface first: build output, runtime data, launch contract, optional artifact, provider interface, or validation layer. Then write the Starlark that belongs to that surface.

That surface naming starts with analysis purity. The implementation may inspect ctx.attr, ctx.file, ctx.files, ctx.executable, configuration, and dependency providers. It must not open source files, write artifacts, or run the tool itself. Anything that depends on file contents belongs in an action. Anything that depends on attribute values or provider data belongs in _impl. The wider phase model behind that boundary lives in 2.2 Three Phases of a Build.

Reading Path

Read 4.2.1 Rule Function and 4.2.2 Actions first. The smallest useful rule already needs both. Bring in 4.2.3 Args & Command Lines as soon as the rule handles more than a handful of files, especially transitive inputs arriving through depsets from 4.1.5 depset vs list.

Read 4.2.4 Error Handling & Validation before the rule grows too many ad hoc checks. It names provider(init = ...) and _validation early because error handling has to choose the right layer. Revisit those signals after the provider and output-group articles if you implement those paths. Then move through 4.2.5 DefaultInfo & Runfiles before 4.2.6 Executable & Test Rules, because the default-output, runfiles, and executable fields are the same provider and executable/test rules specialize that contract. Save 4.2.7 Custom Provider Declaration and 4.2.8 Provider-as-Interface Pattern for the moment the rule has to cooperate with other rules. Keep 4.2.9 OutputGroupInfo & Output Groups for optional artifacts, validation markers, and tool-requested outputs that should not become default build products.

Production hardening moves into 4.4 Production Rule Surface, and the proof layer lives in 4.5 Rule Testing & Documentation. Both lean on the surfaces established here, not on hidden behavior inside _impl.

think

Classify: A custom rule has a generated build artifact, a runtime file, metadata for downstream rules, an optional report for tools, and an invalid input case. Which rule surface should own each one?

Reveal

Put the normal build artifact in DefaultInfo.files, the runtime file in runfiles, downstream semantic data in a custom provider, and the optional report in an OutputGroupInfo field. Reject structurally invalid calls in the attribute schema when possible, then use fail() for analysis-time relationships the schema cannot express, and reserve a validation action for checks that require file contents or execution.

Use the mini-ruleset as a runnable companion. Its glyph_library implementation shows validation, actions, providers, default outputs, and output groups in one rule.

key takeaway

A custom rule is the analysis-phase contract of a target kind, not a build script. The rule function declares the BUILD-file API, the implementation validates inputs, declares outputs, registers actions, and returns providers. The nine items in this section are the contract sheet for that target kind: accepted inputs, scheduled work, file surfaces, data interfaces, and failure boundaries.