4.13.1 Build a Ruleset End-to-End

recommended

The mini-ruleset project is the finished state of this walkthrough. Its README is a reference map. Here we will build one continuous path through the project, from the first provider to a release-shaped ruleset. Keep the project open while you read, because every listing points to the file you can run.

This is a guided reconstruction over one working tree. Each numbered checkpoint adds one contract and names the evidence that proves it. Checkpoints 1–3 have small runnable states that you can materialize without changing the checked-out project. From checkpoint 4 onward, the walkthrough develops and verifies the finished project directly.

You can read and run the checkpoints as written, or build along in a disposable copy or branch. The first two use deliberately temporary implementations so the provider and action contracts remain visible before toolchains and workers enter the design. When a listing is labelled temporary complete, replace that whole temporary file at the next checkpoint. Do not mix it with the finished implementation. # --snip-- marks code already established or introduced later.

If you only want to inspect the end state, run the catalog commands in the checked-out project. Its dependency-free bootstrap target remains a useful comparison point, but it uses the finished implementation: it is not evidence that either temporary bootstrap state is currently checked in.

The finished-state command catalog remains in the project's meta.yaml. The first three runnable states live under checkpoints. Its README explains how the small sets of replacement files create and validate each state. The commands below use checkpoints/materialize_checkpoint.sh to create each disposable workspace.

The language is deliberately tiny. A Glyph source names a module, imports other modules, and may declare a runtime resource. That leaves the Bazel boundaries visible: analysis creates providers and actions, execution invokes a compiler, and the public API hides the implementation. 4.2 Custom Rules, Providers & Actions explains those mechanics in depth. Here the focus is the order in which they become one stable ruleset.

Use this map when the details get dense:

CheckpointContract addedEvidence before moving on
1minimal provider and produced outputbuild the leaf in the checkpoint-1 workspace
2real compiler CLI and actionrebuild the leaf in the checkpoint-2 workspace
3supported BUILD load pathbuild through defs.bzl in the checkpoint-3 workspace
4toolchain, worker, and one execution groupworker build plus aquery
5settings and configured edgesrun debug and inspect split branches
6optional graph overlayrequest the aspect output group
7external dependency materializationbuild through generated repos
8tests, generated docs, and release gatestest every boundary
9intentional rejection contractsreproduce, decode, and repair failures

At every checkpoint, ask two questions before adding the next mechanism:

  1. What new contract did this command prove? A green build is useful only when you can name whether it proved a provider shape, an action, a supported load path, a configured edge, or a distribution boundary.
  2. Where would a failure belong? Loading failures point at .bzl and visibility boundaries. Analysis failures point at attributes, providers, toolchains, and configured targets. Execution failures point at declared inputs, tools, arguments, and produced files. Keep that phase classification before the specific diagnostic—the same symptom often has very different repairs in different phases.

This short loop is the capstone's main working habit. The individual APIs came from earlier sections. Here you practice changing one boundary, collecting one piece of evidence, and keeping the next failure attributable.

1. Bootstrap one provider and one generated file

The first useful contract is not the compiler. It is the data that one analyzed target gives its dependents. Begin with the three fields a compiler pipeline needs. The checkpoint1 overlay contains the files for this temporary complete first state of glyph/providers.bzl:

"""Public providers for rules_glyph."""

GlyphInfo = provider(
    doc = "Carries the bootstrap compile and link contract for Glyph libraries.",
    fields = {
        "direct_modules": "depset of module names declared directly by this target.",
        "interface_objects": "depset of interface objects read by direct dependents.",
        "link_objects": "depset of compiled objects consumed by the linker.",
    },
)

Do not add fields merely because the finished provider has them. Exports, manifests, modules, and runfiles join the contract only when a later rule consumes them. The decision from 4.1.5 depset vs list is already visible: data meant to cross dependency edges starts as a depset.

Now create the temporary complete first glyph/internal/rules.bzl. It writes placeholder object and interface files. This is the smallest legal action graph that lets us test the provider and output contract before choosing a compiler.

"""Implementation details for the public Glyph rules."""

load("//glyph:providers.bzl", "GlyphInfo")

def _compile_impl(ctx):
    if not ctx.files.srcs:
        fail("%s: glyph_library requires at least one source file" % ctx.label, attr = "srcs")

    obj = ctx.actions.declare_file(ctx.label.name + ".glyphobj")
    iface = ctx.actions.declare_file(ctx.label.name + ".glyphiface")
    ctx.actions.write(obj, "module=%s\n" % ctx.attr.module)
    ctx.actions.write(iface, "module=%s\n" % ctx.attr.module)

    info = GlyphInfo(
        direct_modules = depset([ctx.attr.module]),
        interface_objects = depset([iface]),
        link_objects = depset([obj], order = "postorder"),
    )
    return [info, DefaultInfo(files = depset([obj]))]

glyph_library = rule(
    implementation = _compile_impl,
    attrs = {
        "srcs": attr.label_list(allow_files = [".glyph"], mandatory = True),
        "module": attr.string(mandatory = True),
    },
    provides = [GlyphInfo],
)

Give it one source with no imports or resources. The finished project preserves this leaf as bootstrap.glyph and the checkpoint-1 //examples/basic:bootstrap. During bootstrap, load the implementation directly. Checkpoint 3 removes that temporary exception.

load("//glyph/internal:rules.bzl", "glyph_library")

glyph_library(
    name = "bootstrap",
    srcs = ["bootstrap.glyph"],
    module = "app.bootstrap",
)

Materialize and build that exact state from the project root:

