Skip to content

5.1. Task Automation

What is task automation?

Task automation is the practice of using software to execute repetitive, manual command-line tasks, minimizing human intervention. This practice boosts efficiency, ensures consistency, and reduces errors.

A classic example is the make utility, which automates software builds through a Makefile. By defining tasks like configure, build, and install, developers can run a single command to prepare a project:

make configure build install

This simple command streamlines a complex sequence of operations, making the development process faster and more reliable.

Why is task automation crucial in MLOps?

In MLOps, where reproducibility and consistency are paramount, task automation is not just a convenience—it's a necessity.

  • Ensures Reproducibility: Automating tasks like data preprocessing, model training, and evaluation guarantees that every step is executed identically, which is critical for reproducible results.
  • Promotes Collaboration: It lets teams share a standardized set of commands for common actions (e.g., mise run test, mise run build), ensuring everyone follows the same procedures and reducing environment-specific errors.
  • Reduces Human Error: Manual command entry is prone to typos. Automation eliminates these mistakes, leading to more reliable builds, tests, and deployments.
  • Improves Efficiency: By automating routine actions, you adhere to the Don't Repeat Yourself (DRY) principle, freeing up AI/ML engineers to focus on more complex challenges.
  • Unifies the Workflow: The real payoff comes when the same task definitions drive your terminal, your git hooks, and your CI/CD pipeline. There is a single source of truth for what "check" or "test" means, so local and remote runs can never drift.

Which task runner should you use?

While Make is powerful and widely adopted, its syntax can be cryptic (e.g., $*, $%, :=) and its strict formatting rules, like requiring tabs instead of spaces, present a steep learning curve.

A modern, more intuitive alternative is mise (pronounced "meez"), a fast tool written in Rust that combines two jobs in a single file:

  1. A task runner: It defines project tasks with a clean, readable syntax—replacing tools like Make, Just, or PyInvoke.
  2. A tool-version manager: It pins the exact versions of your command-line tools (uv, dprint, gitleaks, trivy, hadolint, and even Python itself), so every contributor and every CI machine runs an identical toolchain.

Consider this example from the MLOps Python Package template for building a Python distribution:

[tasks.build]
alias = "b"
description = "Build the Python distribution (wheel + sdist)"
depends = ["build:python"]

[tasks."build:python"]
description = "Build Python distribution artifacts (uv)"
run = "uv build"

Developers can execute the primary task—by its full name or its short alias—with a simple command:

# Execute the build task (or its alias: mise run b)
mise run build

Because mise also owns the tool versions, there is no separate uv add step to install the runner into your Python environment: mise is a standalone binary that bootstraps everything else.

How do you configure a task automation system?

First, install mise using your operating system's package manager (e.g., brew install mise, apt install mise, or the official install script). It is a single binary, not a Python dependency.

Next, create a mise.toml file at the root of your repository. This file is the central entry point for your automated actions: it loads environment variables, pins your toolchain, and defines your tasks.

# https://mise.jdx.dev
# Canonical task vocabulary shared by git hooks (lefthook) and CI (GitHub Actions).

[env]
# Automatically load environment variables from a local .env file.
_.source = ".env"

[settings.task]
# Fail fast if a pinned tool is missing instead of silently installing it in hooks.
run_auto_install = false

# TOOLS: pin the exact command-line tools every contributor and CI run should use.
[tools]
actionlint = "latest"
dprint = "latest"
git-cliff = "latest"
gitleaks = "latest"
hadolint = "latest"
trivy = "latest"
uv = "latest"
zizmor = "latest"

# TASKS: define the project's automation entry points.
[tasks.install]
alias = "i"
description = "Install dependencies and git hooks"
depends = ["install:hooks"]

[tasks."install:python"]
description = "Sync Python dependencies (uv)"
run = "uv sync --all-groups"

[tasks."install:hooks"]
description = "Install git hooks (lefthook)"
depends = ["install:python"]
run = "uv run lefthook install"

Two details in this file are worth reading twice.

First, the dependency chain is deliberate: install depends on install:hooks, which itself depends on install:python. mise resolves the chain, so a single mise run install syncs the environment before it tries to run lefthook from that environment. Declaring depends = ["install:python", "install:hooks"] on install instead would let mise start both in parallel and fail on a fresh clone.

Second, the Python interpreter is deliberately absent from [tools] in the MLOps Python Package. A .python-version file at the repository root holds 3.14, and uv reads it to provision the interpreter—so mise pins uv, and uv pins Python. One version, one file, no chance of the two disagreeing. Note that mise does not read .python-version on its own: support for these "idiomatic" version files is off by default (idiomatic_version_file_enable_tools is empty), so listing python = "3.14" under [tools] as well would be a second source of truth to keep in sync, not a safety net.

