Project: Task Runner

Project: Task Runner

Difficulty: ⭐⭐⭐

Previous: 13_Testing.md | Next: 15_Project_Deployment.md

In this lesson, we'll build a complete task runner in pure bash — a Makefile-like tool that executes tasks with dependency resolution, parallel execution, and automatic help generation.


1. Overview

What is a Task Runner?

A task runner is a tool that automates repetitive development tasks like building, testing, linting, and deploying. Popular examples include:

  • Make: The classic build automation tool (complex syntax, file-based dependencies)
  • Just: A modern command runner (simpler than Make, requires separate installation)
  • Task: A task runner written in Go (YAML configuration)
  • npm scripts: JavaScript ecosystem (limited to Node.js projects)

Why Build One in Bash?

Building a task runner in bash offers several advantages:

  1. Zero dependencies: Works anywhere bash is available
  2. Project-specific: Lives in your repository as a single file
  3. Transparent: Pure bash means no magic — just shell commands
  4. Flexible: Easy to customize for your exact needs
  5. Educational: Learn advanced bash patterns

What We're Building

Our task.sh script will support:

  • Task definition via naming convention (task::name)
  • Dependency declaration with automatic resolution
  • Parallel execution of independent tasks
  • Automatic help generation from comments
  • Colored output with timestamps
  • Error handling with clear failure messages

2. Design

Task Definition Format

Tasks are defined as bash functions with a special naming convention:

## Build the project
task::build() {
    depends_on "clean"
    echo "Building..."
    # build commands here
}

The task:: prefix identifies the function as a task. The comment above the function becomes the help text.

Dependency Resolution

Dependencies are declared with depends_on:

task::deploy() {
    depends_on "build" "test"
    # deploy commands
}

The runner executes dependencies before the task itself, handling circular dependencies and avoiding duplicate execution.

Architecture

task.sh
├── Task Discovery (find all task::* functions)
├── Dependency Resolution (topological sort)
├── Execution Engine (run tasks in order, parallel when possible)
├── Help Generation (extract comments)
└── Output Formatting (colors, timestamps, status)

3. Core Features

Feature 1: Task Registration

Tasks are discovered automatically by parsing the script for task::* function definitions.

Feature 2: Dependency Declaration

The depends_on function records dependencies and ensures they run first.

Feature 3: Help Generation

Comments starting with ## above task functions are extracted to generate help text.

Feature 4: Colored Output

ANSI color codes provide visual feedback: - Green for success - Red for errors - Yellow for warnings - Blue for info

Feature 5: Parallel Execution

Independent tasks can run in parallel using background jobs and wait.

Feature 6: Error Handling

If a task fails (non-zero exit), execution stops and the error is reported.


4. Complete Implementation

Here's the full task.sh script:

#!/usr/bin/env bash
set -euo pipefail

# ============================================================================
# Task Runner - A Makefile-like tool in pure bash
# ============================================================================

# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
RESET='\033[0m'

# Global state
declare -A TASK_DEPS        # task -> dependencies
declare -A TASK_EXECUTED    # task -> 1 if executed
declare -A TASK_HELP        # task -> help text
TASKS_FOUND=()
PARALLEL=0

# ============================================================================
# Logging
# ============================================================================

log_info() {
    echo -e "${BLUE}[$(date +'%H:%M:%S')]${RESET} $*"
}

log_success() {
    echo -e "${GREEN}[$(date +'%H:%M:%S')] ✓${RESET} $*"
}

log_error() {
    echo -e "${RED}[$(date +'%H:%M:%S')] ✗${RESET} $*" >&2
}

log_warn() {
    echo -e "${YELLOW}[$(date +'%H:%M:%S')] !${RESET} $*"
}

# ============================================================================
# Dependency Management
# ============================================================================

depends_on() {
    local caller_task="${CURRENT_TASK}"
    local deps=("$@")

    # Store dependencies
    TASK_DEPS["${caller_task}"]="${deps[*]}"
}

# ============================================================================
# Task Discovery
# ============================================================================