$ checkpoint_root="$(mktemp -d)"
$ bash checkpoints/materialize_checkpoint.sh 1 "$checkpoint_root/checkpoint1" >/dev/null
$ (cd "$checkpoint_root/checkpoint1" && bazel build //examples/basic:bootstrap)
Target //examples/basic:bootstrap up-to-date:
  bazel-bin/examples/basic/bootstrap.glyphobj
INFO: Build completed successfully

This checkpoint proves that analysis returns the advertised provider and every declared default output has a producer. It does not yet prove that Glyph source was compiled.

2. Complete the bootstrap with the compiler action

The compiler in compiler/glyphc.py checks that the BUILD attribute agrees with the source module, reads dependency interface files, rejects undeclared imports and resources, and writes an object, interface, and manifest. Its compile subcommand is a normal command-line program: the rule passes paths and options as data, and a separate adapter owns the Bazel worker protocol.

The checkpoint2 overlay replaces the two ctx.actions.write() calls with a real compiler action. At this checkpoint use a private executable attribute. It is deliberately temporary: the first execution contract stays runnable without making toolchain resolution a hidden prerequisite. This is the complete replacement block. Add _compiler to the temporary rule declaration after it.

obj = ctx.actions.declare_file(ctx.label.name + ".glyphobj")
iface = ctx.actions.declare_file(ctx.label.name + ".glyphiface")
manifest = ctx.actions.declare_file(ctx.label.name + ".glyphmanifest")

args = ctx.actions.args()
args.add("compile")
args.add("--module", ctx.attr.module)
args.add_all(ctx.files.srcs, before_each = "--src")
args.add("--mode", "opt")
args.add("--target_os", "host")
args.add("--out", obj)
args.add("--iface", iface)
args.add("--manifest", manifest)

ctx.actions.run(
    executable = ctx.executable._compiler,
    arguments = [args],
    inputs = ctx.files.srcs,
    outputs = [obj, iface, manifest],
    mnemonic = "GlyphCompile",
    progress_message = "Compiling Glyph module %{label}",
)
# --snip-- srcs and module
"_compiler": attr.label(
    default = "//compiler:glyphc",
    executable = True,
    cfg = "exec",
),

ctx.actions.args() keeps arguments structured until execution. The private attribute puts the compiler in the execution configuration. See 4.2.2 Actions for the full action contract. Checkpoint 4 explains why this private label is still not the right distribution boundary. Do not opt into an @-param file yet: the plain compiler accepts ordinary arguments, while the worker adapter introduced in checkpoint 4 owns param-file expansion and protocol framing.

The complete materialized rule is the checkpoint-2 glyph/internal/rules.bzl, and its lightweight executable target is compiler/BUILD.bazel.

The checkpoint-2 overlay includes the complete replacement rule, _compiler attribute, and a small executable //compiler:glyphc target, so materializing checkpoint 2 does not depend on a later overlay:

$ checkpoint_root="$(mktemp -d)"
$ bash checkpoints/materialize_checkpoint.sh 2 "$checkpoint_root/checkpoint2" >/dev/null
$ (cd "$checkpoint_root/checkpoint2" && bazel build //examples/basic:bootstrap)
Target //examples/basic:bootstrap up-to-date:
  bazel-bin/examples/basic/bootstrap.glyphobj
INFO: Build completed successfully

This checkpoint now proves source parsing and the compiler CLI as well as the provider and output wiring. On a cold cache in the finished project it may take several minutes because the final worker stack generates Protobuf bindings with a hermetic C++ toolchain. The temporary one-shot action itself does not need the worker protocol. That cost appears only after checkpoint 4.

3. Freeze the public API

Consumers should not keep loading the implementation file used during bootstrap. Build the supported BUILD-facing surface in the checkpoint-3 glyph/defs.bzl:

"""Public entry point for rules_glyph."""

load("//glyph:providers.bzl", _GlyphInfo = "GlyphInfo")
load(
    "//glyph/internal:rules.bzl",
    _glyph_library = "glyph_library",
    # --snip-- binary, test, and report rules exported when introduced
)

# --snip-- macro and transition-wrapper loads added in later checkpoints

visibility("public")

GlyphInfo = _GlyphInfo
glyph_library = _glyph_library
# --snip-- later public exports

Private aliases prevent an imported symbol from becoming an accidental export. The implementation file's visibility("//glyph/...") enforces the other side of the boundary. 4.4.1 Rule Public API Design explains why load paths, provider fields, attributes, outputs, and generated repository names all become API.

At this checkpoint the facade needs only the provider and library. Later checkpoints widen the same explicit export list with binaries, macros, tests, reports, and transition wrappers. The module extension and command-line aspect remain separate documented entry points because they serve MODULE and tooling users, not BUILD authors.

Add visibility("//glyph/...") to the implementation file, then change the bootstrap BUILD file to load from the facade. The output does not change. The contract being proved does. The supported load remains usable while the old internal load is now rejected by Starlark visibility. The checkpoint-3 consumer/BUILD.bazel is an example that is expected to fail. After the bootstrap build that loads the rule through defs.bzl succeeds, run the deliberately unsupported load in the materialized checkpoint-3 workspace:

Won't build
$ checkpoint_root="$(mktemp -d)"
$ bash checkpoints/materialize_checkpoint.sh 3 "$checkpoint_root/checkpoint3" >/dev/null
$ (cd "$checkpoint_root/checkpoint3" && bazel build //consumer:illegal_internal_load)
ERROR: /tmp/glyph-stage3-evidence.lVHjSv/stage3/consumer/BUILD.bazel:1:6: Starlark file //glyph/internal:rules.bzl is not visible for loading from package //consumer. Check the file's `visibility()` declaration.
WARNING: Target pattern parsing failed.
ERROR: Skipping '//consumer:illegal_internal_load': error loading package 'consumer': file //consumer:BUILD.bazel contains .bzl load visibility violations
ERROR: error loading package 'consumer': file //consumer:BUILD.bazel contains .bzl load visibility violations
ERROR: Build did NOT complete successfully

The temporary directory prefix in the first line comes from the captured run. your mktemp directory will differ. The decisive phrase is "not visible for loading from package //consumer". This is a loading-time .bzl load-visibility failure: Bazel cannot load consumer/BUILD.bazel, so it never declares or analyzes illegal_internal_load. It is not target visibility—the target's own visibility is never consulted—and it is not target analysis, which would happen only after Bazel had successfully loaded the package. The lightweight checkpoints/validate_checkpoints.sh check captures this expected failure and rejects any other reason for the command to fail.

load("//glyph:defs.bzl", "glyph_library")

package(default_visibility = ["//visibility:public"])

glyph_library(
    name = "bootstrap",
    srcs = ["bootstrap.glyph"],
    module = "app.bootstrap",
)

The checkpoint3 overlay versions both the facade and the BUILD-file load. Materialize it from the finished project just as before. The script first applies checkpoints 1 and 2:

$ checkpoint_root="$(mktemp -d)"
$ bash checkpoints/materialize_checkpoint.sh 3 "$checkpoint_root/checkpoint3" >/dev/null
$ (cd "$checkpoint_root/checkpoint3" && bazel build //examples/basic:bootstrap)
Target //examples/basic:bootstrap up-to-date:
  bazel-bin/examples/basic/bootstrap.glyphobj
INFO: Build completed successfully

When glyph_binary and the symbolic glyph_app macro arrive, export them through this same BUILD-facing facade. A macro is appropriate for declaration- time composition, while rules continue to own actions and providers—the decision boundary from 4.1.1 Macro vs Rule Decision Framework.

4. Route compiler selection through a toolchain

4a. Replace the bootstrap compiler label

The private _compiler attribute got us to a real action without premature infrastructure. Now replace only tool selection: load GLYPH_TOOLCHAIN_TYPE, read ctx.toolchains[GLYPH_TOOLCHAIN_TYPE].glyph, and declare toolchains = [GLYPH_TOOLCHAIN_TYPE] on the rule. Keep the three-field provider and dependency-free leaf until the next checkpoint. One architectural change per green checkpoint keeps failures attributable.

think

Decide: downstream users need a different compiler on some execution platforms. Should glyph_library expose a public compiler-label attribute, or should the rule request a compiler capability?

Reveal

Request a toolchain type. A public label attribute would make every BUILD call know where the compiler comes from and would turn that location into API. A toolchain keeps consumers on glyph_library(...). Registration and platform resolution choose the implementation outside the target declaration.

A reusable rule must request a capability, not couple its public behavior to one compiler label. The final project routes compiler selection through a toolchain. The complete contract in glyph/toolchains/toolchain.bzl wraps both the ordinary compiler and worker adapter:

"""Toolchain contract for the Glyph compiler."""

visibility("public")

GlyphToolchainInfo = provider(
    fields = {
        "compiler": "FilesToRunProvider for the one-shot Glyph compiler/linker.",
        "worker": "FilesToRunProvider for the persistent-worker adapter that wraps the compiler.",
    },
)

GLYPH_TOOLCHAIN_TYPE = Label("//glyph/toolchains:toolchain_type")

def _glyph_toolchain_impl(ctx):
    return [platform_common.ToolchainInfo(
        glyph = GlyphToolchainInfo(
            compiler = ctx.attr.compiler[DefaultInfo].files_to_run,
            worker = ctx.attr.worker[DefaultInfo].files_to_run,
        ),
    )]

glyph_toolchain = rule(
    implementation = _glyph_toolchain_impl,
    attrs = {
        "compiler": attr.label(executable = True, cfg = "exec", mandatory = True),
        "worker": attr.label(executable = True, cfg = "exec", mandatory = True),
    },
)

Declare the type, implementation, and toolchain() adapter in glyph/toolchains/BUILD.bazel. Then register it in MODULE.bazel. The rule reads the resolved value through ctx.toolchains[GLYPH_TOOLCHAIN_TYPE], while the implementation carries FilesToRunProvider so Python launchers receive their runfiles.

The type, implementation, adapter, and registration are separate pieces. The current glyph/toolchains/BUILD.bazel contains the first three:

load("@bazel_skylib//:bzl_library.bzl", "bzl_library")
load(":toolchain.bzl", "GLYPH_TOOLCHAIN_TYPE", "glyph_toolchain")

package(default_visibility = ["//visibility:private"])

toolchain_type(
    name = "toolchain_type",
    visibility = ["//visibility:public"],
)

glyph_toolchain(
    name = "source_toolchain_impl",
    compiler = "//compiler:glyphc",
    worker = "//glyph/worker:worker",
)

toolchain(
    name = "source_toolchain",
    toolchain = ":source_toolchain_impl",
    toolchain_type = GLYPH_TOOLCHAIN_TYPE,
    visibility = ["//visibility:public"],
)

# --snip-- bzl_library used by Stardoc

Registration in MODULE.bazel makes the candidate and execution platforms visible to resolution:

# --snip-- module dependencies

register_toolchains("//glyph/toolchains:source_toolchain")

register_execution_platforms(
    "//platforms:general_worker",
    "//platforms:report_worker",
    dev_dependency = True,
)

# --snip-- module-extension setup

The worker needs more than an executable swap. Replace the checkpoint-2 action block with this complete version: the param file is both the one-shot adapter's input and the request argument vector in worker mode, while execution requirements make that protocol contract visible to Bazel.

Bring in the adapter as one support unit before changing the action: glyph/worker/BUILD.bazel, glyph_worker.py, and worker_protocol.proto. The adapter is intentionally not unfolded into the rule listing: it owns length-delimited protobuf request/response framing, while this checkpoint focuses on the rule-side contract—param-file arguments, worker capability, and protocol selection. 4.4.4 Persistent Workers for Rule Authors develops the adapter side.

toolchain = ctx.toolchains[GLYPH_TOOLCHAIN_TYPE].glyph

# --snip-- declare obj, iface, and manifest

args = ctx.actions.args()
args.use_param_file("@%s", use_always = True)
args.set_param_file_format("multiline")
args.add("compile")
args.add("--module", ctx.attr.module)
args.add_all(ctx.files.srcs, before_each = "--src")
args.add("--mode", "opt")
args.add("--target_os", "host")
args.add("--out", obj)
args.add("--iface", iface)
args.add("--manifest", manifest)

ctx.actions.run(
    executable = toolchain.worker,
    arguments = [args],
    inputs = ctx.files.srcs,
    outputs = [obj, iface, manifest],
    mnemonic = "GlyphCompile",
    progress_message = "Compiling Glyph module %{label}",
    execution_requirements = {
        "supports-workers": "1",
        "requires-worker-protocol": "proto",
    },
)

Remove _compiler from the attrs and add the toolchain requirement to the rule:

glyph_library = rule(
    implementation = _compile_impl,
    attrs = {
        "srcs": attr.label_list(allow_files = [".glyph"], mandatory = True),
        "module": attr.string(mandatory = True),
    },
    provides = [GlyphInfo],
    toolchains = [GLYPH_TOOLCHAIN_TYPE],
)

4b. Route one action class without exporting infrastructure policy

The two execution platforms in platforms/BUILD.bazel add a worker-pool constraint to the host OS and CPU. They are root-only demo scaffolding (dev_dependency = True), not a scheduling policy imposed on downstream module graphs. A consuming root that uses glyph_report owns registration of an execution platform satisfying the ruleset's report_pool capability. Toolchain resolution chooses the compiler implementation. An execution group makes a separate choice about where one class of actions runs.

Add glyph_report in the same implementation file. The whole rule unit below shows both sides of that execution-group contract from glyph/internal/rules.bzl:

def _report_impl(ctx):
    toolchain = ctx.exec_groups["glyph_report"].toolchains[GLYPH_TOOLCHAIN_TYPE].glyph
    objects = depset(
        transitive = [dep[GlyphInfo].link_objects for dep in ctx.attr.deps],
        order = "postorder",
    )
    report = ctx.actions.declare_file(ctx.label.name + ".modules.txt")
    args = ctx.actions.args()
    args.add("report")
    args.add_all(objects, before_each = "--object")
    args.add("--out", report)
    ctx.actions.run(
        executable = toolchain.compiler,
        arguments = [args],
        inputs = objects,
        outputs = [report],
        mnemonic = "GlyphReport",
        progress_message = "Writing Glyph module report %{label}",
        exec_group = "glyph_report",
    )
    return [
        DefaultInfo(files = depset([report])),
        OutputGroupInfo(glyph_report = depset([report])),
    ]

glyph_report = rule(
    implementation = _report_impl,
    attrs = {
        "deps": attr.label_list(
            providers = [GlyphInfo],
            mandatory = True,
            doc = "Glyph libraries included in the report.",
        ),
    },
    exec_groups = {
        "glyph_report": exec_group(
            toolchains = [GLYPH_TOOLCHAIN_TYPE],
            exec_compatible_with = ["//platforms:report_pool"],
        ),
    },
    doc = "Routes report generation through a dedicated execution group pinned to the report worker pool.",
)

# --snip-- other public rule implementations

The rule resolves the same toolchain type through its named execution group and pins that group to the report_pool constraint. Checkpoint 5 extracts the inline link-object merge into _link_objects() when the binary needs it too. Export glyph_report from the existing facade, then instantiate a report over the dependency-free bootstrap library. The finished project preserves it as bootstrap_report:

# --snip-- libraries, binary, and tests

glyph_report(
    name = "bootstrap_report",
    deps = [":bootstrap"],
)

# --snip-- transition and macro examples

Follow 4.6.1 Platform Model for Rule Authors, 4.6.2 Defining, Registering & Accessing Toolchains, 4.6.3 Toolchain Resolution, and 4.6.5 Execution Groups & Auto Exec Groups for the producer/consumer split, resolution, and per-action scheduling details.

Add a second dependency-free leaf from bootstrap_worker.glyph and build only that target through the worker strategy. The distinct label guarantees a cache miss both during the build-along and when the finished command catalog runs sequentially. --strategy alone does not change an action key. using the already-built bootstrap target would not prove worker execution.

$ bazel build //examples/basic:bootstrap_worker --strategy=GlyphCompile=worker
Target //examples/basic:bootstrap_worker up-to-date:
  bazel-bin/examples/basic/bootstrap_worker.glyphobj
INFO: Build completed successfully

Checkpoint 5 introduces the binary that the finished glyph_app macro will compose with a private library. 4.4.4 Persistent Workers for Rule Authors explains worker framing and process reuse in depth.

The execution platforms are not decorative. Query the report action and inspect the scheduling decision:

$ bazel aquery 'mnemonic("GlyphReport", //examples/basic:bootstrap_report)'
action 'Writing Glyph module report //examples/basic:bootstrap_report'
  Mnemonic: GlyphReport
  Target: //examples/basic:bootstrap_report
  Execution platform: //platforms:report_worker
  ExecutionInfo: {Pool: glyph-report}

This proves a separate choice from toolchain resolution: glyph_report keeps the same Glyph compiler contract, but its execution group routes only the report action to the platform carrying the report_pool constraint.

5. Make configuration explicit

5a. Grow the first application graph

Configuration is not the next dependency yet. First make the provider useful to more than a leaf. Replace the temporary provider with the finished complete GlyphInfo:

"""Public providers for rules_glyph."""

GlyphInfo = provider(
    doc = "Carries the compile, link, and runtime contract for Glyph libraries.",
    fields = {
        "direct_modules": "depset of module names declared directly by this target; the source of truth exported_modules and modules are derived from.",
        "exported_modules": "depset of modules visible to direct dependents through deps/exports; recorded in the compile manifest.",
        "interface_objects": "depset of small interface objects read when compiling direct dependents.",
        "link_objects": "postorder depset of full compiled objects needed when linking.",
        "modules": "depset of Glyph module names linked through this target.",
        "manifests": "depset of compile manifests surfaced through output groups.",
        "runtime_files": "depset of files needed when linked Glyph binaries run.",
    },
)

The provider grows because consumers now exist. Add the aggregation helpers from the finished rules.bzl, then extend the checkpoint-4 compile implementation in three exact places:

# Before declaring outputs:
direct_deps = ctx.attr.deps + ctx.attr.exports
direct_modules = depset([ctx.attr.module])
exported_modules = depset(
    transitive = [direct_modules, _exported_modules(ctx.attr.exports)],
)
compile_inputs = _interface_objects(direct_deps)

# In the established GlyphCompile args and inputs:
args.add_all(compile_inputs, before_each = "--dep_iface")
args.add_all(exported_modules, before_each = "--exported_module")
args.add_all(ctx.files.data, map_each = _resource_short_path, before_each = "--resource")
# inputs = depset(ctx.files.srcs, transitive = [compile_inputs])

# After the action:
interface_objects = depset(
    [iface],
    transitive = [_interface_objects(ctx.attr.exports)],
)
link_objects = depset(
    [obj],
    transitive = [_link_objects(direct_deps)],
    order = "postorder",
)
modules = depset(transitive = [direct_modules, _link_modules(direct_deps)])
manifests = depset([manifest], transitive = [_manifests(direct_deps)])
runtime_files = depset(
    ctx.files.data,
    transitive = [_runtime_files(direct_deps)],
)

info = GlyphInfo(
    direct_modules = direct_modules,
    exported_modules = exported_modules,
    interface_objects = interface_objects,
    link_objects = link_objects,
    modules = modules,
    manifests = manifests,
    runtime_files = runtime_files,
)
return [
    info,
    DefaultInfo(
        files = depset([obj]),
        runfiles = ctx.runfiles(transitive_files = runtime_files),
    ),
    OutputGroupInfo(
        glyph_manifest = depset([manifest]),
        glyph_manifests = manifests,
    ),
]

Add public deps, exports, and data attributes to glyph_library. All three are now load-bearing in that block. Keep mode = "opt" and target_os = "host" in the compile action for this checkpoint.

Next add glyph_binary. This complete rule unit consumes the provider contract established above, invokes the one-shot compiler from the same toolchain, and turns provider-carried runtime files into runfiles. The two literal configuration values are deliberate at this checkpoint. Checkpoint 5b replaces them with private setting dependencies.

def _binary_impl(ctx):
    if not ctx.attr.deps:
        fail("%s: glyph_binary needs at least one glyph_library dep" % ctx.label, attr = "deps")

    toolchain = ctx.toolchains[GLYPH_TOOLCHAIN_TYPE].glyph
    objects = _link_objects(ctx.attr.deps)
    mode = "opt"
    target_os = "host"

    executable = ctx.actions.declare_file(ctx.label.name)
    args = ctx.actions.args()
    args.add("link")
    args.add("--main", ctx.attr.main_module)
    args.add_all(objects, before_each = "--object")
    args.add("--mode", mode)
    args.add("--target_os", target_os)
    args.add("--workspace_name", ctx.workspace_name)
    args.add("--out", executable)

    ctx.actions.run(
        executable = toolchain.compiler,
        arguments = [args],
        inputs = objects,
        outputs = [executable],
        mnemonic = "GlyphLink",
        progress_message = "Linking Glyph binary %{label}",
    )

    runtime_files = depset(
        ctx.files.data,
        transitive = [_runtime_files(ctx.attr.deps)],
    )
    return [DefaultInfo(
        executable = executable,
        files = depset([executable]),
        runfiles = ctx.runfiles(transitive_files = runtime_files),
    )]

glyph_binary = rule(
    implementation = _binary_impl,
    attrs = {
        "deps": attr.label_list(
            providers = [GlyphInfo],
            mandatory = True,
        ),
        "main_module": attr.string(mandatory = True),
        "data": attr.label_list(allow_files = True),
    },
    executable = True,
    toolchains = [GLYPH_TOOLCHAIN_TYPE],
)

Export glyph_binary through defs.bzl. In checkpoint 5b, replace the two literals with _setting_value(ctx.attr._mode) and _setting_value(ctx.attr._target_os), then add the two private label attrs shown in the finished rule.

Now create the first complete local application graph. The two bootstrap targets are controlled elisions because their complete declarations are already above. everything newly required for hello is visible:

load("//glyph:defs.bzl", "glyph_binary", "glyph_library")

package(default_visibility = ["//visibility:public"])

# --snip-- bootstrap and bootstrap_worker from checkpoints 1 and 4

glyph_library(
    name = "bootstrap_stdlib",
    srcs = ["bootstrap_stdlib.glyph"],
    module = "stdlib.print",
)

glyph_library(
    name = "api",
    srcs = ["api.glyph"],
    module = "app.api",
    exports = [":bootstrap_stdlib"],
)

glyph_library(
    name = "core",
    srcs = ["core.glyph"],
    module = "app.core",
    data = ["runtime/greeting.txt"],
    deps = [":api"],
)

glyph_binary(
    name = "hello",
    deps = [":core"],
    main_module = "app.core",
)
$ bazel build //examples/basic:hello
Target //examples/basic:hello up-to-date:
  bazel-bin/examples/basic/hello
INFO: Build completed successfully

With both component rules stable, add the finished symbolic glyph_app macro and export it from defs.bzl. It creates a private library plus a public binary. It does not define actions or provider logic. The finished macro_hello target later exercises this same facade through the worker command catalog.

The graph now justifies configuration: there is a real compile/link behavior to vary and a real dependency edge to transition.

5b. Configure selected edges

First define a typed string flag in glyph/settings/settings.bzl, then instantiate mode, target_os, and debug_mode in glyph/settings/BUILD.bazel. The rule now adds private _mode and _target_os attributes and reads their GlyphSettingInfo values. This is exactly why they were absent from the first listing: configuration enters only when the implementation has a behavior to configure.

The local bootstrap_stdlib.glyph continues to stand in for an external package. Checkpoint 7 replaces only its label. configuration remains independent of repository materialization.

Now add the outgoing debug transition from glyph/transitions/transitions.bzl:

# --snip-- loads

def _debug_transition_impl(settings, attr):
    return {"//glyph/settings:mode": "debug"}

_debug_transition = transition(
    implementation = _debug_transition_impl,
    inputs = [],
    outputs = ["//glyph/settings:mode"],
)

# --snip-- _debug_binary_impl writes a launcher and forwards runfiles

glyph_debug_binary = rule(
    implementation = _debug_binary_impl,
    attrs = {
        "binary": attr.label(
            executable = True,
            cfg = _debug_transition,
            mandatory = True,
        ),
    },
    executable = True,
)

# --snip-- incoming and split transition rules

The wrapper stays in its original configuration. Only the binary edge moves to mode=debug. The same file also contains an incoming rule transition and a two-branch OS split. 4.7.1 Build Settings for Rule Authors explains flag design. 4.7.2 Starlark Transitions explains the configuration-graph cost and boundary rules.

Re-export the wrappers from the same defs.bzl facade, then wire the setting reader and outgoing edge together in examples/basic/BUILD.bazel:

load(
    "//glyph:defs.bzl",
    "glyph_binary",
    "glyph_debug_binary",
    "glyph_library",
    "glyph_split_report",
    # --snip-- the remaining public rules and macros
)

# --snip-- api and core libraries

glyph_library(
    name = "debug_probe",
    srcs = ["debug_probe.glyph"],
    module = "app.debug_probe",
)

glyph_binary(
    name = "hello",
    deps = [":core"] + select({
        "//glyph/settings:debug_mode": [":debug_probe"],
        "//conditions:default": [],
    }),
    main_module = "app.core",
)

# --snip-- tests and report target

glyph_debug_binary(
    name = "hello_debug",
    binary = ":hello",
)

glyph_split_report(
    name = "hello_split_report",
    binary = ":hello",
)

# --snip-- incoming-transition and macro examples

This is the complete observable loop: the outgoing transition writes mode=debug, debug_mode reads it as a config_setting, and select() adds :debug_probe only on the transitioned dependency edge.

The project command is:

$ bazel run //examples/basic:hello_debug
Glyph binary main=app.core mode=debug target_os=host
hello from glyph stdlib
hello from app api
hello from app.core
debug probe active
resource examples/basic/runtime/greeting.txt: hello from Glyph runtime data

The split version changes target_os on two copies of one dependency, then reads each configured value through ctx.split_attr. The branch logic lives in glyph/transitions/transitions.bzl:

# --snip-- loads and the debug transition

def _split_os_transition_impl(settings, attr):
    return {
        "linux_branch": {"//glyph/settings:target_os": "linux"},
        "macos_branch": {"//glyph/settings:target_os": "macos"},
    }

_split_os_transition = transition(
    implementation = _split_os_transition_impl,
    inputs = [],
    outputs = ["//glyph/settings:target_os"],
)

# --snip-- glyph_debug_binary

def _split_report_impl(ctx):
    lines = ["split branches:"]
    for key in sorted(ctx.split_attr.binary.keys()):
        binary = ctx.split_attr.binary[key]
        target_os = ctx.split_attr._target_os[key][GlyphSettingInfo].value
        lines.append("%s: target_os=%s binary=%s" % (key, target_os, binary.label))
    report = ctx.actions.declare_file(ctx.label.name + ".split.txt")
    ctx.actions.write(output = report, content = "\n".join(lines) + "\n")
    return [DefaultInfo(files = depset([report]))]

glyph_split_report = rule(
    implementation = _split_report_impl,
    attrs = {
        "binary": attr.label(cfg = _split_os_transition, mandatory = True),
        "_target_os": attr.label(
            default = "//glyph/settings:target_os",
            cfg = _split_os_transition,
        ),
    },
)

# --snip-- incoming transition rule
$ bazel build //examples/basic:hello_split_report && \
    cat bazel-bin/examples/basic/hello_split_report.split.txt
Target //examples/basic:hello_split_report up-to-date:
  bazel-bin/examples/basic/hello_split_report.split.txt
INFO: Build completed successfully
split branches:
linux_branch: target_os=linux binary=@@//examples/basic:hello
macos_branch: target_os=macos binary=@@//examples/basic:hello

@@//examples/basic:hello is the canonical main-repository spelling printed by the Label object, not a third target. 3.1.3 Repo Mapping explains the canonical versus apparent-name distinction.

4.7.3 Split Transitions covers branch shapes, ctx.split_attr, and the cost of analyzing one dependency in multiple configurations.

6. Add an opt-in graph overlay

First establish the rule-owned half of the comparison. Add this complete action after GlyphCompile, then add _validation = depset([validation]) to the existing OutputGroupInfo:

validation = ctx.actions.declare_file(ctx.label.name + ".validation")
validation_args = ctx.actions.args()
validation_args.add("validate")
validation_args.add("--object", obj)
validation_args.add("--out", validation)
ctx.actions.run(
    executable = toolchain.compiler,
    arguments = [validation_args],
    inputs = [obj],
    outputs = [validation],
    mnemonic = "GlyphValidate",
    progress_message = "Validating Glyph module %{label}",
)

# Inside the established OutputGroupInfo(...):
# --snip-- glyph_manifest and glyph_manifests from checkpoint 5
_validation = depset([validation]),

The finished action invokes toolchain.compiler validate over obj. Every consumer inherits this execution-phase gate unless explicitly opting out of validation actions.

Once GlyphInfo is stable and rule-owned validation exists, infrastructure can traverse the graph without changing the rules. The complete aspect in tools/aspects/glyph_metadata_aspect.bzl is short enough to read as one unit:

load("//glyph:providers.bzl", "GlyphInfo")

def _glyph_metadata_aspect_impl(target, ctx):
    out = ctx.actions.declare_file(ctx.label.name + ".glyph_metadata.txt")
    modules = sorted(target[GlyphInfo].modules.to_list())
    ctx.actions.write(output = out, content = "\n".join(modules) + "\n")

    transitive = []
    for attr_name in ("deps", "exports"):
        for dep in getattr(ctx.rule.attr, attr_name, []):
            if OutputGroupInfo in dep and hasattr(dep[OutputGroupInfo], "glyph_metadata"):
                transitive.append(dep[OutputGroupInfo].glyph_metadata)

    return [OutputGroupInfo(
        glyph_metadata = depset([out], transitive = transitive),
    )]

glyph_metadata_aspect = aspect(
    implementation = _glyph_metadata_aspect_impl,
    attr_aspects = ["deps", "exports"],
    required_providers = [GlyphInfo],
)

The traversal follows both dependency edges used by the provider. It is optional and infrastructure-owned. The _validation action inside glyph_library is rule-owned and inherited by every consumer. That ownership decision is the core of 4.8.3 Validation Actions vs Aspects. The implementation API itself is in 4.8.2 Aspect Implementation Basics.

$ bazel build //examples/basic:bootstrap \
    --aspects=//tools/aspects:glyph_metadata_aspect.bzl%glyph_metadata_aspect \
    --output_groups=glyph_metadata
Aspect //tools/aspects:glyph_metadata_aspect.bzl%glyph_metadata_aspect of //examples/basic:bootstrap up-to-date:
  bazel-bin/examples/basic/bootstrap.glyph_metadata.txt
INFO: Build completed successfully

After checkpoint 7 materializes external packages, run the same aspect over the finished //examples/basic:metadata alias to watch it traverse core, api, and the generated stdlib repository. The aspect implementation does not change. only the graph under its starting target grows.

7. Materialize external packages with a module extension

The extension collects graph-wide tags, rejects incompatible versions, and only then creates one repository per resolved package. Here is the whole glyph/extensions.bzl, with attribute documentation and comments elided:

"""Bzlmod extension for local Glyph package dependencies."""

load("//glyph:repositories.bzl", "glyph_repo_name")
load("//glyph/internal:repo_rules.bzl", "glyph_module_repo")

_module_tag = tag_class(attrs = {
    "name": attr.string(mandatory = True),
    "version": attr.string(mandatory = True),
})

def _glyph_deps_impl(module_ctx):
    requests = {}
    requesters = {}
    root_direct_deps = {}

    for mod in module_ctx.modules:
        for tag in mod.tags.module:
            if tag.name in requests and requests[tag.name] != tag.version:
                fail(
                    "conflicting versions for Glyph package %s: %s from module %s, %s from module %s" % (
                        tag.name,
                        requests[tag.name],
                        requesters[tag.name],
                        tag.version,
                        mod.name,
                    ),
                )
            requests[tag.name] = tag.version
            requesters[tag.name] = mod.name
            if mod.is_root:
                root_direct_deps[glyph_repo_name(tag.name)] = True

    for package in sorted(requests):
        repo_name = glyph_repo_name(package)
        glyph_module_repo(
            name = repo_name,
            package = package,
            version = requests[package],
        )

    return module_ctx.extension_metadata(
        root_module_direct_deps = sorted(root_direct_deps.keys()),
        root_module_direct_dev_deps = [],
    )

glyph_deps = module_extension(
    implementation = _glyph_deps_impl,
    tag_classes = {"module": _module_tag},
    # --snip-- documentation
)

The extension does not create files itself. Its call to glyph_module_repo crosses into the repository-rule layer, which writes the external source and BUILD.bazel. Keeping that materializer separate lets the extension own graph aggregation while the repository rule owns one package's filesystem contract.

The root module consumes that API with tags, then imports the generated apparent repository names. This is the consumer side of the extension contract in MODULE.bazel:

# --snip-- dependencies, toolchains, and execution platforms

glyph = use_extension("//glyph:extensions.bzl", "glyph_deps")
glyph.module(name = "stdlib", version = "1.0.0")
glyph.module(name = "math", version = "1.0.0")
use_repo(glyph, "glyph_math", "glyph_stdlib")

Now replace the temporary api edge exports = [":bootstrap_stdlib"] with exports = ["@glyph_stdlib//:lib"]. This is a useful architecture check: only a repository label changes. The rule/provider/toolchain layers do not learn how the dependency was materialized. The local bootstrap library may remain as a small comparison target, but it is no longer on the application graph.

glyph_repo_name() centralizes generation inside Starlark, but use_repo() must still spell the public names explicitly in MODULE.bazel. The test command below is what catches drift between those two surfaces. The local fake registry keeps this project offline after dependency fetch. A production repository rule would also own URLs and integrity hashes. See 4.9.4 Repository Rule API for materialization and 4.10.1 Module Extension Fundamentals for graph-wide tag aggregation.

$ bazel build //examples/third_party:uses_math
Target //examples/third_party:uses_math up-to-date:
  bazel-bin/examples/third_party/uses_math
INFO: Build completed successfully

8. Prove the contract and prepare the handoff

The handoff has four audiences, and each needs a different gate:

AudienceGateWhat it catches
compiler maintainerfocused Python unit testlanguage/tool behavior without Bazel graph noise
rule maintaineranalysis testsprovider, action, output-group, and diagnostic drift
BUILD-file consumerpublic integration and example testsload-path, linking, runfiles, and runtime drift
releaserStardoc diff, CI, and BCR scaffoldingpublic documentation and downstream packaging drift

Do not collapse those rows into one broad smoke test. A single failure at the end of the stack proves that something broke. Layered gates identify which contract changed.

The focused compiler/glyphc_test.py locks down the compiler's logical-resource versus runfiles-path mapping. The tests/analysis package asserts provider fields, declared action mnemonics, validation-output isolation, and one expected failure without executing the declared actions. Finally, tests/integration and the public glyph_test targets run produced binaries and their runfiles. This is the boundary split taught in 4.5.1 Analysis-Phase Testing and 4.5.2 Ruleset Integration Tests.

For example, the provider test reads only the public GlyphInfo contract. Setup targets and the other two test shapes are controlled elisions from tests/analysis/analysis_tests.bzl:

load("@bazel_skylib//lib:unittest.bzl", "analysistest", "asserts")
load("//glyph:defs.bzl", "GlyphInfo", "glyph_library")

def _provider_contract_test_impl(ctx):
    env = analysistest.begin(ctx)
    target = analysistest.target_under_test(env)
    info = target[GlyphInfo]

    asserts.equals(env, ["tests.subject"], info.direct_modules.to_list())
    asserts.equals(
        env,
        ["tests.api", "tests.facade", "tests.subject"],
        sorted(info.modules.to_list()),
    )
    asserts.equals(env, 3, len(info.link_objects.to_list()))
    return analysistest.end(env)

_provider_contract_test = analysistest.make(_provider_contract_test_impl)

# --snip-- action-shape and expect_failure tests
# --snip-- fixture targets and test_suite assembly
$ bazel test //compiler:glyphc_test \
    //examples/basic:hello_test \
    //examples/basic:runtime_data_test \
    //tests/analysis:glyph_analysis_test \
    //tests/integration:basic_smoke_test
INFO: Build completed successfully
//compiler:glyphc_test                                  PASSED
//examples/basic:hello_test                             PASSED
//examples/basic:runtime_data_test                      PASSED
//tests/analysis:glyph_analysis_test_actions            PASSED
//tests/analysis:glyph_analysis_test_provider           PASSED
//tests/analysis:glyph_analysis_test_rejects_empty_srcs PASSED
//tests/integration:basic_smoke_test                     PASSED

Executed 7 out of 7 tests: 7 tests pass.

The checked-in CI workflow keeps those layers distinct. It first runs the broad ruleset suite, then builds the public examples, exercises worker and one-shot compilation on different, previously unbuilt targets, verifies optional output groups, and inverts the expected result for each rejection target. That target choice matters: --strategy does not change an action key, so running worker and local builds over an already cached target would be a green workflow with no evidence that either strategy executed.

The docs/BUILD.bazel target runs Stardoc over the public facade, and a diff test compares the generated file with docs/glyph_api.md. That makes API documentation a checked artifact rather than a prose promise. The full pattern is in 4.5.4 Stardoc — API Documentation.

Generate the document, then run the drift gate rather than treating generation alone as proof:

$ bazel build //docs:glyph_api_docs
Target //docs:glyph_api_docs up-to-date:
  bazel-bin/docs/glyph_api_generated.md
INFO: Build completed successfully

$ bazel test //docs:glyph_api_docs_diff_test
//docs:glyph_api_docs_diff_test PASSED
Executed 1 out of 1 test: 1 test passes.

Finally inspect the BCR templates and release plan. They do not publish anything. They make the remaining handoff explicit. The presubmit template addresses example targets as @rules_glyph//... because BCR evaluates them from an anonymous test module, not from the ruleset as the root module. It exercises public runtime targets. analysis tests are maintainer-facing checks and stay in the ruleset's own presubmit rather than standing in for downstream consumption. The source MODULE.bazel declares the stable module name and Bazel floor, while the release/BCR workflow supplies 0.1.0. A checkout does not pretend to be a published registry version. 4.11 Ruleset Packaging & Publishing connects archive integrity, CI gates, module metadata, and compatibility policy into the publishing contract.

This final boundary also separates repository CI from downstream consumption. The repository workflow may run maintainer-only analysis tests and intentional failures. The BCR presubmit addresses only public example targets through @rules_glyph//..., as a consumer would. Passing the first without the second can still hide a broken archive, module name, public load, or generated repo. passing only the second can miss an internal provider or action regression.

$ bazel run //tools/release:print_release_plan
rules_glyph release checklist:
1. Run bazel test //...
2. Build //docs:glyph_api_docs and attach generated docs to the release.
3. Create a stable source archive from the release tag.
4. Fill bcr/templates/source.json with url, integrity, and strip_prefix.
5. Run the BCR presubmit matrix from bcr/templates/presubmit.yml.

For teaching: this script prints the release gate instead of publishing
anything. A production ruleset would wire these steps into tag-triggered CI.

9. End with the contracts that fail

A ruleset is not complete until its error surface is intentional. The targets in examples/errors/BUILD.bazel are tagged manual, so broad builds remain green while each contract can be invoked directly.

The blocks below retain only the decisive lines from Bazel 9.0.0. The diagnostic text itself is unchanged.

missing_import proves that imports come from declared deps, not from whatever files happen to exist. Its missing_import.glyph imports stdlib.print, but the target has no provider-bearing dependency:

Won't build
$ bazel build //examples/errors:missing_import
errors.missing_import: import 'stdlib.print' is missing from deps
Target //examples/errors:missing_import failed to build
ERROR: Build did NOT complete successfully

This is execution of the GlyphCompile action, not loading or analysis. The compiler could read the source and dependency interfaces, then rejected an import absent from those interfaces. The smallest repair is to add the library that produces stdlib.print:

glyph_library(
    name = "missing_import",
    srcs = ["missing_import.glyph"],
    module = "errors.missing_import",
    deps = ["@glyph_stdlib//:lib"],
)

missing_resource reaches the same action through a different contract. Its missing_resource.glyph names a runtime path that is absent from data:

Won't build
$ bazel build //examples/errors:missing_resource
errors.missing_resource: resource 'examples/errors/absent.txt' is missing from data
Target //examples/errors:missing_resource failed to build
ERROR: Build did NOT complete successfully

The decisive phrase is missing from data: adding another compile dependency would not help. Create absent.txt and declare it on the target:

glyph_library(
    name = "missing_resource",
    srcs = ["missing_resource.glyph"],
    module = "errors.missing_resource",
    data = ["absent.txt"],
)

Finally, unnamespaced compiles successfully and then fails in the separate, rule-owned GlyphValidate action:

Won't build
$ bazel build //examples/errors:unnamespaced
module name 'broken' is not namespaced; expected '<namespace>.<name>'
Target //examples/errors:unnamespaced failed to build
ERROR: Build did NOT complete successfully

The repair is not a deps or data change. Give the source declaration and BUILD attribute the same namespaced value, for example errors.unnamespaced. 4.2.4 Error Handling & Validation explains why schema validation, analysis-time fail(), and execution-phase validation own different failures.

The finished tree, every supported command, and the deliberately mocked pieces remain documented in the mini-ruleset README. You now have one buildable end state rather than nine disconnected demonstrations: provider data feeds actions, the public facade freezes the supported loads, toolchains supply the compiler, configuration changes selected edges, aspects overlay the graph, the extension creates repositories, and tests/docs/release scaffolding protect the resulting contract.

key takeaway

A ruleset grows safely when each checkpoint consumes a contract established by the previous checkpoint. Start with provider data and one declared action. Freeze public loads before adding toolchains and configuration. Add aspects and extensions only after their provider and repository boundaries are stable. Then make tests, generated docs, release scaffolding, and failure modes part of the same API.

Check your understanding · 4 questions

1.A bootstrap rule invokes its compiler through a private executable attribute. What is the next boundary to stabilize before supporting multiple execution platforms?

Select one answer

2.Match each mechanism to the boundary it controls in the finished ruleset:

Drag each answer onto the matching prompt, or click an answer and then click a prompt

Answers
Provider
Execution group
Aspect
Module extension

3.Which checks belong in the ruleset's handoff gates?

Select all that apply

4.An import exists somewhere in the workspace, but GlyphCompile reports that it is missing from deps. What is the smallest correct repair?

Select one answer

0 of 4 answered