mise requires you to explicitly trust a project's configuration before it will run any of its tasks or install its tools—a safety measure that protects you from executing untrusted code when you clone a new repository:

# Trust the project's mise.toml (only needed once per project)
mise trust

# Install every tool pinned under [tools]
mise install

# List all available tasks with their descriptions
mise tasks

# Run a task
mise run install

For more details, refer to the official mise documentation.

Why set run_auto_install = false?

By default, mise installs a missing tool on demand the first time a task needs it. That convenience becomes a liability the moment tasks are run from a git hook or a CI job: a pre-commit hook that silently downloads trivy mid-commit turns a two-second check into a two-minute one, and a CI job that installs a tool outside of mise install can quietly run a version nobody pinned.

Setting run_auto_install = false under [settings.task] turns a missing tool into an immediate, explicit error. Installation becomes a deliberate step (mise install, or the jdx/mise-action step in CI), and every task run afterwards uses exactly the toolchain the repository declared.

How do you lock the toolchain with mise.lock?

Pinning trivy = "latest" under [tools] says which tool you want, not which build. Two contributors who ran mise install a month apart can easily end up on different releases, and a new scanner release can turn a green repository red for reasons that have nothing to do with your change.

mise solves this the same way uv does, with a lockfile. Turn the lockfile setting on—in your project's mise.toml or in your global ~/.config/mise/config.toml—and declare the platforms your team and CI actually run on:

[settings]
lockfile = true
lockfile_platforms = ["linux-x64", "macos-arm64"]

Then generate or refresh mise.lock:

# Resolve every tool in [tools] and record versions, URLs, and checksums
mise lock

# Target specific platforms explicitly, or a single tool
mise lock --platform linux-x64,macos-arm64
mise lock trivy

The generated mise.lock records, for every pinned tool, the exact resolved version, the download URL, and a SHA-256 checksum per platform:

# @generated - this file is auto-generated by `mise lock`

[[tools.actionlint]]
version = "1.7.12"
backend = "aqua:rhysd/actionlint"

[tools.actionlint."platforms.linux-x64"]
checksum = "sha256:8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8"
url = "https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_linux_amd64.tar.gz"

Commit mise.lock next to uv.lock. The two files are the same idea applied to two layers: uv.lock pins the Python dependencies your code imports, mise.lock pins the command-line tools your tasks execute. Together they make a clone reproducible from the interpreter up. The checksums also matter: they turn a tool download into a verified one, so a compromised release artifact fails the install instead of running.

The payoff reaches CI as well. jdx/mise-action keys its cache on mise.lock, so a run that changes no tool reuses the previously downloaded binaries instead of fetching them again—see the CI/CD Workflows chapter.

How should you organize your tasks in an MLOps project?

The key to a maintainable project is a shared task vocabulary: a small, predictable set of top-level tasks that means the same thing in every repository. Agents, new contributors, git hooks, and CI can all rely on it without reading the implementation. The MLOps Python Package template standardizes on these core tasks (each with a short alias):

Task Alias Purpose
all a Run the whole gate in order: format, check, test, build.
install i Sync dependencies and install git hooks.
format f Auto-format all sources and documents.
check c Run every static check (format, lint, types, secrets, vulnerabilities, misconfigurations, workflows, Dockerfile).
test t Run the test suite with coverage.
build b Build the distribution artifacts (wheel + sdist).

Beyond that vocabulary, a project adds whatever top-level tasks it genuinely needs. The MLOps Python Package adds docs (d) to generate the API documentation, coverage (v) to open the HTML coverage report, upgrade (u), clean (n), project to run every MLflow job in sequence, and namespaced helpers such as mlflow:serve, build:image, and docker:compose. The point of the vocabulary is not to forbid extra tasks—it is that the six above always exist and always mean the same thing.

Why does the all task matter?

all is the smallest task in mise.toml and the most important one:

[tasks.all]
alias = "a"
description = "Format, check, test, and build the project (the canonical gate)"
run = ["mise run format", "mise run check", "mise run test", "mise run build"]

Note that run is a list, not depends. depends would let mise run the four in parallel; a list runs them in order, which is what you want here—formatting must happen before checking, and building an artifact from unchecked, untested code is wasted work.

The reason to name this sequence is that a list of steps can omit one, but a named gate cannot. Before this task existed, the CI workflow spelled out its own steps—mise run format, then mise run check, then mise run test—and simply forgot mise run build. Nothing was broken and nothing complained; the packaging step was just never exercised on any pull request, and a broken pyproject.toml build section would only have surfaced at release time. That is the classic failure mode of duplicated pipelines: they do not diverge loudly, they diverge silently, by omission.

