Most repositories share the same workflows. Standardizing them is easy. Reusing them is not.
I maintain a lot of repositories. Personal projects, work projects, libraries, small services. They span various ecosystems: Python, TypeScript, Rust, Go, even Terraform and Terragrunt.
Almost every one of them needs the same developer-facing workflows:
setup-dev: set up a development environment, from fresh clone to running project. Today that means a pile of manual steps. As a workflow, it is one thing.update: refresh everything that can rot. Homebrew itself and its packages (brew update+brew upgrade), tool versions (mise,uv tool), and the repo’s own dependencies:uvfor Python,cargofor Rust,bunfor TypeScript,go get -ufor Go, even GitHub Actions versions.lint: format, type check, everything that should pass before a committest: as the name suggests. Most app and library repos have it. Some, like Terraform, don’t.publish: library repos onlybuild/deploy: app repos, and infrastructure too. For Terraform, build isinit+planand deploy isapply. For a Cloud Run service, build isdocker build+pushand deploy isgcloud run deploy.
That list is the common core, not the ceiling. Repos grow variants on top: test-integration, test-e2e, docs. The names multiply. The pattern does not.
The commands themselves were never the problem. Any task runner (Make, Just, Task, mise, Invoke) standardizes the interface in an afternoon:
test:
# ...
lint:
# ...
release:
# ...
setup-dev:
# ...
Now every repo speaks the same verbs, whatever language lives underneath. Muscle memory carries over. A new contributor types make test and it works.
I did this years ago. And I still ended up maintaining the same rituals, by hand, in every repository I own.
This article is about that gap.
The part that never got standardized
Open two lint targets side by side. A Python repo:
# this recipe, hand-copied into every repo I own
lint:
bunx prettier --write "**/*.{js,jsx,ts,tsx,json,yaml,md}"
uv run toml-sort --in-place pyproject.toml .mise.toml
uv run ruff format .
uv run ruff check --fix .
uv run ty check .
uv run deptry .
actionlint
A Rust repo (it also ships a Python wheel through maturin). Same recipe; the diff is the whole story:
lint:
bunx prettier --write "**/*.{js,jsx,ts,tsx,json,yaml,md}"
uv run toml-sort --in-place pyproject.toml .mise.toml
uv run ruff format .
uv run ruff check --fix .
uv run ty check .
uv run deptry .
+ cargo fmt --check
+ cargo clippy --all-targets -- -D warnings
actionlint
The second recipe is the first recipe plus two cargo lines. Same workflow, one repo type extends it.
Every repository I own carries its own copy of this recipe. The first copy was free. Evolution was not. Adopting deptry meant editing the lint recipe in every repo, one at a time. Tune the prettier glob in one repo and the rest keep the old one. Fix a clippy flag in one and it stays wrong everywhere else.
The copies drift too. Some repos have deptry, some don’t. Some split lint and lint-fix. Each repo ends up with its own dialect of a copy-paste.
One workflow, conceptually. N copies, in practice, each with its own history.
Why the existing tools don’t close the gap
The existing runners have a solution for this, always the same shape: share text. Make can include common.mk. Just has modules, Task and mise have includes and templates. One shared file, N repos point at it, and it works, as long as every repo wants the identical recipe.
This works until the first repo needs something slightly different. A Rust repo wants lint plus two cargo lines, and sharing text has no way to say “that lint, plus this.” The Rust repo keeps its own copy, and the copies live separate lives again. Inclusion reuses text. What you needed to reuse was the workflow, and workflows vary per repo.
Invoke gets closer: tasks are real functions in a real language. But flat functions, with no native way to say this repo’s lint is that lint, plus cargo. You hand-roll it, a helper called from N hand-written tasks, the same duplication one level up.
I looked past the runners too: build systems, monorepo tooling, anything with a task in its name. The search kept ending the same way. Nothing let a repo say “that lint, plus this.” If something out there does, I never found it.
What the missing abstraction actually needs
The fix has two requirements.
- Inheritance, override, and mixins. The standard OOP toolkit for specializing a general thing, decades old and boring by design. A repo must be able to say: this
lintis the sharedlint, plus two cargo lines. - Distribution. The shared part must be versioned and installed like any dependency, so improving the shared lint is one edit, not N copy-pastes.
The two requirements are the spec for a library: general code, written once, versioned, installed, specialized per consumer. Every ecosystem ships this for ordinary code. For workflows, I couldn’t find it anywhere. HTTP clients set the precedent: nobody pastes one into every repo, the general part is a library and each app passes its config. Workflows are the last kind of code waiting for that arrangement.
So I built one
The last thing I wanted was another task runner. But the spec was two requirements, and I couldn’t find anything that passed. I built one anyway. It’s called bakefile.
Not from scratch, though. The shape had already shipped once, as the config framework from an earlier post (Smarter Python Configs): base, service, and instance classes inheriting and overriding, which met both requirements and proved the concept. bakefile is that idea rearchitected, clean enough to publish as a library.
Any language with classes and a package registry would carry it. Python fits the spec well, and it is the language I already write. PyPI is the distribution channel. And the hard parts were already libraries: Typer for the CLI, Pydantic for typed task parameters. In bakefile, a workflow is a method on a class. The universal part of the recipe, prettier, on a bare base:
from bake import Bakebook, command
class BaseBakebook(Bakebook):
@command()
def lint(self) -> None:
self.ctx.run('bunx prettier --write "**/*.{js,jsx,ts,tsx,json,yaml,md}"')
bakebook = BaseBakebook()
bake lint runs it. Writing it costs the same as the Makefile. The win comes when each ecosystem extends the base:
class PythonBakebook(BaseBakebook):
def lint(self) -> None:
super().lint() # prettier
self.ctx.run("uv run toml-sort --in-place pyproject.toml .mise.toml")
self.ctx.run("uv run ruff format .")
self.ctx.run("uv run ruff check --fix .")
self.ctx.run("uv run ty check .")
self.ctx.run("uv run deptry .")
class RustBakebook(BaseBakebook):
def lint(self) -> None:
super().lint()
self.ctx.run("cargo fmt --check")
self.ctx.run("cargo clippy --all-targets -- -D warnings")
Neither copies the base. What’s left of the recipes is actionlint, which belongs to neither language, so it lives in a mixin:
class GitHubActionsTools(BaseBakebook):
def lint(self) -> None:
super().lint()
self.ctx.run("actionlint")
The maturin repo copies nothing. It composes:
class MyBakebook(GitHubActionsTools, RustBakebook, PythonBakebook): ...
Its lint runs the base, then the Python lines, then the cargo lines, then actionlint: the second recipe from earlier, nine lines, none copied. Drop RustBakebook and it is the first recipe. “That recipe, plus this,” which no include file could say, is super().
These classes are general code, so they do what general code does: move into a library. bakefile ships one, bakelib, with them as Spaces. A new linter is one commit in one repository, picked up by every repo on its next dependency update.
Neither requirement needed new machinery. The abstraction was waiting for workflows to arrive as code.
The tradeoffs, stated plainly
There are costs. bake starts a Python interpreter on every run. make and just are single binaries. They start faster, and it hasn’t mattered: the workflows a runner executes, lint suites, test suites, deploys, take seconds to minutes, and interpreter startup happens once, before any of them.
Every repo now carries Python, including the Rust and Terraform ones. PEP 723 softens this.
Note
A
bakefile.pyfollowing PEP 723 declares its dependencies in a comment block at the top, and the runner fetches them itself. No pyproject.toml, no virtualenv. The repo carries one file.
The shared classes are now a dependency too, versioned and upgraded like any library. That is the cost. The benefits are the other side of the same fact: the improvement lands once, in the shared library, and each repo takes it with a one-line version bump, on its own schedule.
The question I’d rather you take away
Recall the last thing you fixed in a lint recipe. A clippy flag, a prettier glob, a new checker. It went into the repo you had open that day. Did it reach the others?
In ordinary code you would not even have that question. The general part moved into libraries decades ago, and each app kept its own part. Workflow commands are still waiting for that.
bakefile is my answer, in the language I had. With two or three repositories, copying is fine. Past that, it is worth it.
bakefile is on GitHub, Apache 2.0. Install with pip install "bakefile[lib]".






