task

Run any project.

The task file is a simple bash script and standardized interface for all software projects.

It is to be understood as a software development pattern to standardize the installation, configuration and execution of different software frameworks.

Specification

The specification is a short guide to setting up a task file for a Python project.

touch task
chmod +x task
#!/bin/bash
set -e
if [[ -a ".env" ]]; then
    source .env
fi
function help() {
    echo
    echo "task <command> [options]"
    echo
    echo "commands:"
    echo

    # Define column widths
    CMD_WIDTH=10
    OPT_WIDTH=6
    DESC_WIDTH=40
    COLUMN="| %-${CMD_WIDTH}s | %-${OPT_WIDTH}s | %-${DESC_WIDTH}s |\n"

    # Print table header
    printf "$COLUMN" "Command" "Option" "Description"
    echo "|$(printf '%*s' $((CMD_WIDTH + 2)) '' | tr ' ' '-')|$(printf '%*s' $((OPT_WIDTH + 2)) '' | tr ' ' '-')|$(printf '%*s' $((DESC_WIDTH + 2)) '' | tr ' ' '-')|"

    # Print table rows

    printf "$COLUMN" "all" "" "Run all tasks."
    printf "$COLUMN" "install" "" "Setup the local environment."
    printf "$COLUMN" "lint" "" "Run pre-commit and update index.html."
    printf "$COLUMN" "source" "" "Source the Python virtual env."
    printf "$COLUMN" "version" "" "Show version of required tools."

    echo
}
function version() {
    uv --version
}

function install() {
    echo "Setup venv and install python dependencies"
    uv venv env
    source env/bin/activate
    uv pip install pre-commit
}

function lint() {
    source env/bin/activate

    echo "Run pre-commit"
    pre-commit run --all-file
}
if declare -f "$1" > /dev/null; then
    "$1" "${@:2}"
else
    case "$1" in
        all)
            install
            lint
            ;;
        source)
            source env/bin/activate
            ;;
        *)
            echo "Unknown command: $1"
            help
            exit 1
            ;;
    esac
fi

Naming

The naming of functions is important. There are basically two styles:

  1. Action + Object
  2. Object + Action

The task file function use the first style. The name of function starts with the action followed by the object. The object definition can be singular and/or plural.

Examples for actions: activate, install, dev, develop, init, build, start, update, remove, delete, enable, disable, template, convert, create, edit, change, get, set, patch, fetch, generate, push, pull, import, export, list, publish, release, test, setup, prepare, restart, stop, store, restore, translate, upgrade, zip, visualize, sync, switch, run, reset, load, dump, checkout, commit, drop, deploy, handle, trigger, render, lint, uninstall, split, parse, fix, refactor, transform, cat, ls, rm, serve, help, show, filter, login, logout, encrypt, decrypt, upload, download, analyse, transpile, compile, minify, copy

Examples for objects: env, venv, submodule, container, database, snippet, model, module, repo, mail, doc, dependency, view, user, vault, file, host, node, log, password, hash, script, requirement, part, component, system, workspace, image, process, state, platform, dir, folder, readme, overview, lang, level, request, response, result, worker, server, proxy, workflow, volume, network, package, field, value, secret, chart, node, edge, function, method, firewall, html, css, image, svg, style, query, native, group, notebook

Objects can be tools: odoo, vupress, nodejs, zsh, bash, fish, podman, kind, minikube, helm, nvim, docker, podman, rust, python, tmux, vim, helix, system, git, pass, llm, sql, dotenv, javascript, vue, vite, astro, typescript, turbo, pnpm, eslint, jenkins, k8s, nextcloud, postgres, metabase, ansible, prometheus, grafana, hugo, deno, bun, babel, panda, gulp, grunt, electron, react, express, mongodb, angular, ionic, meteor, webpack, bower, jupyter

Patterns

The task file showed above is very basic. Commands can have parameters and functions call each other. The following is a collection of more complex task file patterns.

Set default parameter

Fallback to a default value for a parameter.

