DEV Community

Cover image for Build and Deploy: A Shell Script for Hugo Sites
Adam Ormsby
Adam Ormsby

Posted on • Originally published at adamormsby.com on

1

Build and Deploy: A Shell Script for Hugo Sites

Shell Scripts are Awesome

I don’t know about you, but I love writing code that automates my work. I finally had the chance to do just that, and now I’m the proud owner of a shell script that builds and deploys my Hugo site without any extra help from me - and it even works with my git submodule setup!

So why do this? Well, when I began working with Hugo, I ran across a rudimentary deploy script in their documentation. Since I was still familiarizing myself with Hugo, it hadn’t occurred to me yet to automate the build process, and I immediately fell in love with the idea. However, I could see the script would need major improvements to do what I wanted, and I was completely new to shell.

At first, I struggled a bit with writing shell code (especially keeping things POSIX-compliant for portability!), but I stumbled upon the shellscript.sh tutorials and dove straight in. Thanks to those fantastic tutorials, I now have a sweet script to show off and perhaps even a newfound love for the shell. Apart from variable expansion struggles, it’s really quite elegant!

I find my hugo-deploy.sh script does help keep builds following a standard process, but I think I’ll really see it shine when I get around to scheduling automated builds on a remote server. Exciting!

full script at end

Deploy Script Functionality

Be sure to place this script in your site’s root directory if you intend to use it. It’s built with my git submodule setup in mind. But I don’t think it would be too hard to modify it for something different.

Order of Operations

From start to finish, here’s what the script does.

  1. Update the automatic build number (included in the git commit)
  2. Check for any user options (see below)
  3. Check if your active repo branches match your intended deploy branches
    • Production and development branches can both be set
  4. Check if your local repo is up to date (no merge conflicts allowed!)
  5. (Optional) Clear out the site output folder before the Hugo build
  6. Run Hugo build command
  7. Git add, commit, and push to your specified remotes

If any point in this process fails, the script exits with an error message detailing where the issue occurred. You’ll have to fix the issue before running the script again. Thankfully, most of the issues are quick fixes. If you have to do a merge, that’s on you!

‘Settings’ Variables

There are some ‘settings’ variables at the top of the script that you may need to modify to match your project.

# BUILD/DEPLOY SETTINGS - edit as needed for your use case

PUB_SUBMODULE="public" # name of output folder where git submodule is located
IGNORE_FILES=". .. .git CNAME" # space-delimited array of files to protect when 'fresh' option is used
DEV_BRANCHES="dev dev" # development branches to build on and push to, 1-root, 2-pubmodule
PROD_BRANCHES="master master" # production branches to build on and push to, 1-root, 2-pubmodule

Enter fullscreen mode Exit fullscreen mode

PUB_SUBMODULE holds the name of the folder where your public site submodule is located. The default output folder is public, which is where I have my submodule for the live site. If you’ve changed your output folder in the site config, you’ll need to update this to match it. The variable value is relative to the root directory, so if you’ve buried your output folder then set the path accordingly (e.g. my/public/build).

IGNORE_FILES is used with the ‘fresh’ option (see below). It acts as an array of filenames that will not be deleted from the output folder if you select the ‘fresh’ option. It’s mostly to protect non-Hugo files.

PROD_BRANCHES are the two production branches on which you intend to build and push your site data. The first branch name matches the preferred deploy branch for your site source data (in your root directory), and the second branch name matches the preferred deploy branch for your live site (the supposed submodule). These are the values the script uses to check your active branches in step 3 above. It’s a nice safety check.

DEV_BRANCHES works the same way for a development build, which is enabled through the user options.

User Options

Sometimes you need a little customization! That’s where my included script user options come into play. Hopefully these options can help you out a bit.

# Script Options
./hugo-deploy.sh [-d|-f] [-m "COMMIT_MESSAGE"] [-o "HUGO_OPTIONS"]

Enter fullscreen mode Exit fullscreen mode
  • -d ‘dev’ => deploys to development branches set in DEV_BRANCHES list (default is PROD_BRANCHES)
  • -f ‘fresh’ => deletes public directory data before rebuild (skips files in IGNORE_FILES list)
  • -m ‘message’ => appends a custom commit message to the auto-build string, works exactly like git -m
  • -o ‘options’ (for Hugo build) => includes Hugo build options during deploy process (default none), all hugo build options are valid
  • -h => ‘help’
# Usage Example
./hugo-deploy.sh -d -f -m "Deploying like a rockstar!" -o "--cleanDestinationDir"

Enter fullscreen mode Exit fullscreen mode

Fun Stuff: If you rename the script file to hugo-deploy.command, you can run the script with a double click! I haven’t actually tested this, but I think it just runs without options. Try it out!