With all in place there is exactly one definition of "the project passes". CI runs a single step:

mise run all

So do you, before opening a pull request. Your git hooks run the same tasks, split across the moments where each one is cheap: the formatters and check on pre-commit, test on pre-push. Adding a new gate—say, a Dockerfile linter—means adding one line to check, and every hook, every terminal, and every CI run picks it up at once. Nobody has to remember to update a YAML file.

How do you organize the subtasks?

Each top-level task fans out to namespaced subtasks written as task:subtask. This keeps mise.toml modular and lets you run a single piece in isolation. For example, check runs every static check in parallel by depending on its subtasks:

[tasks.check]
alias = "c"
description = "Run all static checks in parallel"
depends = [
  "check:actions",
  "check:dockerfile",
  "check:format",
  "check:leaks",
  "check:lint",
  "check:scan",
  "check:types",
  "check:vuln",
]

# Lint Python sources (Ruff)
[tasks."check:lint"]
description = "Lint Python sources (ruff)"
run = "uv run ruff check --force-exclude ."

# Type-check Python sources (ty)
[tasks."check:types"]
description = "Type-check Python sources (ty)"
run = "uv run ty check"

# Scan dependencies for known vulnerabilities (pip-audit)
[tasks."check:vuln"]
description = "Scan dependencies for vulnerabilities (pip-audit)"
run = "uv run pip-audit --skip-editable --cache-dir .cache/pip-audit"

# Lint and audit the GitHub Actions workflows themselves
[tasks."check:actions"]
description = "Lint and audit GitHub Actions workflows (actionlint + zizmor)"
run = ["actionlint", "zizmor --offline .github/workflows/"]

# Lint the container image definition
[tasks."check:dockerfile"]
description = "Lint the container image definition (hadolint)"
run = "hadolint Dockerfile"

The subtask names follow a simple convention:

  • format:<input> keys off the source you format: format:python (Ruff) for .py files, and format:dprint (dprint) for JSON, Markdown, TOML, and YAML.
  • check:<concern> keys off the property verified, so the name means the same thing in any language: check:format, check:lint, check:types, check:vuln (dependency vulnerabilities), check:leaks (leaked secrets), check:scan (misconfigurations, licenses, and secrets across the checkout), check:actions (CI workflows), and check:dockerfile (the container image definition).

Because check fans out with depends, mise runs all eight subtasks in parallel. That parallelism is worth knowing about when a subtask writes to disk: check:vuln uses --cache-dir .cache/pip-audit, and check:scan is configured to skip .cache, precisely so that Trivy does not walk a directory pip-audit is writing into at the same moment.

This structure lets you run an individual subtask, the whole group, or the entire suite with equally simple commands:

# Run only the linter
mise run check:lint

# Run only the type checker
mise run check:types

# Run every static check (fans out to all check:* subtasks in parallel)
mise run check

# Run the full gate: format, check, test, build
mise run all

What are some best practices for writing automation tasks?

To maximize the benefits of task automation, follow these best practices:

  • Keep Tasks Atomic: Each task should have a single, well-defined purpose (e.g., check:types instead of a combined check-and-format). Atomic tasks are easier to debug, reuse, and compose.
  • Create Meta-Tasks with depends: Combine smaller subtasks into larger workflows using the depends array. The check task, which fans out to every check:* subtask, is a perfect example—and mise runs the dependencies in parallel for you.
  • Name the Gate: Define a single all task that runs format, check, test, and build in order, and make it the one thing CI executes. A named gate cannot silently lose a step the way a hand-written list of CI steps can.
  • Pin Your Toolchain in [tools], then Lock It: Declare the exact tools your tasks need so every contributor and CI runner uses identical versions, and commit the mise.lock produced by mise lock alongside uv.lock. This eliminates "it works on my machine" surprises without a separate installation step.
  • Fail Loudly on a Missing Tool: Keep run_auto_install = false so a task never silently downloads a tool mid-hook. Installation is its own explicit step.
  • Use [env] and .env Files: Load environment-specific configuration through [env] and a local .env file (via _.source = ".env") instead of hardcoding values inside tasks.
  • Document Every Task: Give each task a description. mise surfaces these in mise tasks and interactive selectors, turning your mise.toml into self-documenting project onboarding.
  • Make mise the Single Source of Truth: Have your git hooks and CI/CD workflows call mise run <task> rather than re-implementing commands. When the definition lives in one place, local checks and remote pipelines can never disagree.

Additional Resources