function build() {
    PLATFORM="amd64"
    if [ -n "$1" ]; then
        PLATFORM="$1"
    fi

Ensure parameter is not empty

Check the first param and exit if it is empty.

function deploy() {
    if test -z "$1"; then echo "\$1 is empty."; exit; fi

Prompt for input

Use read to ask for inputs.

if [ -z "$2" ]; then
	read -p "Enter the task description: " TASK_DESCRIPTION
else
	TASK_DESCRIPTION="$2"
fi

Setup local env vars

Define env vars at the beginning of the task file.

CONFIGURATION_FILE="file.conf"
GIT_BRANCH=$(git symbolic-ref --short -q HEAD)

Template with env vars

Create a parameterized file from a template. Requires envsubst.

function template-with-env() {
    echo "Template $CONFIGURATION_FILE"

    export CONFIGURATION_1
    export CONFIGURATION_2=${CONFIGURATION_2:="value"}

    envsubst < "file.conf.template" > "$CONFIGURATION_FILE"
}

Create Python virtual env

Initialize Python virtual env with uv.

function init-venv() {
    if [ ! -d "venv$GIT_BRANCH" ]; then
        echo "Init venv$GIT_BRANCH with $(uv --version)."
        uv venv "venv$GIT_BRANCH"
    fi
}

Activate Python virtual env

function activate-venv() {
    echo "Source virtualenv venv$GIT_BRANCH."
    source "venv$GIT_BRANCH/bin/activate"
    echo "$(python --version) is active."
}

Call a Python script

Run a Python script.

function generate-password-hash() {
    activate-venv
    if test -z "$1"; then echo "\$1 is empty."; exit; fi
    PASSWORD_PLAIN="$1"
    scripts/password_hash
}
#!/usr/bin/env python3

import os
from passlib.context import CryptContext
crypt_context = CryptContext(schemes=['pbkdf2_sha512', 'plaintext'], deprecated=['plaintext'])

password = os.environ.get('PASSWORD_PLAIN')
print(crypt_context.hash(password))

Command with named parameters

Assuming you have a docker-compose.yml and would like to start selected or all containers.

function start() {    
    
    if [[ "$1" =~ "db" ]]; then
        docker compose up -d db
    fi

    if [[ "$1" =~ "admin" ]]; then
        docker compose up -d admin
        echo "Open http://localhost:8000 url in your browser."
    fi

    if [[ "$1" =~ "odoo" ]]; then
        docker compose up -d odoo
        echo "Open http://localhost:8069 url in your browser."
    fi

    if [[ "$1" =~ "mail" ]]; then
	    docker compose up -d mail
        echo "Open http://localhost:8025 url in your browser."
    fi
}

Start all containers with task start and selected with task start db,admin.

Run commands in container

Use docker exec -i to run commands in a container.

function drop-db() {
    DATABASE="$1"
    if [ -z "$DATABASE" ]; then
        DATABASE="example"
    fi

    docker exec -i db psql "postgres://odoo:odoo@localhost:5432/postgres" -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$DATABASE';"
    docker exec -i db psql "postgres://odoo:odoo@localhost:5432/postgres" -c "DROP DATABASE \"$DATABASE\";"
}

Loop over files and folders

Use for to loop over file, folders or arrays.

function render() {
    echo "Update index.html for all folders"
    for FOLDER in ./*; do
        if [ -f "$FOLDER/README.md" ]; then
            cd "$FOLDER" || exit
            md2html README.md _site/index.html
            cd .. || exit
        fi
    done
}

Parse and reassemble a file

With this function you can split a file into multiple parts whenever a specific keyword is matching. In this example the keyword is !vault.

function convert-vault-file() {
    FILE_PATH="$1"
    TEMP_FILE=$(mktemp)
    TEMP_PART_FILE=$(mktemp)
	WRITE_FINISHED=false
	
	while IFS= read -r LINE; do
		# Check for keyword
		if [[ "$LINE" =~ "!vault" ]]; then
			# Process part if ready to write
			if [ "$WRITE_FINISHED" ] && [ -s "$TEMP_PART_FILE" ]; then
				# Decrypt part file and write to assemble file
				ansible-vault decrypt "$TEMP_PART_FILE"
				KEY=$(echo "$LINE" | cut -d':' -f1)
				VALUE=$(cat "$TEMP_PART_FILE")
				echo "$KEY: $VALUE" >> "$TEMP_FILE"
			fi
			# Clear the file
			: > "$TEMP_PART_FILE"
			# Flag as ready to write
			WRITE_FINISHED=true
		else
			if [ "$WRITE_FINISHED" ]; then
				# Pipe into part file
				echo "$LINE" >> "$TEMP_PART_FILE"
			fi
		fi
	done < "$FILE_PATH"

	# Output assembled file
	cat "$TEMP_FILE"
	# Cleanup temp files
	rm -f "$TEMP_FILE"
	rm -f "$TEMP_PART_FILE"
}

Usage

Running the task file requires a shell alias: alias task='./task'

Show the available commands with task help.

From the specification the project can be installed with task install.

To source the Python environment run source task source.

Execute all commands with task all.

Completion

With the output of task help the task commands can be completed.

Completion for bash: /etc/bash_completion.d/task_completions

#!/bin/bash

_task_completions() {
    local cur prev commands
    cur="${COMP_WORDS[COMP_CWORD]}"
    prev="${COMP_WORDS[COMP_CWORD-1]}"

    commands=$(./task help | grep -A999 -e '---' | awk '{print $2}' | sed 's/^[[:space:]]*//' | grep -v '^$' | tr '\n' ' ')

    if [[ ${COMP_CWORD} == 1 ]]; then
        COMPREPLY=( $(compgen -W "${commands}" -- "${cur}") )
    elif [[ ${COMP_CWORD} == 2 ]]; then
        COMPREPLY=( $(compgen -f -- "${cur}") )
    else
        COMPREPLY=()
    fi
}

complete -F _task_completions task

Completion for zsh: ~/.oh-my-zsh/completions/_task

#compdef task

_arguments '1: :->tasks' '*: :_files'

case "$state" in
    tasks)
        args=$(./task help | grep -A999 -e '---' | awk '{print $2}' | sed 's/^[[:space:]]*//' | grep -v '^$' | tr '\n' ' ')
        args="$args help"
        _arguments "1:profiles:($args)"
        ;;
esac

GitHub Actions

Running task file commands in GitHub Actions is highly recommended. This way you can run the same CI/CD procedures in the GitHub runner as you do on your localhost.

The GitHub Actions config is simple: .github/workflows/test.yml

on:
  pull_request:
    branches:
      - "main"
  push:
    branches:
      - "main"

jobs:
  task-all:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - name: Install uv
        uses: astral-sh/setup-uv@v5
      - name: Run task install
        run: ./task install
      - name: Run task lint
        run: ./task lint

Jenkins

Run task file commands in Jenkins: Jenkinsfile

pipeline {

    agent any
    
    stages {
        stage('version') {
            steps {
                script {
                    currentBuild.description = sh (script: 'git log -1 --pretty=%B', returnStdout: true).trim()
                }
                sh './task version'
            }
        }
        stage('install') {
            steps {
                sh './task install'
            }
        }
        stage('lint') {
            steps {
                sh './task lint'
            }
        }
    }
}%         

Example

This website is built with a task file. Here is the source:

#!/bin/bash
set -e

function help() {
    echo
    echo "task <command> [options]"
    echo
    echo "commands:"
    echo

    # Define column widths
    CMD_WIDTH=10
    OPT_WIDTH=6
    DESC_WIDTH=40
    COLUMN="| %-${CMD_WIDTH}s | %-${OPT_WIDTH}s | %-${DESC_WIDTH}s |\n"

    # Print table header
    printf "$COLUMN" "Command" "Option" "Description"
    echo "|$(printf '%*s' $((CMD_WIDTH + 2)) '' | tr ' ' '-')|$(printf '%*s' $((OPT_WIDTH + 2)) '' | tr ' ' '-')|$(printf '%*s' $((DESC_WIDTH + 2)) '' | tr ' ' '-')|"

    # Print table rows

    printf "$COLUMN" "all" "" "Run all tasks."
    printf "$COLUMN" "install" "" "Install node packages."
    printf "$COLUMN" "dev" "" "Run 11ty server."
    printf "$COLUMN" "build" "" "Build the 11ty website."
    printf "$COLUMN" "serve" "" "Serve the 11ty output folder."
    printf "$COLUMN" "version" "" "Show version of required tools."

    echo
}


function install() {
    npm install
}

function dev() {
    npx eleventy --serve
}

function build() {
    npx eleventy
}

function serve() {
    npx serve ./_site
}

function version() {
    echo "eleventy: $(npx eleventy --version)"
}

if declare -f "$1" > /dev/null; then
    "$1" "${@:2}"
else
    case "$1" in
        all)
            install
            build
            ;;
        *)
            echo "Unknown command: $1"
            help
            exit 1
            ;;
    esac
fi

Source: janikvonrotz/taskfile.build

More examples

Implementations of the task file standard can be accessed from these projects: