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, 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]
python = "3.14"
uv = "latest"
dprint = "latest"
gitleaks = "latest"
trivy = "latest"

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

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

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

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.

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
install i Sync dependencies and install git hooks.
format f Auto-format all sources and documents.
check c Run all static checks (lint, types, format, security).
test t Run the test suite with coverage.
build b Build the distribution artifacts (wheel, image).
watch w Run the app with live reload (for web services).

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:format",
  "check:leaks",
  "check:lint",
  "check:types",
  "check:vuln",
  "check:scan",
]

# 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"

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), and check:scan (configuration misconfigurations).

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

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.
  • Pin Your Toolchain in [tools]: Declare the exact tools your tasks need so every contributor and CI runner uses identical versions. This eliminates "it works on my machine" surprises without a separate installation 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