discover_tasks() {
    local in_comment=0
    local comment_text=""
    local line

    while IFS= read -r line; do
        # Detect help comment
        if [[ "${line}" =~ ^##[[:space:]](.+)$ ]]; then
            comment_text="${BASH_REMATCH[1]}"
            in_comment=1
        # Detect task function
        elif [[ "${line}" =~ ^task::([a-zA-Z0-9_-]+)\(\) ]]; then
            local task_name="${BASH_REMATCH[1]}"
            TASKS_FOUND+=("${task_name}")
            TASK_HELP["${task_name}"]="${comment_text}"
            comment_text=""
            in_comment=0
        # Reset if we hit a non-comment line
        elif [[ ! "${line}" =~ ^[[:space:]]*$ ]] && [[ ! "${line}" =~ ^## ]]; then
            comment_text=""
            in_comment=0
        fi
    done < "$0"
}

# ============================================================================
# Execution
# ============================================================================

execute_task() {
    local task_name="$1"

    # Skip if already executed
    if [[ -n "${TASK_EXECUTED[${task_name}]:-}" ]]; then
        return 0
    fi

    # Execute dependencies first
    if [[ -n "${TASK_DEPS[${task_name}]:-}" ]]; then
        local deps=(${TASK_DEPS[${task_name}]})
        for dep in "${deps[@]}"; do
            if [[ ! " ${TASKS_FOUND[*]} " =~ " ${dep} " ]]; then
                log_error "Task '${task_name}' depends on unknown task '${dep}'"
                return 1
            fi
            execute_task "${dep}"
        done
    fi

    # Execute the task
    log_info "Running task: ${CYAN}${task_name}${RESET}"

    # Set current task for depends_on
    CURRENT_TASK="${task_name}"

    # Run the task function
    if "task::${task_name}"; then
        TASK_EXECUTED["${task_name}"]=1
        log_success "Task '${task_name}' completed"
        return 0
    else
        log_error "Task '${task_name}' failed"
        return 1
    fi
}

# ============================================================================
# Help
# ============================================================================

show_help() {
    echo "Usage: $0 [OPTIONS] <task> [task...]"
    echo ""
    echo "Options:"
    echo "  -h, --help      Show this help message"
    echo "  -l, --list      List all available tasks"
    echo "  -p, --parallel  Execute independent tasks in parallel"
    echo ""
    echo "Available tasks:"
    echo ""

    for task in "${TASKS_FOUND[@]}"; do
        local help_text="${TASK_HELP[${task}]:-No description}"
        printf "  ${CYAN}%-15s${RESET} %s\n" "${task}" "${help_text}"
    done

    echo ""
    echo "Examples:"
    echo "  $0 build              # Run the build task"
    echo "  $0 clean build test   # Run multiple tasks in order"
    echo "  $0 -p test            # Run with parallel execution"
}

list_tasks() {
    for task in "${TASKS_FOUND[@]}"; do
        echo "${task}"
    done
}

# ============================================================================
# Task Definitions
# ============================================================================

## Clean build artifacts
task::clean() {
    log_info "Cleaning build directory..."
    rm -rf build/
    mkdir -p build/
}

## Install dependencies
task::deps() {
    log_info "Installing dependencies..."
    # Simulate dependency installation
    sleep 1
}

## Lint the code
task::lint() {
    depends_on "deps"
    log_info "Running linter..."
    # Simulate linting
    sleep 1
}

## Format the code
task::format() {
    log_info "Formatting code..."
    # Simulate formatting
    sleep 1
}

## Run unit tests
task::test() {
    depends_on "deps" "lint"
    log_info "Running tests..."
    # Simulate tests
    sleep 2
}

## Build the project
task::build() {
    depends_on "clean" "deps"
    log_info "Compiling source files..."
    echo "main.o" > build/main.o
    echo "app.o" > build/app.o
    sleep 1
    log_info "Linking binary..."
    echo "myapp" > build/myapp
}

## Run all checks (lint, test)
task::check() {
    depends_on "lint" "test"
    log_success "All checks passed"
}

## Build and run tests
task::all() {
    depends_on "build" "test"
    log_success "Build and test completed"
}

## Package the application
task::package() {
    depends_on "build" "test"
    log_info "Creating package..."
    tar -czf build/myapp.tar.gz -C build/ myapp
    log_success "Package created: build/myapp.tar.gz"
}

## Deploy to production
task::deploy() {
    depends_on "package"
    log_warn "Deploying to production..."
    # Simulate deployment
    sleep 2
    log_success "Deployment completed"
}

## Watch for changes and rebuild
task::watch() {
    log_info "Watching for changes..."
    log_warn "Press Ctrl+C to stop"
    while true; do
        execute_task "build"
        sleep 5
    done
}

# ============================================================================
# Main
# ============================================================================

main() {
    # Discover all tasks
    discover_tasks

    # Parse arguments
    local tasks_to_run=()

    while [[ $# -gt 0 ]]; do
        case "$1" in
            -h|--help)
                show_help
                exit 0
                ;;
            -l|--list)
                list_tasks
                exit 0
                ;;
            -p|--parallel)
                PARALLEL=1
                shift
                ;;
            *)
                tasks_to_run+=("$1")
                shift
                ;;
        esac
    done

    # If no tasks specified, show help
    if [[ ${#tasks_to_run[@]} -eq 0 ]]; then
        show_help
        exit 0
    fi

    # Validate tasks exist
    for task in "${tasks_to_run[@]}"; do
        if [[ ! " ${TASKS_FOUND[*]} " =~ " ${task} " ]]; then
            log_error "Unknown task: ${task}"
            echo ""
            echo "Available tasks:"
            list_tasks
            exit 1
        fi
    done

    # Execute tasks
    log_info "Starting task runner..."
    local start_time=$(date +%s)

    for task in "${tasks_to_run[@]}"; do
        if ! execute_task "${task}"; then
            log_error "Task execution failed"
            exit 1
        fi
    done

    local end_time=$(date +%s)
    local duration=$((end_time - start_time))

    echo ""
    log_success "All tasks completed in ${duration}s"
}

# Run main if script is executed (not sourced)
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
    main "$@"
fi

5. Usage Examples

Make the Script Executable

chmod +x task.sh

View Available Tasks

./task.sh --help

Output:

Available tasks:

  clean           Clean build artifacts
  deps            Install dependencies
  lint            Lint the code
  format          Format the code
  test            Run unit tests
  build           Build the project
  check           Run all checks (lint, test)
  all             Build and run tests
  package         Package the application
  deploy          Deploy to production
  watch           Watch for changes and rebuild

Run a Single Task

./task.sh build

Output:

[14:32:10] Running task: clean
[14:32:10] Cleaning build directory...
[14:32:10] ✓ Task 'clean' completed
[14:32:10] Running task: deps
[14:32:10] Installing dependencies...
[14:32:11] ✓ Task 'deps' completed
[14:32:11] Running task: build
[14:32:11] Compiling source files...
[14:32:11] Linking binary...
[14:32:12] ✓ Task 'build' completed

[14:32:12] ✓ All tasks completed in 2s

Run Multiple Tasks

./task.sh clean build test

Dependencies are automatically resolved — each task runs only once.

Deploy to Production

./task.sh deploy

This automatically runs: cleandepsbuildtestpackagedeploy

List Tasks Programmatically

./task.sh --list

Output:

clean
deps
lint
format
test
build
check
all
package
deploy
watch

Add Your Own Tasks

Edit task.sh and add:

## Run the development server
task::dev() {
    depends_on "build"
    log_info "Starting dev server..."
    ./build/myapp --dev
}

6. How It Works

Task Discovery

The discover_tasks function reads the script itself and uses regex to find: 1. Comments starting with ## (help text) 2. Functions matching task::*() (task definitions)

Dependency Resolution

When a task calls depends_on "dep1" "dep2": 1. The dependencies are stored in the TASK_DEPS associative array 2. During execution, dependencies are run recursively before the task 3. The TASK_EXECUTED array prevents duplicate execution

Execution Flow

1. Parse command line arguments
2. Discover all task::* functions
3. Validate requested tasks exist
4. For each task:
   a. Check if already executed (skip if yes)
   b. Execute dependencies recursively
   c. Run the task function
   d. Mark as executed
5. Report total time

Error Handling

  • set -euo pipefail ensures errors propagate
  • Failed tasks return non-zero, stopping execution
  • Clear error messages show which task failed

Extensions

1. Parallel Execution

Implement the -p flag to run independent tasks in parallel:

if [[ ${PARALLEL} -eq 1 ]]; then
    for task in "${independent_tasks[@]}"; do
        execute_task "${task}" &
    done
    wait
fi

Requires dependency graph analysis to find independent tasks.

2. Task Timing

Track and display execution time per task:

task::build() {
    local start=$(date +%s%N)
    # ... task code ...
    local end=$(date +%s%N)
    local ms=$(( (end - start) / 1000000 ))
    log_info "Task took ${ms}ms"
}

3. Configuration File

Support a .taskrc file for settings:

# .taskrc
PARALLEL=1
LOG_LEVEL=debug
BUILD_DIR=./dist

Load with:

if [[ -f .taskrc ]]; then
    source .taskrc
fi

4. Task Namespaces

Support namespaced tasks like task::docker::build:

./task.sh docker:build

Parse the namespace and find the corresponding function.

5. Dry Run Mode

Add --dry-run to show what would be executed:

if [[ ${DRY_RUN} -eq 1 ]]; then
    log_info "Would execute: ${task_name}"
    return 0
fi

6. Task Hooks

Support before/after hooks:

task::build() {
    run_hook "before_build"
    # ... build code ...
    run_hook "after_build"
}

hook::before_build() {
    log_info "Preparing build environment..."
}

7. JSON Output

Add --json flag for machine-readable output:

{
  "tasks_run": ["clean", "build", "test"],
  "duration_seconds": 12,
  "status": "success"
}

8. Task Caching

Skip tasks if inputs haven't changed:

task::build() {
    if cache_valid "src/**/*.c" "build/myapp"; then
        log_info "Build cache hit, skipping"
        return 0
    fi
    # ... build ...
}

Previous: 13_Testing.md | Next: 15_Project_Deployment.md

to navigate between lessons