Make vs Just vs Task vs mise: which reuses workflows?

Five task runners, one missing feature: extend instead of copy. Here is each tool’s attempt, and where it breaks.

The previous post argued that developer workflows need an abstraction: the same lint, test, and deploy recipes get hand-copied into every repo, and nothing lets a repo extend the shared version instead of forking it. That post made the case. This one does the audit.

Every tool below gets the same two questions, and only these two:

  1. How does it share workflows between repos?
  2. Can a repo extend the shared workflow instead of copying it?

Anything else (syntax, speed, Windows support) is settled territory; the comparisons you have already read cover it well. Reuse between repos is the axis nobody scores, and it is the axis that decides whether your next linter migration touches one file or thirty.

The short answer

  • Same commands in every repoMake or Just. Done, don’t overthink. Any runner on this page gives you test, lint, build as a standard vocabulary, and muscle memory carries.
  • One shared workflow, open for extension → none of the five supports this. When one repo needs a small change, it must copy the shared file and edit the copy. After that, improvements to the shared file never reach that repo again, and the copies slowly diverge.

The villain here is not Make’s tabs or timestamps. It is N copies, hand-synced: one workflow, one canonical version, and a private copy in every repo quietly drifting. The fix lands in the repo you have open that day. The other four keep the old version. The previous post told that story; this one grades the tools on stopping it.

The comparison on real axes

MakeJustTaskmiseInvokebakefile
Standardizes commands
Shares files between repospartial
Extends shared workflowspartial
Versioned distribution

The table compares reusability only: how each tool shares workflows between repos, extends them, and distributes them. Speed and install live outside it: make and just are single binaries that start instantly, bake starts a Python interpreter, and Make is preinstalled everywhere. The final section comes back to those points. The top row is a tie on purpose; standardizing commands is the solved part. The last two rows are the whole post.

Make: include

# common.mk, in the shared-workflows repo. Each consumer repo mounts it as a submodule:
#   git submodule add https://github.com/me/shared-workflows shared-workflows
lint:
	uv run ruff format .
	uv run ruff check --fix .
	uv run ty check .
	uv run deptry .

hello:
	@echo world

Make’s answer is twenty years older than the problem’s recent fame: splice a shared file’s text into yours. include just reads the submodule path. (The recipe is trimmed to the Python-only lines; the real one from the previous post also runs prettier, toml-sort, and actionlint.)

A maturin repo (Rust, plus a Python wheel) needs lint with two cargo lines, and Make has no way to say “that, plus this.” The repo redefines the target, and the redefinition repeats every line of the original:

# Makefile, in a consumer repo (the maturin one)
include shared-workflows/common.mk

lint:
	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

make hello still comes from the shared file; only lint forked, and the fork carries every line. The override wins because it sits below the include; move the include to the bottom of the Makefile and the shared recipe wins instead, so whether the fork works depends on file layout, not intent. From here, the shared lint and this fork live separate lives, which is the problem we started with.

Note

Every make run in this repo now prints warning: overriding commands for target 'lint'. The fork is a supported arrangement, just a warned-about one.

A plain Python repo never meets this wall. Its whole Makefile is the include line, and make lint and make hello run the shared recipes: same commands in every repo, zero copies.

The versioned-distribution row is met only by borrowing: Make itself contributes nothing to distribution, so versioning is whatever the submodule gives you. Pinning a revision per repo works, but bumping it is a hand-run ritual in every repo, and Make neither helps nor notices when one skips its turn.

Just: import and mod

Just ships two mechanisms, and they fail differently.

import is Make’s include with better error messages: recipes land in the same namespace.

# shared.just, in the shared-workflows repo. Each consumer repo mounts it as a submodule:
#   git submodule add https://github.com/me/shared-workflows shared-workflows
lint:
    uv run ruff format .
    uv run ruff check --fix .
    uv run ty check .
    uv run deptry .

hello:
    @echo world

A repo can redefine an imported recipe by declaring a duplicate (with allow-duplicate-recipes), which is how the maturin repo gets its two cargo lines:

# justfile, in a consumer repo (the maturin one)
set allow-duplicate-recipes

import 'shared.just'

lint:
    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

But a duplicate is replacement, not extension. Make warns about the fork on every run; Just demands the allow-duplicate-recipes setting up front and then stays quiet about it. The override holds every line of the recipe, just hello still comes from the shared file, base improvements stop at the override, and each fork drifts exactly like the Makefile fork does. The manual itself flags the override rules as half-finished: same-depth duplicates resolve in the wrong order, which the docs call “definitely a bug,” kept for compatibility.