Disclaimers / Seeking Shell Wisdom

I ran into some issues with this script that I couldn’t totally avoid. Please be aware of them if you try out my script.

  • Variable expansion - I’m aware that the variables I use in my various git commands are not double-quoted. (And just in case I didn’t notice, ShellCheck yelled about them in bright colors.) However, adding quotes or modifying the expansion caused the commands to be incorrectly formatted and resulted in failure. They work as they are now, but I’m curious to know if there’s a way to improve this. I’d love to get advice from experienced shell coders.

  • Limited Testing - I work on Mac OS, so I know this script works on that system - at least on my machine! I have no idea about Windows or Linux. If you try it out, please let me know!

The Full Script

View on Github

#!/bin/sh
# region settings
# BUILD/DEPLOY SETTINGS - edit as needed for your use case
PUB_SUBMODULE="public" # name of output folder where git submodule is located
IGNORE_FILES=". .. .git CNAME" # space-delimited array of files to protect when 'fresh' option is used
DEV_BRANCHES="dev dev" # development branches to build on and push to, 1-root, 2-pubmodule
PROD_BRANCHES="master master" # production branches to build on and push to, 1-root, 2-pubmodule
# endregion
# region script vars
# vars used by script, do no edit
FRESH="false"
HUGO_OPTIONS=""
# console text styles
S_LY="\033[93m"
S_LR="\033[91m"
S_N="\033[m"
S_B="\033[1m"
S_LG="\033[32m"
# endregion
# region script functions
# retrieve build number from build.dat
get_build_data() {
if [ ! -f "build.dat" ]; then
BUILD_NUMBER=1
else
BUILD_NUMBER=$(($(cat build.dat) + 1))
fi
COMMIT_MESSAGE="site build and deploy #${BUILD_NUMBER}"
}
# update build number before operation begins
update_build_data() {
# reset build number on fail
if [ "${1}" = "revert" ]; then
BUILD_NUMBER=$((${BUILD_NUMBER} - 1))
echo "${BUILD_NUMBER}" >build.dat
fi
# store build number in build.dat
echo "${BUILD_NUMBER}" >build.dat || fail_and_exit "warn" "Build number could not be updated for some reason."
}
# make sure the active local branches match the settings (and exit if they don't)
check_branches() {
# set public or base module as active string
if [ "${2}" = "public" ]; then
SUBDIR="-C ${PUB_SUBMODULE}/"
else
SUBDIR=""
fi
# check active branch
CURRENT_BRANCH="git ${SUBDIR} branch --show-current"
if [ "$(${CURRENT_BRANCH})" != "${1}" ]; then
fail_and_exit "warn" "Active ${2} branch does not match deploy settings. Switch public repo to '${1}' branch and run again."
fi
}
# check if branches are up to date with remote (and exit if they aren't)
check_remote_status() {
# set public or base module as active string
if [ "${2}" = "public" ]; then
SUBDIR="-C ${PUB_SUBMODULE}/"
else
SUBDIR=""
fi
git fetch # update local data (no merge)
# get commit hash data from local, remote, and their common ancestor to check branch status
A=$(git ${SUBDIR} rev-parse ${1})
B=$(git ${SUBDIR} rev-parse origin/${1})
C=$(git ${SUBDIR} merge-base ${1} origin/${1})
# compare hashes, only fail if local is out of date or local and remote have diverged
if [ "${A}" = "${B}" ]; then
:
elif [ "${A}" = "${C}" ]; then
fail_and_exit "warn" "Active ${2} branch is out of date. Pull latest and try again."
elif [ "${B}" = "${C}" ]; then
:
else
fail_and_exit "warn" "Active ${2} branch is in a divergent state. Pull, resolve, and try again."
fi
}
# on 'fresh', delete public data before rebuild (ignores files by name from settings 'array')
clear_pub_data() {
# get string of filenames at submodule path
FILE_LIST=$(ls -a "${PUB_SUBMODULE}/")
# remove the ignored filenames from the string list
for i in $(echo "${IGNORE_FILES}" | sed "s/ /\\ /g"); do
FILE_LIST=$(echo "${FILE_LIST}" | sed "s/^${i}$/\\ /g")
done
unset i
# delete remaining files in the filename list
for f in $(echo "${FILE_LIST}" | sed "s/ /\\ /g"); do
rm -r "${PUB_SUBMODULE:?}/${f}"
done
unset f
}
# 'hugo build' plus any optional arguments
build_site() {
hugo "${HUGO_OPTIONS}" || fail_and_exit "hugo"
}
# add, commit, and push, baby!
deploy_to_remote() {
git_add_commit "${PUB_SUBMODULE}" # add and commit files to public module
git_add_commit # add and commit files to base module
# Push base and public submodule data recursively
git push -u origin "${BASE_BRANCH}" --recurse-submodules=on-demand || fail_and_exit "git push to remote"
}
# add and commit steps for each git module
git_add_commit() {
# set public or base module as active string
if [ -n "${1}" ]; then
SUBDIR="-C ${1}/"
WHICH="public"
else
SUBDIR=""
WHICH="base"
fi
# git add all files
git ${SUBDIR} add . || fail_and_exit "git add to ${WHICH} repo"
# git commit with message
git ${SUBDIR} commit -m "${COMMIT_MESSAGE}" || fail_and_exit "git commit to ${WHICH} repo"
}
# add custom commit message (optional)
append_commit() {
COMMIT_MESSAGE="${COMMIT_MESSAGE} - $1"
}
# generic function for setting variables based on script options
set_variable() {
varname=$1
shift
eval "$varname=\"$*\""
}
# exit with a console message on any failed action
fail_and_exit() {
if [ "${1}" = "warn" ]; then
EXIT_LOG=$(printf "%s%s%s" "\n" "${S_B}${S_LY}WARNING:${S_N} ${2}" "\n")
else
EXIT_LOG=$(printf "%s%s%s" "\n" "${S_B}${S_LR}ERROR:${S_N} Deploy process failed during '${1}' step. Fix issues and try again." "\n")
fi
if [ "${1}" = "git commit to public repo" ]; then
EXIT_LOG=$(printf "%s%s%s" "${EXIT_LOG}" "(Note: Commit action will fail if there are no local changes.)" "\n")
fi
echo "${EXIT_LOG}"
update_build_data "revert"
exit 1
}
# usage printout on -h option
usage() {
echo "$(printf "%s%s%s" "\n" "${S_B}${S_LG}USAGE:${S_N} ${0} [-d|-f] [ -m \"COMMIT_MESSAGE\" ] [ -o \"HUGO_OPTIONS\" ]" "\n")"
echo " -d | dev, deploys to development branches set in DEV_BRANCHES list (default is PROD_BRANCHES)"
echo " -f | fresh, deletes public directory data before rebuild (skips files in IGNORE_FILES list)"
echo " -m | message, appends auto-build commit message, works like git -m"
echo " -o | hugo options, includes Hugo build options during deploy process (default none)"
echo " -h | help and usage"
echo "$(printf "%s%s%s" "\n" "${S_B}${S_LG}EXAMPLE:${S_N} ${0} -d -f -m \"Deploying like a rockstar!\" -o \"--cleanDestinationDir\"" "\n")"
exit 2
}
# optional debug mode to view all output, uncomment functional to use and fill with desired tests
debug_mode() {
echo "---TEST MODE---"
set -x # outputs all commands called in script to the console
# fill me out! :)
echo "---TEST COMPLETE---"
exit 0
}
# endregion
# region main script
####################################################################
# Main script starts here
BRANCH_SET="${PROD_BRANCHES}"
# retrieve build number from build.dat and update value before operation begins
get_build_data
update_build_data
# optional arguments, see 'usage' (-h)
while getopts 'dfm:o:h' c; do
case $c in
m) append_commit "${OPTARG}" ;;
d) set_variable BRANCH_SET "${DEV_BRANCHES}" ;;
f) set_variable FRESH "true" ;;
o) set_variable HUGO_OPTIONS "${OPTARG}" ;;
h | *)
update_build_data "revert"
usage ;; esac
done
# optional debug mode to view all output, uncomment to use and fill function with desired tests
# debug_mode
# separate the 'array' of branches into individual strings
BASE_BRANCH=$(echo "${BRANCH_SET}" | cut -d" " -f1)
PUB_BRANCH=$(echo "${BRANCH_SET}" | cut -d" " -f2)
# make sure the active local branches match the settings (and exit if they don't)
check_branches "${BASE_BRANCH}" "base"
check_branches "${PUB_BRANCH}" "public"
# check if branches are up to date with remote (and exit if they aren't)
check_remote_status "${BASE_BRANCH}" "base"
check_remote_status "${PUB_BRANCH}" "public"
# on 'fresh', delete public data before rebuild (ignores files by name from settings 'array')
if [ "${FRESH}" = "true" ]; then
clear_pub_data
fi
# 'hugo build' plus any optional arguments
build_site
# add, commit, and push, baby!
deploy_to_remote
# endregion
view raw hugo-deploy.sh hosted with ❤ by GitHub

Sentry image

See why 4M developers consider Sentry, “not bad.”

Fixing code doesn’t have to be the worst part of your day. Learn how Sentry can help.

Learn more

Top comments (0)