This readme is very much a work-in-progress and will probably have big gaps for some time.
This is a framework for creating easy-to-maintain and flexible build scripts; at least, that's the intent. This project was started because I have too many docker repos where I followed a copy-paste-customize workflow for creating build scripts. Maintainability is a problem. Also a problem: most of the build scripts are Bash, which makes complicated looping or control flow more eventful than desired.
The main idea with this is to create multiple layers where code re-use can happen between projects, but without creating an ugly mess at the same time. TBH itself should provide just enough logic itself to be useful, and leave other implementation-specific logic to said implementation. There are artificial constraints in some key places to try to keep implementation clean and avoid creating code spaghetti.
This is entirely Tcl, written for Tcl. If you're looking for something to manage other kinds of scripts, you're probably better served with something else (even though this could be repurposed for that, it's probably not worth your effort).
High-level program flow/logic is abstracted from configuration and low-level logic:
- Targets - High-level logic (looping, "do this, then this, etc.")
- Helpers - Low-level logic (transactional)
- Defaults - Configuration (project name, default Docker tag, etc.)
Targets are the main interface component. All code in TBH itself exists to provide a means for executing targets. Targets are pluggable and are defined by calling the target procedure in target files. Targets are not procedures (not directly anyway) and, as such, do not support the concept of arguments. Instead, targets make use of the Defaults system for run-time configuration.
Helpers are plumbing that exists to make writing targets easier. Targets should be as simple as possible and should rely on helpers as much as possible. Consider helpers generic, fixed-purpose, re-usable procedures. Helpers use and require arguments. You cannot run helpers directly; you must use a target to call helpers.
Defaults are a mechanism for creating an ordered hierarchy for configuration. Defaults files define one or more "default" configuration settings. Many defaults files can exist and file name order and how near the file is to the project directory dictates which setting wins.
This capability exists so that a system can have default settings that are overridden by project-specific configuration. Beyond defaults files, any default can be overridden by run-time arguments passed to tbh.
Let's use my real-world use-case of a few Docker projects. Some of the projects are built from a Dockerfile, and some are built by a script that calls Buildah directly. Some projects are a mixture of both scenarios. To start, let's look at the purpose of the components:
- [project directory]/tbh/targets/make_project.target
- Builds the container image for multiple architectures
- Does some extra things beyond what buildah-bud-multiarch.target from tbh-contrib does
- Calls get-file.helper to cache reusable bits for the container images
- Reads a dict containing a list of architectures to build
- Calls buildah-bud.helper for each architecture image, specifying a custom tag convention
- [tbh-contrib]/helpers/get-file.helper
- Downloads a file
- [tbh-contrib]/helpers/buildah-bud.helper
- Creates docker images with Buildah
- [tbh-contrib]/targets/buildah-docker-push-multiarch.target
- Pushes all multi-arch images created by make_project.target to a remote registry
- Calls buildah-docker-push.helper per-image
- [tbh-contrib]/helpers/buildah-docker-push.helper
- Pushes a Docker image to a remote registry
- [tbh-contrib]/helpers/buildah-manifest-create.helper
- Creates a local manifest
- [tbh-contrib]/helpers/buildah-manifest-push.helper
- Pushes a local manifest to a remote registry
That's enough to highlight some things. Some key points for this scenario:
- the make_project target is highly customized for the project, which is why it is in the project repo
- The various helpers highlighted here are very generic and reusable; so much so that they are provided by tbh-contrib.
- The build might be customized for the project, but buildah-docker-push-multiarch.target is perfectly usable for pushing the images, so it's just used for this project.
- make_project.target could have been named buildah-bud-multiarch.target and would have overridden the functionality in the target of that name in tbh-contrib. There's nothing stopping you from doing this, it could get pretty confusing, though.
- Put tbh.tcl where it can be executable and in PATH. You could just pop it into /usr/local/bin/tbh and make it executable.
- If you want tbh-contrib, put the contents somewhere it'll be found. You can export TBH_PATH and point to tbh-contrib.
- You can add additional directories (search paths) to tbhDirs, around line 45-ish
Run tbh
in the root directory of your project. Running it without any
arguments will print a summary of things you can run.
The tbh directory in the project has some examples you can use as a starting point. For each tbhDir, files are loaded from these subdirectories:
- tbh/helpers/*.helper
- tbh/targets/*.target
- tbh/defaults/*.defaults
You can define multiple items of a type in each file, though probably shouldn't; keeping each "thing" in its own file helps to promote the concept that it's its own "thing".
Also worth noting - you can't cross streams. You are free to define multiple items of a type in a file, but you can't mix targets with defaults or helpers. This is an intentional design limitation and serves to promote logical separation of content and discourage creating monolithic tbh files.
The rest of this section references the following three example files for explanation.
target "hello-world" {
title "Hello World"
description "Hello World target"
version "1.0"
help {
This is a big help block.
The intent is to add some level of documentation to stuff.
}
run {
# Executable Tcl code block
call hello-world [cfg hello-world-string]
}
}
Notes:
- The string right after the target name is the internal name of the target
- This name uniquely identifies the helper
- This should be unique, but there's no strict constraint on that
- The last write will win, in terms of naming
- The
title
,description
, andversion
statements should be self-explanatory- TBH prints these values in command output in some places
- These values aren't used for any kind of internal housekeeping; they're really here just to provide in-line metadata
- The
help
block will be printed to stdout by thetbh help target_name
command- Whitespace will be stripped from the beginning of help lines, so don't rely on indentation too much
- The
run
block is where the action is.- You can call helpers with the
call
command - You can call other targets with the
run
command, though you should use this sparingly - Note the call to
cfg
. This command allows you to retrieve configuration settings from defaults files/command line arguments - You can call
title "blah blah blah"
to print a fancy title
- You can call helpers with the
helper "hello-world" {
title "Hello World"
description "Hello World Helper"
version "1.0"
args {blerb}
body {
# Executable Tcl code block
puts $blerb
}
}
Notes:
- The structure of helper definitions is similar to targets
- The
args
are a list that is directly passed to proc to define the helper, so follow proc usage body
contains the executable code for the helper- This is intentionally not named
run
to be distinct from targets - you can call
cfg
in helpers but you shouldn't - helpers should rely onargs
instead - You can call
title "blah blah blah"
to print a fancy title
- This is intentionally not named
defaults {
hello-world-string "Hello, World"
}
Notes:
- Everything in a
defaults
block is considered a dict, so you can use quoting to create a hierarchy
To call your custom target, run tbh run hello-world
.