mod goes the other way: namespaced submodules (just shared::lint) with deliberately hard walls. The manual is blunt: “Recipes, aliases, and variables defined in one submodule cannot be used in another.” A recipe cannot list another module’s recipe as a dependency, and it cannot read its variables; wrapping means shelling out to just shared::lint as a subprocess, the same escape a plain shell script has. Sharing between repos degenerates to filesystem paths, unversioned.

Task: includes, with the best distribution of the five

Task deserves credit the other existing tools do not get: its includes accept URLs, including Git refs.

# Taskfile.yml, in the shared-workflows repo. Consumers fetch it over HTTPS,
# so there is no submodule to mount, only a URL in each repo's Taskfile.
version: "3"

tasks:
    lint:
        cmds:
            - uv run ruff format .
            - uv run ruff check --fix .
            - uv run ty check .
            - uv run deptry .

    hello:
        silent: true
        cmds:
            - echo world
# Taskfile.yml, in a consumer repo (the maturin one)
version: "3"

includes:
    shared: https://raw.githubusercontent.com/me/shared-tasks/main/Taskfile.yml?ref=v1.4.0

tasks:
    lint:
        cmds:
            - 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

Where it breaks is the other axis, and it breaks more quietly than its rivals: an include is a namespace, and namespaced tasks cannot be extended. task shared:lint and task shared:hello still run the shared recipes, but the repo’s own lint repeats every command, and the two lints share nothing. There is no warning and no duplicate to declare; the namespaces never collide, so Task never notices that one lint forked. Base improvements reach shared:lint and stop at the repo’s copy, which drifts exactly like the Makefile fork does.

The include line, though, is real versioned distribution. The improvement lands in one repo, repos pin and bump on their own schedule, rollbacks are a ref change. On the distribution axis Task beats Make and Just outright, and the honest table above says so.

Vars pass into the include, so if the shared file anticipated a variation, a flag can parametrize it. Anticipated is the operative word; the shared file must predict every repo’s needs in advance, and the variations it did not predict become forks with the ref pin frozen at the last version that fit.

mise: extends, the honest near-miss in TOML

mise grew real inheritance. Define a template, extend it per task:

# mise.toml, in the parent folder above the repos. mise walks up the directory
# tree and loads it for every repo below, no mount and no paste.
[task_templates.lint]
run = "uv run ruff format . && uv run ruff check --fix . && uv run ty check ."

[task_templates.hello]
run = "echo world"

The extend itself is one line; the command field is where the reuse story dies. run “local overrides completely.” No append, no chain, no parent call. To get its two cargo lines, the maturin repo rewrites the entire string:

# .mise.toml, in a consumer repo (the maturin one)
[tasks.lint]
extends = "lint"
run = "uv run ruff format . && uv run ruff check --fix . && uv run ty check . && cargo fmt --check && cargo clippy --all-targets -- -D warnings"

[tasks.hello]
extends = "hello"

So the fork is replacement, exactly like the Makefile fork, and even mise hello, untouched by the fork, needs its own stanza, because a template is a definition, not a task. The field semantics are otherwise thoughtful: tools and env deep-merge, so the task adds node = "22" and keeps the template’s Python; run is the one field where inheritance dies, and dependencies follow with the twist that an empty local list silently inherits the template’s list. From here, the rewritten lint and the shared lint live separate lives, which drifts exactly like the Makefile fork does.

Distribution is where the parent folder shows its cost: the arrangement works only where the parent exists, on the machine that laid it out. CI clones a repo alone and extends fails; the docs concede it plainly, another machine “will need the same template definition to resolve extends.” mise’s own answer is the monorepo: make the parent a repo and the templates ship with the clone, but one repo is one version, so a template fix lands in every package at the same instant, no pin, no staged rollout. For separate repos, the documented options shrink to a machine-local layout or copying the template into each repo, the N-copies problem with a TOML accent. Fairness requires one more line: tasks are not mise’s main job. Version management is, and it is excellent there.

Invoke: real Python, flat

Invoke is the closest in spirit because tasks are real functions in a real language: parameters, type hints, imports, testable logic. Sharing is the import statement, the thing the other runners need a feature for:

# tasks.py, in the shared-workflows repo. Each consumer repo mounts it as a
# submodule and imports it like any Python module:
#   git submodule add https://github.com/me/shared-workflows shared-workflows
from invoke import task


@task
def lint(c):
    c.run("uv run ruff format .")
    c.run("uv run ruff check --fix .")
    c.run("uv run ty check .")
    c.run("uv run deptry .")


@task
def hello(c):
    print("world")

The maturin repo imports the function, calls it, adds two cargo lines, and repeats not one line of the recipe:

# tasks.py, in a consumer repo (the maturin one)
from invoke import Collection, task

from shared.tasks import hello, lint as lint_base


@task
def lint(c):
    lint_base(c)
    c.run("cargo fmt --check")
    c.run("cargo clippy --all-targets -- -D warnings")


ns = Collection()
ns.add_task(lint)
ns.add_task(hello)

This is the first fork in the audit that does not carry every line of the original, and the credit belongs to Python, not Invoke: lint_base is a function call, the composition any two Python functions allow. Invoke’s own contribution is a flat namespace, and flat is the operative word. Nothing is runnable until the consumer wires it, one ns.add_task(...) per shared task; skip the line for hello and invoke hello reports “No idea what ‘hello’ is!” Importing the shared module as a collection parks the tasks under its module name instead (tasks.lint), and when a local name collides with a shared one, the last add_task wins, silently: no warning like Make’s, no setting like Just’s, so Invoke never notices that a fork exists.

Note

The example’s shared.tasks hides one more wrinkle: the submodule folder is shared-workflows, and a hyphen is not a Python identifier, so even the import line needs a rename or a shim.

Distribution inherits the same flatness. Invoke contributes nothing, so versioning is whatever Python’s import gives you: a submodule pinned by hand, Make’s borrowing again, or a package on PyPI that you write, version, and publish yourself. Which is the tell. If the answer to Invoke’s gaps is “write Python classes with super() calls and publish them on PyPI,” you have not picked a task runner anymore. You have written what I wrote, because that is bakefile’s whole architecture.

bakefile: super(), distributed as a package

bakefile’s answer is the standard object-oriented toolkit, applied to workflows. A workflow is a method on a class, and the shared file is a Python package:

# bakebook.py, in the shared-workflows repo, published as a Python package.
# PyPI, a private index, a git ref, or a local path all work; consumers
# install it like any dependency: pip install shared-workflows
from bake import Bakebook, command


class PythonBakebook(Bakebook):
    @command()
    def lint(self) -> None:
        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 .")

    @command()
    def hello(self) -> None:
        print("world")

The maturin repo subclasses and calls up:

# bakefile.py, in a consumer repo (the maturin one)
from shared_workflows import PythonBakebook


class RustBakebook(PythonBakebook):
    def lint(self) -> None:
        super().lint()
        self.ctx.run("cargo fmt --check")
        self.ctx.run("cargo clippy --all-targets -- -D warnings")


bakebook = RustBakebook()

super().lint() runs the shared four lines and returns, and the cargo lines follow. Nothing repeated, no duplicate to declare, and hello arrives with no wiring at all: inheritance carries every task of the shared class, where Invoke demanded one add_task per shared task and Make’s override only worked if the include sat in exactly the right spot. Variations that belong to neither language compose as mixins, class MyBakebook(GitHubActionsTools, RustBakebook, PythonBakebook), every layer calling up in method-resolution order, none copied.

Distribution is the boring half, deliberately. The shared classes are an ordinary Python package, so distribution is the machinery Python already has: public PyPI, a private index for work code, a pinned git ref, or a local path, all documented on the sharing-tasks page and all installing the same from the consumer’s side. The new linter is one commit in one repo, and each consumer takes it with a one-line bump of the pin (shared-workflows==1.4.0 to 1.4.1) on its own schedule; rollback is pinning the old number. The consumer repo carries one file: a bakefile.py following PEP 723, which declares the pin in a comment block at the top and leaves the environment to the runner. That is the full scorecard: the table’s last two rows, both check marks, from machinery older than every tool in this audit.

The design, the tradeoffs, and the ready-made classes: the why-bakefile page covers the first two, and bakelib ships the third. Later in this series: how the versioned workflows hold together in CI, and what three real repositories look like on it.

Should you use bakefile?

Skip it, plainly, if any of these hold:

  • One to three repos. Copying is fine. The sync cost has not compounded yet, and a dependency you own is still a dependency.
  • Raw speed at the top of the loop. make and just start in milliseconds. bakefile pays interpreter startup on every invocation. It has never mattered in my workflows, but I run long tasks; your mileage is yours.
  • A team of Make veterans. Don’t fight muscle memory for ideology. The reuse win must beat the retraining cost, and small teams pay retraining faster.

Reach for it when the pattern from the previous post is real: more than a handful of repos, the same workflows in each, and a change history you can point to where one improvement should have landed everywhere and landed somewhere.

Next time you fix a lint recipe, count the repos that need the same fix. That number is the whole argument.


bakefile is on GitHub, Apache 2.0. Install with pip install "bakefile[lib]".