bakefile is a Python task runner I wrote. An earlier post in this series audited it against Make, Just, Task, mise, and Invoke; this post assumes nothing from it. The tool’s central bet: a repo’s entire developer interface, project setup, tool installs, lint, test, build, publish, versioning, can live in one class. Every repo that adopts it carries a single bakefile.py script, and the class inside (conventionally named MyBakebook, called a bakebook) is assembled from ready-made pieces called Spaces: a Python Space, a Rust Space, GitHub Actions tooling, and so on, each a small class stacked on a shared base. Compose the class, inherit the workflows.
That sounds like a lot of machinery for one class declaration, so here are three entire per-repo configs, verbatim:
# the floor: only the methods you write become commands
class MyBakebook(Bakebook): ...
# a Python library, published to PyPI
class MyBakebook(GitHubActionsTools, PythonLibSpace): ...
# a Rust CLI, published to crates.io; maturin builds a Python
# interface that publishes to PyPI too
class MyBakebook(RustSpace, PythonSpace, GitHubActionsTools, BaseLibSpace): ...
# a JavaScript site (Astro): this blog
class MyBakebook(GitHubActionsTools, BaseSpace): ...
# each repo instantiates its bakebook; bake discovers it
bakebook = MyBakebook()
The first line is the floor: a bakebook begins as a bare Bakebook, a plain class whose public methods each become a bake command, and the other lines add Spaces that ship those tasks already written. The three after it are real, and the range is the point. The next is leetcode-py; then zerv, where the two registries are why it swaps PythonLibSpace for BaseLibSpace; and the last is the JavaScript Astro site, running on just GitHubActionsTools and BaseSpace because no JavaScript Space exists (how that works is section 2). Dozens more of these files run in my other repos. From the smallest repo I own to the largest, the per-repo config stays a composition line, never a config file, and the file ends by instantiating it: bake finds bakebook in the file and runs its methods.
These repos are also a stress test of the series’ opening argument: that developer workflows need an abstraction, or every repo drifts into its own dialect of lint-and-test scripts. An argument is cheap. This post is the receipts. Those MyBakebook classes above work because design patterns sit under the hood: composition over configuration, Template Method, a uniform interface, and workflows shared as libraries, four patterns that hold across every repo I run bakefile in, from the class line up to org-wide fan-out.
Composition over configuration
Each pattern in this post works a different side of the arrangement, and this first one is about what you write. Start with what those three lines give you: every public method on the bakebook becomes a bake command, inherited ones included, so a fresh clone of the Python library already knows the whole dev loop, setup to publish to clean, and nobody wrote a single one of those tasks in that repo. Even bake --help fills itself in, because the command list is the method list. The most boring task of the lot shows where tasks like these come from: bake clean comes from CleanUtils, a cleanup mixin composed into BaseSpace itself. Simplified from bakelib:
class CleanUtils(Bakebook): # abridged
@command(help="Clean gitignored files with optional exclusions")
def clean(self, exclude_patterns=None, default_excludes=True):
# list git-ignored candidates, then remove them:
# dry-run aware, skips nested git repos, honors -e/--exclude-patterns
# default_excludes adds the hook's set: {".env", ".cache"}
...
# bakelib: the base every Space stacks on, CleanUtils already inside
class BaseSpace(CleanUtils, Bakebook): ...
Why composition rather than configuration? A config-file runner ships clean as config keys the tool interprets: exclude patterns, maybe a flag. What clean means, how it deletes, when it skips, stays inside the tool, and anything the keys did not anticipate is unavailable. Here the mixin IS the behavior: a repo inherits clean instead of configuring it, and when the defaults don’t fit, the fix is code, override the hook and return a different set. The full language instead of keys a schema guessed at.
The pattern’s core move: the class declaration IS the configuration. A Makefile starts empty, so every repo re-authors the same lint-and-test boilerplate and each copy drifts. A bakebook starts full, and the config records only the delta: which parts you compose. The Python library’s contributing guide documents its interface without sourcing a single task, because the honest answer to “where is this task defined” is “in the library, pinned in one dependency line”.
CleanUtils is nothing special here. Mixins contribute whole capabilities the same way: CI env-var handling, service-shaped deploy tasks. One mixin, one capability, one class line to switch it on.
Composition is also how features arrive, and the change that ships one can be a single class line. Adding GitHubActionsTools teaches lint actionlint and update actions-up, nothing else moves, which is how the JavaScript Astro repo lints its GitHub workflows with no workflow-linting config of its own. Adding BaseLibSpace goes further: bake publish appears, and with it the SecretUtils vault, a whole bake secret command group, keychain storage and reactive refresh included, machinery the supporting-cast post unpacks. The same logic scales: one shared layer adds its own parts on top of bakelib, its repos’ bakefiles run from a few lines to a couple dozen, almost all inherited, and a sweep of those bakefiles shows the overwhelming majority of what a bakebook does comes from the classes, not the file.
So the takeaway is the mirror image of the one from the comparison post: if your task-runner config holds lint/test/build boilerplate, that boilerplate is the drift the series’ first post predicted. Config should say what is different about your repo, never restate what is universal.
Template Method: extend, never copy
The first pattern assumed the pick fits. This one is about what you change when it doesn’t. No library anticipates everything, and the failure mode of most task runners is right there: your stack deviates slightly, so you copy the whole task into your config and edit it, and now you own it forever, including every fix the library ships later that your copy never sees. The Spaces avoid that by being written as Template Method classes, a classic design pattern: the base class owns the skeleton of each workflow, and the steps a downstream repo might want to change are small overridable hooks, mostly named with a leading underscore. Every bundled workflow calls super() on its way through. So a bakebook never copies a Space to change one step. It subclasses, overrides the hook, calls super(), adds its line.
The bundled Spaces practice what the pattern preaches. They ship as bakelib, the library layer of the task runner, and every Space in it is a Template Method class: each one stacks on the same base, BaseSpace, which holds the skeletons, and redefines or extends only the tasks it exists for. The full menu:
class BaseSpace(CleanUtils, Bakebook):
# declares the interface: lint, test, setup_dev, update, version, clean
def lint(self) -> None:
self.ctx.run("prettier --write .") # + toml-sort; abridged
class PythonSpace(BaseSpace):
# redefines lint and test: uv, ruff, ty, deptry, pytest
def lint(self) -> None:
super().lint()
self.ctx.run("ruff format .") # + ruff check, ty, deptry; abridged
class RustSpace(BaseSpace):
# redefines lint: cargo sort, fmt, clippy
def lint(self) -> None:
super().lint()
self.ctx.run("cargo clippy") # + cargo sort, fmt, check; abridged
class GitHubActionsTools(BaseSpace):
# extends lint and update: actionlint, actions-up
def lint(self) -> None:
super().lint()
self.ctx.run("actionlint")
No JavaScript Space on the list: bakelib has no JavaScript support yet. So the JavaScript Astro repo picks GitHubActionsTools and BaseSpace, the two classes with nothing JavaScript-shaped about them. BaseSpace sits at the bottom of the menu: no language features of its own, which is exactly why the hooks matter. The Astro repo doesn’t copy anything to fill the gap; it hangs bun off the same hooks the Python Spaces use:
class MyBakebook(GitHubActionsTools, BaseSpace):
def _setup_project(self) -> None:
super()._setup_project()
self.ctx.run("bun install --frozen-lockfile")
def _update_project(self) -> None:
super()._update_project()
self.ctx.run("bun update")
def lint(self) -> None:
super().lint()
self.ctx.run("bun run lint:format")
Now setup-dev installs bun packages after the base setup, update runs bun update after the base update, and lint appends four bun scripts to the inherited prettier-and-toml-sort pass. The repo carries only its delta; the workflow it hangs the delta on comes from the library. The task runner’s own repo does the same with a single guard bolted onto its lint: pinned dependency versions must still agree with the lockfile.
The deepest example is the Rust CLI, zerv, which publishes to both PyPI and crates.io. bakelib ships a publisher per registry, but it builds its Python interface with maturin instead of uv build, so it subclasses the PyPI publisher and overrides the single hook that builds. Everything else in bake publish, the version bump, the token handling, the idempotency, flows through untouched.
The seams hold in the larger repos too: one of them extends the inherited update flow so a dependency refresh automatically re-vendors a package of its own. Same shape every time: skeleton in the library, variation in the repo, nothing copied. And that is the quiet guarantee behind section 1’s composition story. If your stack isn’t covered by a bundled Space, the seams still work. That is how a JavaScript blog runs on a Python task runner.
A uniform interface, with local meanings
The first two patterns were about what you write and what you change. This one is about everyone else: the caller side of the arrangement. Every bakebook exposes the same core interface: bake setup-dev (empty clone to dev-ready repo in one command), bake lint (format and type check), bake test (unit test), and bake update (update tools and dependencies). A few more exist (docs), but these four carry the daily loop. In a fresh clone I never read a README to learn how this repo spells “run the checks”; under Make, that question has five answers, lint, linter, style, check, fmt, and each is a wager on whoever wrote the file.
The tasks are uniform. What each task means is local, and lint is where the gap runs widest. In the task runner’s own repo, a Python library, lint is the inherited Python sweep; in the Rust CLI, the same task adds the cargo tools, because the repo is Rust and Python at once. A terraform repo in one of those fleets joins the same pattern without a language Space at all, and its lint grows something stranger, the task re-invoking itself in child projects:
def lint(self): # the parent repo's whole lint task
self.run_local_linters() # prettier · toml-sort · ruff · ty · deptry · actionlint
for child in self.child_projects():
self.ctx.run("bake lint", cwd=child) # same command, child's own bakebook
Nobody writing these repos listed those commands. The meaning of lint is whatever the composed stacks need it to be; the task name is the only thing that stays fixed. (That terraform loop is the “At scale” story.)
The whole section is visible in two class diagrams. Both share the same blue band at the bottom: BaseSpace, holding the identical skeleton tasks every bakebook inherits. That band is the uniform interface. Everything stacked above it is the local meaning, Spaces redefining those tasks for their own stacks, and the gray per-repo bakebooks composing one line each on top:
The second is the same picture from work: different Spaces above the band, same interface below. BaseServiceSpace and GitHubActionsTools stack on BaseSpace, a FastAPI Space composes PythonSpace with BaseServiceSpace, a Terraform stack layers Terragrunt on Terraform, and the gray project bakebooks on top inherit from their Spaces and from GitHubActionsTools:
Uniformity pays most where the repos are somebody else’s. A developer who has never seen my repo already knows its interface, so deploy-heavy services converge on the same shape, bake build, bake deploy, then a task asserting the deployment landed, and the CI files across those repos are near-clones. The files look the same because the interface is the same, not because someone copied a config they stopped understanding.
This is the interface/implementation split from the series’ opening argument, working: the interface is the contract, the meaning stays local, and a repo can redefine test however it likes because callers never care how it is implemented. They care that it exits non-zero when the repo is broken.
Workflows as libraries
The opening post of this series argued that workflows deserve the arrangement ordinary code has always had. bakefile takes that literally, down to the mechanism: a bakebook is an ordinary Python class, and an ordinary Python class can live in a package. So sharing workflows means publishing a package. No include directive, no templating, no copy-paste. Make reaches for include, Just for modules; bakefile reaches for the mechanism Python already has.
The consuming side is a dependency declaration plus a subclass. In a Python project the declaration is two lines of pyproject.toml, next to every other dependency the repo already has:
[project]
dependencies = [
"bakefile[lib]>=0.0.27",
"bakelib-fastapi>=1.0.0",
]
With the pins declared, the bakebook itself is only the delta:
from bake import command
from bakelib_fastapi import ProjectTasks
class MyBakebook(ProjectTasks):
@command()
def release(self) -> None:
self.ctx.run("gh release create v1.0.0")
bakebook = MyBakebook()
A repo with no project file to hold those pins, the JavaScript site among them, keeps the same declaration as PEP 723 metadata in a comment block at the top of a standalone bakefile.py (the docs cover both forms).
The publishing side needs even less: put the bakebook class in a package and publish it. It stays a normal Python package whose export happens to be a bakebook, and it can live on public PyPI, a private index, a git reference, or a local path. Only the reference line changes; the install itself is identical. Two of the four, the private index and the local path, add one small [tool.uv.sources] entry, uv’s standard way of resolving a by-name dependency somewhere other than PyPI (the docs cover all four):
dependencies = [
# a public PyPI package
"bakelib-fastapi>=1.0.0",
# or a git reference, pinned to a tag
# "bakelib-fastapi @ git+https://github.com/my-org/bakelib-fastapi.git@v1.0.0",
# or a local path, editable while authoring it (declared in [tool.uv.sources])
# { path = "../bakelib-fastapi", editable = true },
# or a private index: same package name, index + [tool.uv.sources] config
]
Inherit every task, override what differs, compose through multiple inheritance: subclassing is the whole API, the same three moves as the first two patterns.
The bundled Spaces are this exact shape, prebuilt: bakelib ships as the lib extra of the task runner, which is why every consumer above pins bakefile[lib] alongside the libraries it composes. The upgrade story follows from the packaging, not from bakefile: when the library grows a new lint step or a new Space, every repo inherits it with a version bump. Under copy-paste sharing, that same improvement is a pull request to every repo that ever copied the logic, and the copies that miss it drift.
The same arrangement runs one layer up, on the private-index option: a shared toolkit wheel layers its own Spaces on bakelib, ships through that index, and carries version pins like any Python package; many repos consume it, and their bakefiles are composition-only, a few lines each. The extreme form: one of them publishes its own bakebook inside a wheel, and its entire bakefile.py re-exports that bakebook, three lines, zero local logic. Even the config file is a distribution artifact now.
That is the compounding move, and it only works because the sharing unit is a package. bakelib is a library for the repos, the toolkit wheel is a library layered on bakelib, and every bakefile.py is just a consumer with a pin. Copy-paste sharing diverges a little every time it crosses a repo boundary, so it subtracts. Package sharing lands each improvement once, in a library, and every consumer inherits it on their next bump, so it accumulates.
At scale: fan-out and boilerplate collapse
The patterns compound in one direction: more repos, less written per repo. Three ways that compounding shows up:
-
Monorepo fan-out. A root bakebook grows one task that fans any other task out to every child project (the terraform loop from the uniform-interface section, scaled up):
bake lintat the root runs lint in each subdirectory, filtered to the repos that changed, dry-run-aware (the runner primitive from the supporting-cast post, applied). Each child stays an ordinary bakebook; the fan-out is one more inherited task, not a new coordination layer. -
Config-only wrapper bakefiles. Repos that wrap an infrastructure tool (terraform-style) shrink to pure declaration, tens of lines each: the workflow skeleton lives in a shared Space, and each repo’s bakefile records only its own paths and targets. Stack enough of these and none of them contains logic.
# a terraform wrapper repo (the network layer) class MyBakebook(TerraformSpace): # init, plan, apply, clean: all inherited # the only overrides: config as class attributes, not code service_account = "terraform-network@my-project.iam.gserviceaccount.com" -
The env mechanism at fleet size. A pattern from the supporting-cast post (next in the series) scales hardest: bakebooks grouped per environment as class attributes,
$ENVpicks the instance, task names stay identical. Across dozens of services the same classes repeat, and a new service inherits the whole set by composing them.
Takeaway: the shared layer is the investment, the first few repos pay for it. Every repo after that onboards with a composition line, not another script.
What it costs
None of the four patterns is free, so here is the bill.
The library layer needs an owner. Inheritance concentrates improvement, and it concentrates maintenance in the same place: every fix, every dependency bump, every new Space lands in one repo that somebody has to maintain. The upside and the downside are the same fact, one place to change everything is also one thing that always needs changing. The toolkit wheel above is owned and centrally tested, which is the only reason it stays an asset instead of becoming a liability.
The override hooks are a semver commitment. The moment a repo hangs logic on a hook, that hook is public API: renaming it, reordering it in the skeleton, or changing its signature breaks every consumer that overrode it. The hooks look small and informal, an underscore and a few lines, but they carry library-grade obligations, and it is the same discipline that keeps any library honest, changing the seams slowly and deliberately.
If this lands in an org, adoption will be partial, some repos on it, some not, and that is fine: shared tasks make migration incremental, a repo adopts one command at a time, keeps its old scripts beside the new interface, and the pattern pays off from the first inherited command.
Final thoughts
The series’ opening argument was that developer workflows deserve the arrangement ordinary code has always had: versions, libraries, interfaces. This post is the receipt side. The same four patterns hold from a one-line bakebook in the smallest repo to dozens of bakefiles in an org: composition over configuration keeps the config a delta, Template Method keeps variation out of the copies, the uniform interface keeps muscle memory portable, and workflows as libraries lands every improvement once, in a package, where every consumer inherits it. None of the patterns is clever. They are ordinary object orientation, pointed at a layer of the stack that usually gets shell scripts.
If this one landed: the comparison post covers why not Make, Just, Task, mise, or Invoke, and the zerv post covers the versioning half. The supporting-cast post (next in the series) drops a level to the machinery underneath these patterns: setup, secrets, publish, environments. The branching post covers the other half of the daily loop. And the why-bakefile page makes the tool’s own case, if you want it from the source.






