In a previous post, I described how to use Cake.CoreCLR and Cake.Tool versions of Cake to run build scripts on Linux, without requiring Mono. In a follow up post, I provided a PowerShell bootstrapper script equivalent of the bash bootstrapper script.

As I mentioned in my previous post, one of the big advantages of Cake build scripts is that they are cross platform, so the same build script can be run across Windows Linux and Mac. Unfortunately, the bootstrapping scripts are generally platform-specific. Bash scripts are generally pretty portable between Linux distros, but the tiny Alpine Linux is one exception; this uses the Almquist shell (ash) instead.

In this post I provide a shell script version of my original bash script that works on the Alpine ash shell, so can be used with the tiny Alpine-based .NET Core SDK Docker images. This is of particular interest given the reduced Docker image sizes in .NET Core 3. The script installs the Cake .NET Core global tool.

Installing the Cake.Tool global tool locally with ash

The following script uses the same global tool approach from my previous post, but instead of using bash-specific constructs, it uses a script that should work with any POSIX shell, including Alpine's ash shell.

Disclaimer: I'm out of my comfort zone here - with enough Googling I can just about manage bash, but figuring out what is POSIX and what is a "bashism" was a lot of trial and error… If I'm doing anything Bad™, please point it out in the comments!

As in my previous bootstrapper scripts, this assumes you already have .NET Core and the .NET CLI installed - this script only bootstraps the Cake global tool, and runs it. Its primary use (for me) was with the .NET Core SDK Docker Alpine images.

#!/bin/sh

# Define directories.
THIS_SCRIPT=`realpath $0`
SCRIPT_DIR=`dirname $THIS_SCRIPT`
TOOLS_DIR=$SCRIPT_DIR/tools

# Define default arguments.
SCRIPT="build.cake"
CAKE_VERSION="0.33.0"
CAKE_ARGUMENTS=""

# Parse arguments.
for i in "[email protected]"; do
    case $1 in
        -s|--script) SCRIPT="$2"; shift ;;
        --cake-version) CAKE_VERSION="--version=$2"; shift ;;
        --) shift; CAKE_ARGUMENTS="${CAKE_ARGUMENTS} [email protected]"; break ;;
        *) CAKE_ARGUMENTS="${CAKE_ARGUMENTS} $1" ;;
    esac
    shift
done

# Make sure the tools folder exists
if [ ! -d "$TOOLS_DIR" ]; then
    mkdir "$TOOLS_DIR"
fi

CAKE_PATH="$TOOLS_DIR/dotnet-cake"
CAKE_INSTALLED_VERSION=$($CAKE_PATH --version 2>&1)

if [ "$CAKE_VERSION" != "$CAKE_INSTALLED_VERSION" ]; then
    if [ -f "$CAKE_PATH" ]; then
        dotnet tool uninstall Cake.Tool --tool-path "$TOOLS_DIR"
    fi

    echo "Installing Cake $CAKE_VERSION..."
    dotnet tool install Cake.Tool --tool-path "$TOOLS_DIR" --version $CAKE_VERSION

    if [ $? -ne 0 ]; then
        echo "An error occured while installing Cake."
        exit 1
    fi
fi


# Start Cake
eval "$CAKE_PATH" "$SCRIPT" "${CAKE_ARGUMENTS}"

The script starts by checking the installed version of Cake.Tool (if any). If the correct version is not installed, it installs the global tool using dotnet tool install, providing a --tool-path to install the tool locally, instead of globally. After building the script arguments, it runs the tool!

Differences to the bash script

As I've already described, the need for this script arises from "bashisms" in my previous bootstrapper script (and other example scripts). There are essentially three differences between the scripts:

  • Getting the directory of the script being executed
  • Collating command line arguments into an array
  • Executing Cake using the provided arguments

I'll briefly compare each of these below:

Getting the directory of the script being executed

In my original bash script, I used the ${BASH_SOURCE[0]} variable, which contains the location of the script "in all scenarios":

SCRIPT_DIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )

This is (unsurprisingly) bash-specific, so, after a lot of searching, I used a different approach for the ash shell:

THIS_SCRIPT=`realpath $0`
SCRIPT_DIR=`dirname $THIS_SCRIPT`

There were a lot of different ways to do this suggested, some of which required additional tools not available in ash by default, but this one seemed to work. As far as I can see, the following should also work for our purposes, which is a more direct analogue. I don't know which is "better" or more idiomatic though! 🤷

SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)

Collating command line arguments into an array

The next difference occurs where we're parsing the arguments passed to the script. We want to extract specific arguments like the --cake-version and --script, and collect all the other arguments for passing to the Cake invocation later.

In bash, we can use an array to hold those arguments, extract the special values, and append the other arguments to the array:

# Create an array
CAKE_ARGUMENTS=()

for i in "[email protected]"; do
    case $1 in
        -s|--script) SCRIPT="$2"; shift ;;
        --cake-version) CAKE_VERSION="--version=$2"; shift ;;
        --) shift; CAKE_ARGUMENTS+=("[email protected]"); break ;; # Push all remaining args into array
        *) CAKE_ARGUMENTS+=("$1") ;; # Push next arg into array
    esac
    shift
done

Unfortunately, we can't use arrays like this in ash. The easiest approach I could find to "simulate" an array was to use a space-separated string:

# Empty string for holding the arguments
CAKE_ARGUMENTS=""

for i in "[email protected]"; do
    case $1 in
        -s|--script) SCRIPT="$2"; shift ;;
        --cake-version) CAKE_VERSION="--version=$2"; shift ;;
        --) shift; CAKE_ARGUMENTS="${CAKE_ARGUMENTS} [email protected]"; break ;; # Append all remaining to string
        *) CAKE_ARGUMENTS="${CAKE_ARGUMENTS} $1" ;; # Append to string
    esac
    shift
done

This approach is almost certainly not quite right, but I've found it worked fine for my attempts. I haven't tested it with arguments that contain spaces, or quote marks, so your mileage may vary!

Executing Cake using the provided arguments

This is the part of the script that I'm sure highlights that I don't really know what I'm doing…

In the bash version of the script, we can invoke Cake using exec, and output all of the values stored in the args array using [@]:

exec "$CAKE_PATH" "$SCRIPT" "${CAKE_ARGUMENTS[@]}"

Unfortunately, if we try to use exec with our space-separated argument list in ash, we won't invoke Cake with multiple arguments, we invoke it with a single argument (that has lots of spaces in it!) I couldn't find a way to solve this issue, so I resorted to using eval.

eval "$CAKE_PATH" "$SCRIPT" "${CAKE_ARGUMENTS}"

eval converts the provided arguments to a raw string, then executes it as though you'd written that directly. That works great for my requirements, but it can be dangerous, so is generally frowned upon. Again, it's fine for my purposes (running in Alpine Docker), but if you have a better (safer) alternative for achieving the same thing, do let me know!

Summary

In this post I show an Almquist shell (ash) bootstrapping script for running Cake build scripts using the Cake.Tool .NET Core global tool on Alpine Linux. The script is mostly similar to the bash bootstrapping scripts that I've provided previously, but avoids bashisms that prevent those scripts running on some shells. The script has a couple of issues (namely the use of eval) but it meets my needs, so hopefully it's useful for you too.