4.13.1 Build a Ruleset End-to-End
recommendedThe 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:
| Checkpoint | Contract added | Evidence before moving on |
|---|---|---|
| 1 | minimal provider and produced output | build the leaf in the checkpoint-1 workspace |
| 2 | real compiler CLI and action | rebuild the leaf in the checkpoint-2 workspace |
| 3 | supported BUILD load path | build through defs.bzl in the checkpoint-3 workspace |
| 4 | toolchain, worker, and one execution group | worker build plus aquery |
| 5 | settings and configured edges | run debug and inspect split branches |
| 6 | optional graph overlay | request the aspect output group |
| 7 | external dependency materialization | build through generated repos |
| 8 | tests, generated docs, and release gates | test every boundary |
| 9 | intentional rejection contracts | reproduce, decode, and repair failures |
At every checkpoint, ask two questions before adding the next mechanism:
- 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.
- Where would a failure belong? Loading failures point at
.bzland 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:
$ 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.
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:
| Audience | Gate | What it catches |
|---|---|---|
| compiler maintainer | focused Python unit test | language/tool behavior without Bazel graph noise |
| rule maintainer | analysis tests | provider, action, output-group, and diagnostic drift |
| BUILD-file consumer | public integration and example tests | load-path, linking, runfiles, and runtime drift |
| releaser | Stardoc diff, CI, and BCR scaffolding | public 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:
$ 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:
$ 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:
$ 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.
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
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