Skip to main content

Command Palette

Search for a command to run...

Productionizing the GALA Build Stack: rules_gala, a Real Toolchain, and Gazelle

From rules welded inside the language repo to a standalone, registry-published rules_gala with a real toolchain and Gazelle BUILD generation.

Updated
10 min read
M
Staff Software Engineer at Snowflake. Expert in scalable architecture, cloud infra, and security. Passionate problem-solver and mentor.

A language that transpiles to Go lives or dies by its build story. The language features can be elegant — sealed types, exhaustive pattern matching, type inference — but if wiring GALA into a real project means hand-writing brittle build rules and babysitting a transpilation step, nobody ships it. For most of GALA's life the build rules lived inside the language repo, bolted directly to its internals. That was fine for bootstrapping the compiler. It was not fine for anyone trying to build a real GALA project alongside their Go code.

The last few releases have been about fixing that. GALA 0.51.0 extracted the Bazel rules into a standalone, registry-published module — rules_gala — sitting behind a proper Bazel toolchain. GALA 0.52.0 added first-class Gazelle support, so the BUILD files that wire everything together are now generated instead of hand-authored. This post is about that direction: turning GALA's build tooling from "works on the maintainer's machine" into something production-grade and reusable across modules.


The problem: rules welded to the language

When the build rules ship inside the language repo, every consumer loads them straight from it:

load("@gala//:gala.bzl", "gala_library", "gala_binary", "gala_go_test")

This looks harmless, and it works. But it quietly couples three things that should be independent:

  1. Your project's build logic depends on the internal layout of the language repo. The @gala//:gala.bzl path is an implementation detail, not a stable API. If the compiler repo reorganizes, your BUILD files break.
  2. The rules hardcode where the tools live. The transpiler binary, the stdlib filegroup, the test runner — every rule reached for them with a fixed Label("//...") pointing back into the language repo. There was exactly one way to assemble a GALA build, and it was the language repo's way.
  3. Versioning is muddled. "Which version of the rules am I on?" and "which version of the compiler am I on?" were the same question. You couldn't upgrade your build logic without upgrading the transpiler, or vice versa.

None of this matters when the only consumer of the rules is the language repo itself. It matters a lot the moment you have several GALA projects — a server library, a TUI framework, an application — that each want to pin versions and evolve independently. The rules needed to become a real, separately versioned dependency.


rules_gala: a registry-published Bazel module

GALA 0.51.0 split the build stack cleanly in two.

The language repo keeps what only it can provide: the transpiler binaries, the standard library, the test framework, and a small set of toolchain bindings.

Everything else — the rule definitions, the bzlmod extension, the repository rules, the library/binary/test macros — moved into rules_gala, which is published through its own Bazel module registry.

Consuming it is two lines in .bazelrc to select the registry (order matters — the custom registry must come first so it wins for rules_gala):

common --registry=https://raw.githubusercontent.com/martianoff/rules-gala/main/
common --registry=https://bcr.bazel.build

and a normal bazel_dep in MODULE.bazel:

bazel_dep(name = "rules_gala", version = "0.1.1")
bazel_dep(name = "gala", version = "0.51.0")

register_toolchains(
    "@gala//tools/toolchain:gala_toolchain",
    "@gala//tools/toolchain:gala_bootstrap_toolchain",
)

The load path now points at the rules module, not the language internals:

load("@rules_gala//gala:defs.bzl", "gala_library", "gala_binary", "gala_test")

gala_library(
    name = "mylib",
    srcs = ["mylib.gala"],
    importpath = "example.com/mylib",
    visibility = ["//visibility:public"],
)

gala_test(
    name = "mylib_test",
    srcs = ["mylib_test.gala"],
    deps = [":mylib"],
)

This is a breaking change — load paths and macro names both moved — but it's the kind of break that buys you a stable surface afterward. The rule set that rules_gala exposes is small and deliberately boring:

Rule Purpose
gala_library Build a GALA library (go_library under the hood).
gala_binary Build a GALA executable.
gala_test Auto-discovers and runs every func TestXxx(t T) T. The canonical test rule.
gala_exec_test Diffs program stdout against a checked-in expected file.
gala_unit_test Wraps a gala_binary as a test (exit-code-only).
gala_transpile Transpile a single .gala file to .go.
gala_transpile_package Batch-transpile all .gala files in a package in one process.
gala_bootstrap_transpile Transpile with the bootstrap binary (stdlib use only).

While extracting the rules, the test surface also got a naming cleanup that had been overdue. The canonical "discover and run TestXxx functions" rule is now simply gala_test; the stdout-diffing rule became gala_exec_test:

- gala_go_test   # discovers TestXxx functions
+ gala_test

- gala_test      # diffs stdout against an expected file
+ gala_exec_test

There are no compatibility aliases. That's a deliberate beta-era choice: rename every call site once, at upgrade time, rather than carry two names for the same thing forever.


The piece that makes it reusable: a toolchain

Moving files around doesn't make rules reusable on its own. The real change in 0.51.0 is how the rules find their tools.

Instead of hardcoding Label("//cmd/gala:gala") and friends, every rule in @rules_gala//gala:defs.bzl resolves its tools through a Bazel toolchain@rules_gala//gala:toolchain_type. The language repo registers an implementation of that toolchain backed by its in-repo binaries, stdlib, and test framework. But because the rules only know about the toolchain type, anyone can supply their own implementation:

load("@rules_gala//gala:toolchain.bzl", "gala_toolchain")

gala_toolchain(
    name = "my_gala_toolchain_impl",
    gala_binary      = "//path/to:gala",
    gala_worker      = "//path/to:gala_worker",
    gala_bootstrap   = "//path/to:gala_bootstrap",
    test_runner      = "//path/to:gala_test_runner",
    test_gen         = "//path/to:gala_test_gen",
    all_gala_sources = "//:all_gala_sources",
    go_mod           = "//:go.mod",
)

toolchain(
    name = "my_gala_toolchain",
    toolchain = ":my_gala_toolchain_impl",
    toolchain_type = "@rules_gala//gala:toolchain_type",
)

This is the standard Bazel toolchain pattern — the same idea behind rules_go's Go SDK toolchain. The rules describe what to do with a GALA source file; the toolchain provides which binaries do it. You could build against a locally-built transpiler, a pinned release, or a patched fork without touching a single BUILD file.

There's one subtlety worth calling out, because it's the kind of thing that bites you in a transpiled language. The stdlib itself is written in GALA and has to be transpiled — but the main toolchain's worker embeds the stdlib. If stdlib targets depended on the main toolchain, you'd have a cycle: stdlib → toolchain → worker → embeds stdlib. So gala_bootstrap_transpile resolves through a separate bootstrap_toolchain_type carrying only the bootstrap binary, which has no stdlib dependency. The split is what lets the language build its own standard library hermetically. It's a small detail, but it's exactly the kind of thing that has to be right for the build to be reproducible from a cold cache.


Gazelle: stop hand-writing BUILD files

A clean rule set still leaves you writing BUILD.bazel by hand — listing sources, and worse, manually tracking the deps between GALA packages as imports change. For a Go project you'd never accept that; Gazelle generates and maintains those files for you. GALA 0.52.0 brought the same thing to GALA.

The mechanism is a small one, and it leans on something the compiler now exposes directly. GALA 0.52.0 added a parse-only subcommand:

gala imports --json path/to/file.gala

This walks the ANTLR parse tree and emits the package and its imports as JSON — no transpilation, no Go SDK required. It's deliberately cheap, because it runs once per file during BUILD generation. That subcommand is the helper the GALA Gazelle extension shells out to in order to resolve dependencies.

The extension ships from rules_gala as the @gala_gazelle Bazel module and is bundled together with Gazelle's Go language into a single gazelle_binary. The payoff is that one command maintains everything:

bazel run //:gazelle

That single pass generates and updates BUILD files for Go packages, GALA packages, and mixed GALA+Go packages together. You steer it with # gazelle: directives — most importantly gala_prefix, which sets your module's import prefix so generated deps resolve correctly:


Two things are worth knowing before you run it:

  • The helper needs a gala on PATH new enough to support gala imports --json. An older CLI fails with unknown flag: --json and silently resolves no GALA deps. If your generated deps come back empty, check the CLI version first.
  • Mixed hand-written .go + .gala packages are left alone on purpose. When Gazelle detects a package that mixes the two, it backs off rather than guessing at a gala_library — those stay on manual gala_bootstrap_transpile + go_library wiring. Generating the easy 90% and refusing to corrupt the hard 10% is the right default for a tool you run on every commit.

If you also want module dependencies resolved from a manifest, the bzlmod extension reads them from gala.mod:

gala = use_extension("@rules_gala//gala:extensions.bzl", "gala")
gala.from_file(gala_mod = "//:gala.mod")

What "production-grade" actually means here

Putting the pieces together, a GALA project's build now has the same properties you'd expect from a mature Go-plus-Bazel setup:

  • Reproducible. Tools are resolved through a toolchain and pinned through the module registry. A cold-cache build pulls a known rules_gala and a known gala, not whatever happens to be checked out.
  • Reusable across modules. Because the rules talk to a toolchain type rather than hardcoded labels, a server library, a TUI framework, and an application can each depend on rules_gala and pin their own compiler version.
  • Independently versioned. Build logic (rules_gala) and the compiler (gala) now move on separate tracks. You can upgrade one without the other.
  • One command for BUILD files. bazel run //:gazelle covers Go, GALA, and mixed packages — no hand-maintained dependency lists.

None of this is glamorous. That's the point. The best build tooling is the kind you stop thinking about, and the direction here is to make GALA's build invisible the way go build is invisible.


Honest limitations

This is beta tooling, and a few rough edges are worth naming:

  • Mixed GALA+Go packages are still manual. Gazelle deliberately won't touch them, so you wire those by hand. If your project is heavily intermixed, expect to write some BUILD rules yourself.
  • The Gazelle helper depends on a recent CLI. The version coupling between the gala on your PATH and the --json flag is a real footgun until the ecosystem settles.
  • Bazel-first. This whole story assumes Bazel. There's a gala run/gala build CLI path for small programs, but the production build experience described here is Bazel-centric. If your shop isn't on Bazel, this is not yet for you.
  • The API just broke. 0.51.0 moved every load path and renamed test rules with no aliases. That's the right call for a beta, but it means upgrading is a real, if mechanical, migration.

Try it

If you're starting a new GALA project on Bazel, the setup is the .bazelrc registry lines and the MODULE.bazel bazel_deps above, then bazel run //:gazelle to generate your BUILD files. The rule reference and directive list live in the rules_gala repository, and the migration checklist for existing projects is in the GALA 0.51.0 release notes.

To see the language itself before committing to a build setup, the GALA Playground runs everything in the browser with no installation. And if you just want to read GALA code, the main repository has the full language spec and a growing set of examples.

The language work — sealed types, pattern matching, type inference — is what makes GALA worth writing. The build work is what makes it worth shipping. Getting rules_gala and Gazelle right is how GALA stops being a clever transpiler and starts being something you can put in production.

GALA From the Ground Up

Part 11 of 11

A ten-part technical blog series exploring GALA — a modern programming language that brings sealed types, pattern matching, monadic error handling, and immutable-by-default semantics to the Go ecosystem. Each post takes a single concept, shows the problem it solves with real code, compares it to idiomatic Go, and is honest about trade-offs. Written for Go developers who want more expressiveness and functional programmers who want Go's runtime.

Start from the beginning

Pattern Matching in Go: How GALA Brings Sealed Types and Exhaustive Matching to the Go Ecosystem

Go is a language built on simplicity. But simplicity has costs, and one cost Go developers feel acutely is the lack of pattern matching and algebraic data types. If you have ever modeled a closed set