#!/usr/bin/env bash
# Copyright (c) HashiCorp, Inc.
# SPDX-License-Identifier: MPL-2.0
#
# shellcheck disable=SC2119
# shellcheck disable=SC2164

# If the bash version isn't at least 4, bail
if [ "${BASH_VERSINFO:-0}" -lt "4" ]; then
    printf "ERROR: Expected bash version >= 4 (is: %d)" "${BASH_VERSINFO:-0}"
    exit 1
fi

# Lets have some emojis
WARNING_ICON="⚠️"
ERROR_ICON="🛑"

# Coloring
# shellcheck disable=SC2034
TEXT_BOLD='\e[1m'
TEXT_RED='\e[31m'
# shellcheck disable=SC2034
TEXT_GREEN='\e[32m'
TEXT_YELLOW='\e[33m'
TEXT_CYAN='\e[36m'
TEXT_CLEAR='\e[0m'

# Common variables
export full_sha="${GITHUB_SHA}"
export short_sha="${full_sha:0:8}"
export ident_ref="${GITHUB_REF#*/*/}"
export repository="${GITHUB_REPOSITORY}"
export repo_owner="${repository%/*}"
export repo_name="${repository#*/}"
# shellcheck disable=SC2153
export asset_cache="${ASSETS_PRIVATE_SHORTTERM}/${repository}/${GITHUB_ACTION}"
export run_number="${GITHUB_RUN_NUMBER}"
export run_id="${GITHUB_RUN_ID}"
export job_id="${run_id}-${run_number}"
readonly hc_releases_metadata_filename="release-meta.json"
# This value is used in our cleanup trap to restore the value in cases
# where a function call may have failed and did not restore it
readonly _repository_backup="${repository}"

if [ -z "${ci_bin_dir}" ]; then
    if ci_bin_dir="$(realpath ./.ci-bin)"; then
        export ci_bin_dir
    else
        echo "ERROR: Failed to create the local CI bin directory"
        exit 1
    fi
fi

# We are always noninteractive
export DEBIAN_FRONTEND=noninteractive

# If we are on a runner and debug mode is enabled,
# enable debug mode for ourselves too
if [ -n "${RUNNER_DEBUG}" ]; then
    DEBUG=1
fi

# If DEBUG is enabled and we are running tests,
# flag it so we can adjust where output is sent.
if [ -n "${DEBUG}" ] && [ -n "${BATS_TEST_FILENAME}" ]; then
    DEBUG_WITH_BATS=1
fi

# Write debug output to stderr. Message template
# and arguments are passed to `printf` for formatting.
#
# $1: message template
# $#: message arguments
#
# NOTE: Debug output is only displayed when DEBUG is set
function debug() {
    if [ -n "${DEBUG}" ]; then
        local msg_template="${1}"
        local i=$(( ${#} - 1 ))
        local msg_args=("${@:2:$i}")
        # Update template to include caller information
        msg_template=$(printf "<%s(%s:%d)> %s" "${FUNCNAME[1]}" "${BASH_SOURCE[1]}" "${BASH_LINENO[0]}" "${msg_template}")
        #shellcheck disable=SC2059
        msg="$(printf "${msg_template}" "${msg_args[@]}")"

        if [ -n "${DEBUG_WITH_BATS}" ]; then
            printf "%b%s%b\n" "${TEXT_CYAN}" "${msg}" "${TEXT_CLEAR}" >&3
        else
            printf "%b%s%b\n" "${TEXT_CYAN}" "${msg}" "${TEXT_CLEAR}" >&2
        fi
    fi
}

# Wrap the pushd command so we fail
# if the pushd command fails. Arguments
# are just passed through.
function pushd() {
    debug "executing 'pushd %s'" "${*}"
    command builtin pushd "${@}" > /dev/null || exit 1
}

# Wrap the popd command so we fail
# if the popd command fails. Arguments
# are just passed through.
# shellcheck disable=SC2120
function popd() {
    debug "executing 'popd %s'" "${*}"
    command builtin popd "${@}" || exit 1
}

# Wraps the aws CLI command to support
# role based access. It will check for
# expected environment variables when
# a role has been assumed. If they are
# not found, it will assume the configured
# role. If the role has already been
# assumed, it will check that the credentials
# have not timed out, and re-assume the
# role if so. If no role information is
# provided, it will just pass the command
# through directly
#
# NOTE: Required environment variable: AWS_ASSUME_ROLE_ARN
# NOTE: This was a wrapper for the AWS command that would properly
#       handle the assume role process and and automatically refresh
#       if close to expiry. With credentials being handled by the doormat
#       action now, this is no longer needed but remains in case it's
#       needed for some reason in the future.
function aws_deprecated() {
    # Grab the actual aws cli path
    if ! aws_path="$(which aws)"; then
        (>&2 echo "AWS error: failed to locate aws cli executable")
        return 1
    fi
    # First, check if the role ARN environment variable is
    # configured. If it is not, just pass through.
    if [ "${AWS_ASSUME_ROLE_ARN}" = "" ]; then
        "${aws_path}" "${@}"
        return $?
    fi
    # Check if a role has already been assumed. If it
    # has, validate the credentials have not timed out
    # and pass through.
    if [ "${AWS_SESSION_TOKEN}" != "" ]; then
        # Cut off part of the expiration so we don't end up hitting
        # the expiration just as we make our call
        expires_at=$(date -d "${AWS_SESSION_EXPIRATION} - 20 sec" "+%s")
        if (( "${expires_at}" > $(date +%s) )); then
            "${aws_path}" "${@}"
            return $?
        fi
        # If we are here then the credentials were not
        # valid so clear the session token and restore
        # original credentials
        unset AWS_SESSION_TOKEN
        unset AWS_SESSION_EXPIRATION
        export AWS_ACCESS_KEY_ID="${CORE_AWS_ACCESS_KEY_ID}"
        export AWS_SECRET_ACCESS_KEY="${CORE_AWS_SECRET_ACCESS_KEY}"
    fi
    # Now lets assume the role
    if aws_output="$("${aws_path}" sts assume-role --role-arn "${AWS_ASSUME_ROLE_ARN}" --role-session-name "VagrantCI@${repo_name}-${job_id}")"; then
        export CORE_AWS_ACCESS_KEY_ID="${AWS_ACCESS_KEY_ID}"
        export CORE_AWS_SECRET_ACCESS_KEY="${AWS_SECRET_ACCESS_KEY}"
        id="$(printf '%s' "${aws_output}" | jq -r .Credentials.AccessKeyId)" || failed=1
        key="$(printf '%s' "${aws_output}" | jq -r .Credentials.SecretAccessKey)" || failed=1
        token="$(printf '%s' "${aws_output}" | jq -r .Credentials.SessionToken)" || failed=1
        expire="$(printf '%s' "${aws_output}" | jq -r .Credentials.Expiration)" || failed=1
        if [ "${failed}" = "1" ]; then
            (>&2 echo "Failed to extract assume role credentials")
            return 1
        fi
        unset aws_output
        export AWS_ACCESS_KEY_ID="${id}"
        export AWS_SECRET_ACCESS_KEY="${key}"
        export AWS_SESSION_TOKEN="${token}"
        export AWS_SESSION_EXPIRATION="${expire}"
    else
        (>&2 echo "AWS assume role error: ${aws_output}")
        return 1
    fi
    # And we can execute!
    "${aws_path}" "${@}"
}

# Path to file used for output redirect
# and extracting messages for warning and
# failure information sent to slack
function output_file() {
    if [ "${1}" = "clean" ] && [ -f "${ci_output_file_path}" ]; then
        rm -f "${ci_output_file_path}"
        unset ci_output_file_path
    fi
    if [ -z "${ci_output_file_path}" ] || [ ! -f "${ci_output_file_path}" ]; then
        ci_output_file_path="$(mktemp)"
    fi

    printf "%s" "${ci_output_file_path}"
}

# Write failure message, send error to configured
# slack, and exit with non-zero status. If an
# "$(output_file)" file exists, the last 5 lines will be
# included in the slack message.
#
# $1: Failure message
function failure() {
    local msg_template="${1}"
    local i=$(( ${#} - 1 ))
    local msg_args=("${@:2:$i}")

    # Update template to include caller information if in DEBUG mode
    if [ -n "${DEBUG}" ]; then
        msg_template=$(printf "<%s(%s:%d)> %s" "${FUNCNAME[1]}" "${BASH_SOURCE[1]}" "${BASH_LINENO[0]}" "${msg_template}")
    fi
    #shellcheck disable=SC2059
    msg="$(printf "${msg_template}" "${msg_args[@]}")"

    if [ -n "${DEBUG_WITH_BATS}" ]; then
        printf "%s %b%s%b\n" "${ERROR_ICON}" "${TEXT_RED}" "${msg}" "${TEXT_CLEAR}" >&3
    else
        printf "%s %b%s%b\n" "${ERROR_ICON}" "${TEXT_RED}" "${msg}" "${TEXT_CLEAR}" >&2
    fi

    if [ -n "${SLACK_WEBHOOK}" ]; then
        if [ -f "$(output_file)" ]; then
            slack -s error -m "ERROR: ${msg}" -f "$(output_file)" -T 5
        else
            slack -s error -m "ERROR: ${msg}"
        fi
    fi
    exit 1
}

# Write warning message, send warning to configured
# slack
#
# $1: Warning message
function warn() {
    local msg_template="${1}"
    local i=$(( ${#} - 1 ))
    local msg_args=("${@:2:$i}")

    #shellcheck disable=SC2059
    msg="$(printf "${msg_template}" "${msg_args[@]}")"

    printf "%s %b%s%b\n" "${WARNING_ICON}" "${TEXT_YELLOW}" "${msg}" "${TEXT_CLEAR}" >&2

    if [ -n "${SLACK_WEBHOOK}" ]; then
        if [ -f "$(output_file)" ]; then
            slack -s warn -m "WARNING: ${msg}" -f "$(output_file)"
        else
            slack -s warn -m "WARNING: ${msg}"
        fi
    fi
}

# Write an informational message
function info() {
    local msg_template="${1}\n"
    local i=$(( ${#} - 1 ))
    local msg_args=("${@:2:$i}")

    #shellcheck disable=SC2059
    printf "${msg_template}" "${msg_args[@]}" >&2
}

# Execute command while redirecting all output to
# a file (file is used within fail mesage on when
# command is unsuccessful). Final argument is the
# error message used when the command fails.
#
# $@{1:$#-1}: Command to execute
# $@{$#}: Failure message
function wrap() {
    local i=$((${#} - 1))
    if ! wrap_raw "${@:1:$i}"; then
        cat "$(output_file)"
        failure "${@:$#}"
    fi
    rm "$(output_file)"
}

# Execute command while redirecting all output to
# a file. Exit status is returned.
function wrap_raw() {
    output_file "clean" > /dev/null 2>&1
    "${@}" > "$(output_file)" 2>&1
    return $?
}

# Execute command while redirecting all output to
# a file (file is used within fail mesage on when
# command is unsuccessful). Command output will be
# streamed during execution. Final argument is the
# error message used when the command fails.
#
# $@{1:$#-1}: Command to execute
# $@{$#}: Failure message
function wrap_stream() {
    i=$((${#} - 1))
    if ! wrap_stream_raw "${@:1:$i}"; then
        failure "${@:$#}"
    fi
    rm "$(output_file)"
}

# Execute command while redirecting all output
# to a file. Command output will be streamed
# during execution. Exit status is returned
function wrap_stream_raw() {
    output_file "clean"
    "${@}" > "$(output_file)" 2>&1 &
    pid=$!
    until [ -f "$(output_file)" ]; do
        sleep 0.1
    done
    tail -f --quiet --pid "${pid}" "$(output_file)"
    wait "${pid}"
    return $?
}


# Send command to packet device and wrap
# execution
# $@{1:$#-1}: Command to execute
# $@{$#}: Failure message
function pkt_wrap() {
    wrap packet-exec run -quiet -- "${@}"
}

# Send command to packet device and wrap
# execution
# $@: Command to execute
function pkt_wrap_raw() {
    wrap_raw packet-exec run -quiet -- "${@}"
}

# Send command to packet device and wrap
# execution with output streaming
# $@{1:$#-1}: Command to execute
# $@{$#}: Failure message
function pkt_wrap_stream() {
    wrap_stream packet-exec run -quiet -- "${@}"
}

# Send command to packet device and wrap
# execution with output streaming
# $@: Command to execute
function pkt_wrap_stream_raw() {
    wrap_stream_raw packet-exec run -quiet -- "${@}"
}

# Get the full path directory for a given
# file path. File is not required to exist.
# NOTE: Parent directories of given path will
#       be created.
#
# $1: file path
function file_directory() {
    local path="${1?File path is required}"
    local dir
    if [[ "${path}" != *"/"* ]]; then
        dir="."
    else
        dir="${path%/*}"
    fi
    if [ ! -d "${dir}" ]; then
        mkdir -p "${dir}" ||
            failure "Could not create directory (%s)" "${dir}"
    fi
    pushd "${dir}"
    dir="$(pwd)" ||
        failure "Could not read directory path (%s)" "${dir}"
    popd
    printf "%s" "${dir}"
}

# Wait until the number of background jobs falls below
# the maximum number provided. If the max number was reached
# and waiting was performed until a process completed, the
# string "waited" will be printed to stdout.
#
# NOTE: using `wait -n` would be cleaner but only became
#       available in bash as of 4.3
#
# $1: maximum number of jobs
function background_jobs_limit() {
    local max="${1}"
    if [ -z "${max}" ] || [[ "${max}" = *[!0123456789]* ]]; then
        failure "Maximum number of background jobs required"
    fi

    local debug_printed
    local jobs
    mapfile -t jobs <<< "$(jobs -p)" ||
        failure "Could not read background job list"
    while [ "${#jobs[@]}" -ge "${max}" ]; do
        if [ -z "${debug_printed}" ]; then
            debug "max background jobs reached (%d), waiting for free process" "${max}"
            debug_printed="1"
        fi
        sleep 1
        jobs=()
        local j_pids
        mapfile -t j_pids <<< "$(jobs -p)" ||
            failure "Could not read background job list"
        for j in "${j_pids[@]}"; do
            if kill -0 "${j}" > /dev/null 2>&1; then
                jobs+=( "${j}" )
            fi
        done
    done
    if [ -n "${debug_printed}" ]; then
        debug "background jobs count (%s) under max, continuing" "${#jobs[@]}"
        printf "waited"
    fi
}

# Reap a completed background process. If the process is
# not complete, the process is ignored. The success/failure
# returned from this function only applies to the process
# identified by the provided PID _if_ the matching PID value
# was written to stdout
#
# $1: PID
function reap_completed_background_job() {
    local pid="${1}"
    if [ -z "${pid}" ]; then
        failure "PID of process to reap is required"
    fi
    if kill -0 "${pid}" > /dev/null 2>&1; then
        debug "requested pid to reap (%d) has not completed, ignoring" "${pid}"
        return 0
    fi
    # The pid can be reaped so output the pid to indicate
    # any error is from the job
    printf "%s" "${pid}"
    if ! wait "${pid}"; then
        local code="${?}"
        debug "wait error code %d returned for pid %d" "${code}" "${pid}"
        return "${code}"
    fi

    return 0
}

# Creates a cache and adds the provided items
#
# -d Optional description
# -f Force cache (deletes cache if already exists)
#
# $1: name of cache
# $2: artifact(s) to cache (path to artifact or directory containing artifacts)
function create-cache() {
    local body
    local force
    local opt
    while getopts ":d:f" opt; do
        case "${opt}" in
            "d") body="${OPTARG}" ;;
            "f") force="1" ;;
            *) failure "Invalid flag provided" ;;
        esac
    done
    shift $((OPTIND-1))

    cache_name="${1}"
    artifact_path="${2}"

    if [ -z "${cache_name}" ]; then
        failure "Cache name is required"
    fi
    if [ -z "${artifact_path}" ]; then
        failure "Artifact path is required"
    fi

    # Check for the cache
    if github_draft_release_exists "${repo_name}" "${cache_name}"; then
        # If forcing, delete the cache
        if [ -n "${force}" ]; then
            debug "cache '%s' found and force is set, removing"
            github_delete_draft_release "${cache_name}"
        else
            failure "Cache already exists (name: %s repo: %s)" "${cache_name}" "${repo_name}"
        fi
    fi

    # If no description is provided, then provide a default
    if [ -z "${body}" ]; then
        body="Cache name: %s\nCreate time: %s\nSource run: %s/%s/actions/runs/%s" \
            "${cache_name}" "$(date)" "${GITHUB_SERVER_URL}" "${GITHUB_REPOSITORY}" "${GITHUB_RUN_ID}"
    fi

    # Make sure body is formatted
    if [ -n "${body}" ]; then
        body="$(printf "%b" "${body}")"
    fi

    response="$(github_create_release -o "${repo_owner}" -r "${repo_name}" -n "${cache_name}" -b "${body}")" ||
        failure "Failed to create GitHub release"
}

# Retrieve items from cache
#
# -r Require cache to exist (failure if not found)
#
# $1: cache name
# $2: destination directory
function restore-cache() {
    local required

    while getopts ":r" opt; do
        case "${opt}" in
            "r") required="1" ;;
            *) failure "Invalid flag provided" ;;
        esac
    done
    shift $((OPTIND-1))

    cache_name="${1}"
    destination="${2}"

    if [ -z "${cache_name}" ]; then
        failure "Cache name is required"
    fi
    if [ -z "${destination}" ]; then
        failure "Destination is required"
    fi

    # If required, check for the draft release and error if not found
    if [ -n "${required}" ]; then
        if ! github_draft_release_exists "${repo_name}" "${cache_name}"; then
            failure "Cache '%s' does not exist" "${cache_name}"
        fi
    fi

    mkdir -p "${destination}" ||
        failure "Could not create destination directory (%s)" "${destination}"

    pushd "${destination}"
    github_draft_release_assets "${repo_name}" "${cache_name}"
    popd
}

# Submit given file to Apple's notarization service and
# staple the notarization ticket.
#
# -i UUID: app store connect issuer ID (optional)
# -j PATH: JSON file containing API key
# -k ID:   app store connect API key ID (optional)
# -m SECS: maximum number of seconds to wait (optional, defaults to 600)
# -o PATH: path to write notarized file (optional, will modify input by default)
#
# $1: file to notarize
function notarize_file() {
    local creds_api_key_id
    local creds_api_key_path
    local creds_issuer_id
    local output_file
    local max_wait="600"

    local opt
    while getopts ":i:j:k:m:o:" opt; do
        case "${opt}" in
            "i") creds_api_key_id="${OPTARG}" ;;
            "j") creds_api_key_path="${OPTARG}" ;;
            "k") creds_issuer_id="${OPTARG}" ;;
            "m") max_wait="${OPTARG}" ;;
            "o") output_file="${OPTARG}" ;;
            *) failure "Invalid flag provided" ;;
        esac
    done
    shift $((OPTIND-1))

    # Validate credentials were provided
    if [ -z "${creds_api_key_path}" ]; then
        failure "App store connect key path required for notarization"
    fi
    if [ ! -f "${creds_api_key_path}" ]; then
        failure "Invalid path provided for app store connect key path (%s)" "${creds_api_key_path}"
    fi

    # Collect auth related arguments
    local base_args=( "--api-key-path" "${creds_api_key_path}" )
    if [ -n "${creds_api_key_id}" ]; then
        base_args+=( "--api-key" "${creds_api_key_id}" )
    fi
    if [ -n "${creds_issuer_id}" ]; then
        base_args+=( "--api-issuer" "${creds_issuer_id}" )
    fi

    local input_file="${1}"

    # Validate the input file
    if [ -z "${input_file}" ]; then
        failure "Input file is required for signing"
    fi
    if [ ! -f "${input_file}" ]; then
        failure "Cannot find input file (%s)" "${input_file}"
    fi

    # Check that rcodesign is available, and install
    # it if it is not
    if ! command -v rcodesign > /dev/null; then
        debug "rcodesign executable not found, installing..."
        install_github_tool "indygreg" "apple-platform-rs" "rcodesign"
    fi
    
    local notarize_file
    # If an output file path was defined, copy file
    # to output location before notarizing
    if [ -n "${output_file}" ]; then
        file_directory "${output_file}"
        # Remove file if it already exists
        rm -f "${output_file}" ||
            failure "Could not modify output file (%s)" "${output_file}"
        cp -f "${input_file}" "${output_file}" ||
            failure "Could not write to output file (%s)" "${output_file}"
        notarize_file="${output_file}"
        debug "notarizing file '%s' and writing to '%s'" "${input_file}" "${output_file}"
    else
        notarize_file="${input_file}"
        debug "notarizing file in place '%s'" "${input_file}"
    fi

    # Notarize the file
    local notarize_output
    if notarize_output="$(rcodesign \
        notary-submit \
        "${base_args[@]}" \
        --max-wait-seconds "${max_wait}" \
        --staple \
        "${notarize_file}" 2>&1)"; then
        return 0
    fi

    debug "notarization output: %s" "${notarize_output}"

    # Still here means notarization failure. Pull
    # the logs from the service before failing
    local submission_id="${notarize_output##*submission ID: }"
    submission_id="${submission_id%%$'\n'*}"
    rcodesign \
        notary-log \
        "${base_args[@]}" \
        "${submission_id}"

    failure "Failed to notarize file (%s)" "${input_file}"
}

# Sign a file using signore. Will automatically apply
# modified retry settings when larger files are submitted.
#
# -b NAME: binary identifier (macOS only)
# -e PATH: path to entitlements file (macOS only)
# -o PATH: path to write signed file (optional, will overwrite input by default)
# $1: file to sign
#
# NOTE: If signore is not installed, a HASHIBOT_TOKEN is
#       required for downloading the signore release. The
#       token can also be set in SIGNORE_GITHUB_TOKEN if
#       the HASHIBOT_TOKEN is already set
#
# NOTE: SIGNORE_CLIENT_ID, SIGNORE_CLIENT_SECRET, and SIGNORE_SIGNER
#       environment variables must be set prior to calling this function
function sign_file() {
    # Set 50M to be a largish file
    local largish_file_size="52428800"

    # Signore environment variables are required. Check
    # that they are set.
    if [ -z "${SIGNORE_CLIENT_ID}" ]; then
        failure "Cannot sign file, SIGNORE_CLIENT_ID is not set"
    fi
    if [ -z "${SIGNORE_CLIENT_SECRET}" ]; then
        failure "Cannot sign file, SIGNORE_CLIENT_SECRET is not set"
    fi
    if [ -z "${SIGNORE_SIGNER}" ]; then
        failure "Cannot sign file, SIGNORE_SIGNER is not set"
    fi

    local binary_identifier=""
    local entitlements=""
    local output_file=""

    local opt
    while getopts ":b:e:o:" opt; do
        case "${opt}" in
            "b") binary_identifier="${OPTARG}" ;;
            "e") entitlements="${OPTARG}" ;;
            "o") output_file="${OPTARG}" ;;
            *) failure "Invalid flag provided" ;;
        esac
    done
    shift $((OPTIND-1))

    local input_file="${1}"

    # Check that a good input file was given
    if [ -z "${input_file}" ]; then
        failure "Input file is required for signing"
    fi
    if [ ! -f "${input_file}" ]; then
        failure "Cannot find input file (%s)" "${input_file}"
    fi

    # If the output file is not set it's a replacement
    if [ -z "${output_file}" ]; then
        debug "output file is unset, will replace input file (%s)" "${input_file}"
        output_file="${input_file}"
    fi

    # This will ensure parent directories exist
    file_directory "${output_file}" > /dev/null

    # If signore command is not installed, install it
    if ! command -v "signore" > /dev/null; then
        local hashibot_token_backup="${HASHIBOT_TOKEN}"
        # If the signore github token is set, apply it
        if [ -n "${SIGNORE_GITHUB_TOKEN}" ]; then
            HASHIBOT_TOKEN="${SIGNORE_GITHUB_TOKEN}"
        fi

        install_hashicorp_tool "signore"

        # Restore the hashibot token if it was modified
        HASHIBOT_TOKEN="${hashibot_token_backup}"
    fi

    # Define base set of arguments
    local signore_args=( "sign" "--file" "${input_file}" "--out" "${output_file}" "--match-file-mode" )

    # Check the size of the file to be signed. If it's relatively
    # large, push up the max retries and lengthen the retry interval
    # NOTE: Only checked if `wc` is available
    local file_size="0"
    if command -v wc > /dev/null; then
        file_size="$(wc -c <"${input_file}")" ||
            failure "Could not determine input file size"
    fi

    if [ "${file_size}" -gt "${largish_file_size}" ]; then
        debug "largish file detected, adjusting retry settings"
        signore_args+=( "--max-retries" "30" "--retry-interval" "10s" )
    fi

    # If a binary identifier was provided then it's a macos signing
    if [ -n "${binary_identifier}" ]; then
        # shellcheck disable=SC2016
        template='{type: "macos", input_format: "EXECUTABLE", binary_identifier: $identifier}'
        payload="$(jq -n --arg identifier "${binary_identifier}" "${template}")" ||
            failure "Could not create signore payload for macOS signing"
        signore_args+=( "--signer-options" "${payload}" )
    fi

    # If an entitlement was provided, validate the path
    # and add it to the args
    if [ -n "${entitlements}" ]; then
        if [ ! -f "${entitlements}" ]; then
            failure "Invalid path for entitlements provided (%s)" "${entitlements}"
        fi
        signore_args+=( "--entitlements" "${entitlements}" )
    fi

    debug "signing file '%s' with arguments - %s" "${input_file}" "${signore_args[*]}"

    signore "${signore_args[@]}" ||
        failure "Failed to sign file '%s'" "${input_file}"

    info "successfully signed file (%s)" "${input_file}"
}

# Create a GPG signature. This uses signore to generate a
# gpg signature for a given file. If the destination
# path for the signature is not provided, it will
# be stored at the origin path with a .sig suffix
#
# $1: Path to origin file
# $2: Path to store signature (optional)
function gpg_sign_file() {
    # Check that we have something to sign
    if [ -z "${1}" ]; then
        failure "Origin file is required for signing"
    fi

    if [ ! -f "${1}" ]; then
        failure "Origin file does not exist (${1})"
    fi

    # Validate environment has required signore variables set
    if [ -z "${SIGNORE_CLIENT_ID}" ]; then
        failure "Cannot sign file, SIGNORE_CLIENT_ID is not set"
    fi
    if [ -z "${SIGNORE_CLIENT_SECRET}" ]; then
        failure "Cannot sign file, SIGNORE_CLIENT_SECRET is not set"
    fi
    if [ -z "${SIGNORE_SIGNER}" ]; then
        failure "Cannot sign file, SIGNORE_SIGNER is not set"
    fi

    local origin="${1}"
    local destination="${2}"
    if [ -z "${destination}" ]; then
        destination="${origin}.sig"
        debug "destination automatically set (%s)" "${destination}"
    fi

    if ! command -v signore; then
        debug "installing signore tool"
        install_hashicorp_tool "signore"
    fi

    if [ -e "${destination}" ]; then
        failure "File already exists at signature destination path (${destination})"
    fi

    wrap_stream signore sign --dearmor --file "${origin}" --out "${destination}" \
        "Failed to sign file"
}

# Validate arguments for GitHub release. Checks for
# two arguments and that second argument is an exiting
# file asset, or directory.
#
# $1: GitHub tag name
# $2: Asset file or directory of assets
function release_validate() {
    if [ "${1}" = "" ]; then
        failure "Missing required position 1 argument (TAG) for release"
    fi
    if [ "${2}" = "" ]; then
        failure "Missing required position 2 argument (PATH) for release"
    fi
    if [ ! -e "${2}" ]; then
        failure "Path provided for release (${2}) does not exist"
    fi
}

# Generate a GitHub release
#
# $1: GitHub tag name
# $2: Asset file or directory of assets
function release() {
    release_validate "${@}"
    local tag_name="${1}"
    local assets="${2}"
    local body

    if [ -z "${body}" ]; then
        body="$(release_details "${tag_name}")"
    fi

    response="$(github_create_release -o "${repo_owner}" -r "${repo_name}" -t "${tag_name}" -n "${tag_name}" -b "${body}")" ||
        failure "Failed to create GitHub release"
    local release_id
    release_id="$(printf "%s" "${response}" | jq -r '.id')" ||
        failure "Failed to extract release ID from response for %s on %s" "${tag_name}" "${repository}"

    github_upload_release_artifacts "${repo_name}" "${release_id}" "${assets}"
}

# Generate a GitHub prerelease
#
# $1: GitHub tag name
# $2: Asset file or directory of assets
function prerelease() {
    release_validate "${@}"
    local ptag
    if [[ "${1}" != *"+"* ]]; then
        ptag="${1}+${short_sha}"
    else
        ptag="${1}"
    fi
    local assets="${2}"

    response="$(github_create_release -o "${repo_owner}" -r "${repo_name}" -t "${ptag}" -n "${ptag}" -b "${body}" -p -m)" ||
        failure "Failed to create GitHub prerelease"
    local release_id
    release_id="$(printf "%s" "${response}" | jq -r '.id')" ||
        failure "Failed to extract prerelease ID from response for %s on %s" "${tag_name}" "${repository}"

    github_upload_release_artifacts "${repo_name}" "${release_id}" "${assets}"

    printf "New prerelease published to %s @ %s\n" "${repo_name}" "${ptag}" >&2

    printf "%s" "${ptag}"
}

# Generate a GitHub draft release
#
# $1: GitHub release name
# $2: Asset file or directory of assets
function draft_release() {
    local ptag="${1}"
    local assets="${2}"

    response="$(github_create_release -o "${repo_owner}" -r "${repo_name}" -t "${ptag}" -n "${ptag}" -b "${body}" -d)" ||
        failure "Failed to create GitHub draft release"
    local release_id
    release_id="$(printf "%s" "${response}" | jq -r '.id')" ||
        failure "Failed to extract draft release ID from response for %s on %s" "${tag_name}" "${repository}"

    github_upload_release_artifacts "${repo_name}" "${release_id}" "${assets}"

    printf "%s" "${ptag}"
}


# Generate details of the release. This will consist
# of a link to the changelog if we can properly detect
# it based on current location.
#
# $1: Tag name
#
# Returns: details content
function release_details() {
    local tag_name="${1}"
    local proj_root
    if ! proj_root="$(git rev-parse --show-toplevel)"; then
        return
    fi
    if [ -z "$(git tag -l "${tag_name}")" ] || [ ! -f "${proj_root}/CHANGELOG.md" ]; then
        return
    fi
    printf "CHANGELOG:\n\nhttps://github.com/%s/blob/%s/CHANGELOG.md" "${repository}" "${tag_name}"
}

# Check if version string is valid for release
#
# $1: Version
# Returns: 0 if valid, 1 if invalid
function valid_release_version() {
    if [[ "${1}" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
        return 0
    else
        return 1
    fi
}

# Validate arguments for HashiCorp release. Ensures asset
# directory exists, and checks that the SHASUMS and SHASUM.sig
# files are present.
#
# $1: Asset directory
function hashicorp_release_validate() {
    local directory="${1}"
    local sums
    local sigs

    # Directory checks
    debug "checking asset directory was provided"
    if [ -z "${directory}" ]; then
        failure "No asset directory was provided for HashiCorp release"
    fi
    debug "checking that asset directory exists"
    if [ ! -d "${directory}" ]; then
        failure "Asset directory for HashiCorp release does not exist (${directory})"
    fi

    # SHASUMS checks
    debug "checking for shasums file"
    sums=("${directory}/"*SHA256SUMS)
    if [ ${#sums[@]} -lt 1 ]; then
        failure "Asset directory is missing SHASUMS file"
    fi
    debug "checking for shasums signature file"
    sigs=("${directory}/"*SHA256SUMS.sig)
    if [ ${#sigs[@]} -lt 1 ]; then
        failure "Asset directory is missing SHASUMS signature file"
    fi
}

# Verify release assets by validating checksum properly match
# and that signature file is valid
#
# $1: Asset directory
function hashicorp_release_verify() {
    if [ -z "${HASHICORP_PUBLIC_GPG_KEY_ID}" ]; then
        failure "Cannot verify release without GPG key ID. Set HASHICORP_PUBLIC_GPG_KEY_ID."
    fi

    local directory="${1}"
    local gpghome

    pushd "${directory}"

    # First do a checksum validation
    debug "validating shasums are correct"
    wrap shasum -a 256 -c ./*_SHA256SUMS \
        "Checksum validation of release assets failed"
    # Next check that the signature is valid
    gpghome=$(mktemp -qd)
    export GNUPGHOME="${gpghome}"
    debug "verifying shasums signature file using key: %s" "${HASHICORP_PUBLIC_GPG_KEY_ID}"
    wrap gpg --keyserver keyserver.ubuntu.com --recv "${HASHICORP_PUBLIC_GPG_KEY_ID}" \
        "Failed to import HashiCorp public GPG key"
    wrap gpg --verify ./*SHA256SUMS.sig ./*SHA256SUMS \
        "Validation of SHA256SUMS signature failed"
    rm -rf "${gpghome}"
    popd
}

# Generate releases-api metadata
#
# $1: Product Version
# $2: Asset directory
function hashicorp_release_generate_release_metadata() {
    local version="${1}"
    local directory="${2}"

    if ! command -v bob; then
        debug "bob executable not found, installing"
        install_hashicorp_tool "bob"
    fi

    local hc_releases_input_metadata="input-meta.json"
    # The '-metadata-file' flag expects valid json. Contents are not used for Vagrant.
    echo "{}" > "${hc_releases_input_metadata}"

    debug "generating release metadata information"
    wrap_stream bob generate-release-metadata \
        -metadata-file "${hc_releases_input_metadata}" \
        -in-dir "${directory}" \
        -version "${version}" \
        -out-file "${hc_releases_metadata_filename}" \
        "Failed to generate release metadata"

    rm -f "${hc_releases_input_metadata}"
}

# Upload release metadata and assets to the staging api
#
# $1: Product Name (e.g. "vagrant")
# $2: Product Version
# $3: Asset directory
function hashicorp_release_upload_to_staging() {
    local product="${1}"
    local version="${2}"
    local directory="${3}"

    if ! command -v "hc-releases"; then
        debug "releases-api executable not found, installing"
        install_hashicorp_tool "releases-api"
    fi

    if [ -z "${HC_RELEASES_STAGING_HOST}" ]; then
        failure "Missing required environment variable HC_RELEASES_STAGING_HOST"
    fi
    if [ -z "${HC_RELEASES_STAGING_KEY}" ]; then
       failure "Missing required environment variable HC_RELEASES_STAGING_KEY"
    fi

    export HC_RELEASES_HOST="${HC_RELEASES_STAGING_HOST}"
    export HC_RELEASES_KEY="${HC_RELEASES_STAGING_KEY}"

    pushd "${directory}"

    # Create -file parameter list for hc-releases upload
    local fileParams=()
    for file in *; do
        fileParams+=("-file=${file}")
    done

    debug "uploading release assets to staging"
    wrap_stream hc-releases upload \
        -product "${product}" \
        -version "${version}" \
        "${fileParams[@]}" \
        "Failed to upload HashiCorp release assets"

    popd

    debug "creating release metadata"

    wrap_stream hc-releases metadata create \
        -product "${product}" \
        -input "${hc_releases_metadata_filename}" \
        "Failed to create metadata for HashiCorp release"

    unset HC_RELEASES_HOST
    unset HC_RELEASES_KEY
}

# Promote release from staging to production
#
# $1: Product Name (e.g. "vagrant")
# $2: Product Version
function hashicorp_release_promote_to_production() {
    local product="${1}"
    local version="${2}"

    if ! command -v "hc-releases"; then
        debug "releases-api executable not found, installing"
        install_hashicorp_tool "releases-api"
    fi

    if [ -z "${HC_RELEASES_PROD_HOST}" ]; then
        failure "Missing required environment variable HC_RELEASES_PROD_HOST"
    fi
    if [ -z "${HC_RELEASES_PROD_KEY}" ]; then
       failure "Missing required environment variable HC_RELEASES_PROD_KEY"
    fi
    if [ -z "${HC_RELEASES_STAGING_KEY}" ]; then
       failure "Missing required environment variable HC_RELEASES_STAGING_KEY"
    fi

    export HC_RELEASES_HOST="${HC_RELEASES_PROD_HOST}"
    export HC_RELEASES_KEY="${HC_RELEASES_PROD_KEY}"
    export HC_RELEASES_SOURCE_ENV_KEY="${HC_RELEASES_STAGING_KEY}"

    debug "promoting release to production"
    wrap_stream hc-releases promote \
        -product "${product}" \
        -version "${version}" \
        -source-env staging \
        "Failed to promote HashiCorp release to Production"

    unset HC_RELEASES_HOST
    unset HC_RELEASES_KEY
    unset HC_RELEASES_SOURCE_ENV_KEY
}

# Send the post-publish sns message
#
# $1: Product name (e.g. "vagrant") defaults to $repo_name
# $2: AWS Region of SNS (defaults to us-east-1)
function hashicorp_release_sns_publish() {
    local message
    local product="${1}"
    local region="${2}"

    if [ -z "${product}" ]; then
        product="${repo_name}"
    fi

    if [ -z "${region}" ]; then
        region="us-east-1"
    fi

    # Validate the creds properly assume role and function
    wrap aws_deprecated configure list \
        "Failed to reconfigure AWS credentials for release notification"

    # Now send the release notification
    debug "sending release notification to package repository"
    message=$(jq --null-input --arg product "$product" '{"product": $product}')
    wrap_stream aws sns publish --region "${region}" --topic-arn "${HC_RELEASES_PROD_SNS_TOPIC}" --message "${message}" \
        "Failed to send SNS message for package repository update"

    return 0
}

# Check if a release for the given version
# has been published to the HashiCorp
# releases site.
#
# $1: Product Name
# $2: Product Version
function hashicorp_release_exists() {
    local product="${1}"
    local version="${2}"

    if curl --silent --fail --head "https://releases.hashicorp.com/${product}/${product}_${version}/" > /dev/null ; then
        debug "hashicorp release of %s@%s found" "${product}" "${version}"
        return 0
    fi
    debug "hashicorp release of %s@%s not found" "${product}" "${version}"
    return 1
}

# Generate the SHA256SUMS file for assets
# in a given directory.
#
# $1: Asset Directory
# $2: Product Name
# $3: Product Version
function generate_shasums() {
    local directory="${1}"
    local product="${2}"
    local version="${3}"

    pushd "${directory}"

    local shacontent
    debug "generating shasums file for %s@%s" "${product}" "${version}"
    shacontent="$(shasum -a256 ./*)" ||
        failure "Failed to generate shasums in ${directory}"

    sed 's/\.\///g' <( printf "%s" "${shacontent}" ) > "${product}_${version}_SHA256SUMS" ||
        failure "Failed to write shasums file"

    popd
}

# Generate a HashiCorp releases-api compatible release
#
# $1: Asset directory
# $2: Product Name (e.g. "vagrant")
# $3: Product Version
function hashicorp_release() {
    local directory="${1}"
    local product="${2}"
    local version="${3}"

    # If the version is provided, use the discovered release version
    if [[ "${version}" == "" ]]; then
        version="${release_version}"
    fi

    debug "creating hashicorp release - product: %s version: %s assets: %s" "${product}" "${version}" "${directory}"

    if ! hashicorp_release_exists "${product}" "${version}"; then
        # Jump into our artifact directory
        pushd "${directory}"

        # If any sig files happen to have been included in here,
        # just remove them as they won't be using the correct
        # signing key
        rm -f ./*.sig

        # Generate our shasums file
        debug "generating shasums file for %s@%s" "${product}" "${version}"
        generate_shasums ./ "${product}" "${version}"

        # Grab the shasums file and sign it
        local shasum_files=(./*SHA256SUMS)
        local shasum_file="${shasum_files[0]}"
        # Remove relative prefix if found
        shasum_file="${shasum_file##*/}"
        debug "signing shasums file for %s@%s" "${product}" "${version}"
        gpg_sign_file "${shasum_file[0]}"

        # Jump back out of our artifact directory
        popd

        # Run validation and verification on release assets before
        # we actually do the release.
        debug "running release validation for %s@%s" "${product}" "${version}"
        hashicorp_release_validate "${directory}"
        debug "running release verification for %s@%s" "${product}" "${version}"
        hashicorp_release_verify "${directory}"

        # Now that the assets have been validated and verified,
        # peform the release setps
        debug "generating release metadata for %s@%s" "${product}" "${version}"
        hashicorp_release_generate_release_metadata "${version}" "${directory}"
        debug "uploading release artifacts to staging for %s@%s" "${product}" "${version}"
        hashicorp_release_upload_to_staging "${product}" "${version}" "${directory}"
        debug "promoting release to production for %s@%s" "${product}" "${version}"
        hashicorp_release_promote_to_production "${product}" "${version}"

        printf "HashiCorp release created (%s@%s)\n" "${product}" "${version}"
    else
        printf "hashicorp release not published, already exists (%s@%s)\n" "${product}" "${version}"
    fi

    # Send a notification to update the package repositories
    # with the new release.
    debug "sending packaging notification for %s@%s" "${product}" "${version}"
    hashicorp_release_sns_publish "${product}"
}

# Check if gem version is already published to RubyGems
#
# $1: Name of RubyGem
# $2: Verision of RubyGem
# $3: Custom gem server to search (optional)
function is_version_on_rubygems() {
    local name="${1}"
    local version="${2}"
    local gemstore="${3}"

    if [ -z "${name}" ]; then
        failure "Name is required for version check on %s" "${gemstore:-RubyGems.org}"
    fi

    if [ -z "${version}" ]; then
        failure "Version is required for version check on %s" "${gemstore:-RubyGems.org}"
    fi

    debug "checking rubygem %s at version %s is currently published" "${name}" "${version}"
    local cmd_args=("gem" "search")
    if [ -n "${gemstore}" ]; then
        debug "checking rubygem publication at custom source: %s" "${gemstore}"
        cmd_args+=("--clear-sources" "--source" "${gemstore}")
    fi
    cmd_args+=("--remote" "--exact" "--all")

    local result
    result="$("${cmd_args[@]}" "${name}")" ||
        failure "Failed to retreive remote version list from RubyGems"
    local versions="${result##*\(}"
    local versions="${versions%%)*}"
    local oifs="${IFS}"
    IFS=', '
    local r=1
    for v in $versions; do
        if [ "${v}" = "${version}" ]; then
            r=0
            debug "rubygem %s at version %s was found" "${name}" "${version}"
            break
        fi
    done
    IFS="${oifs}"
    return $r
}

# Check if gem version is already published to hashigems
#
# $1: Name of RubyGem
# $2: Verision of RubyGem
function is_version_on_hashigems() {
    is_version_on_rubygems "${1}" "${2}" "https://gems.hashicorp.com"
}

# Build and release project gem to RubyGems
function publish_to_rubygems() {
    if [ -z "${RUBYGEMS_API_KEY}" ]; then
        failure "RUBYGEMS_API_KEY is required for publishing to RubyGems.org"
    fi

    local gem_file="${1}"

    if [ -z "${gem_file}" ]; then
        failure "RubyGem file is required for publishing to RubyGems.org"
    fi

    if [ ! -f "${gem_file}" ]; then
        failure "Path provided does not exist or is not a file (%s)" "${gem_file}"
    fi

    # NOTE: Newer versions of rubygems support setting the
    #       api key via the GEM_HOST_API_KEY environment
    #       variable. Config file is still used so that older
    #       versions can be used for doing pushes.
    gem_config="$(mktemp -p ./)" ||
        failure "Could not create gem configuration file"
    # NOTE: The `--` are required due to the double dash
    #       start of the first argument
    printf -- "---\n:rubygems_api_key: %s\n" "${RUBYGEMS_API_KEY}" > "${gem_config}"

    gem push --config-file "${gem_config}" "${gem_file}" ||
        failure "Failed to publish RubyGem at '%s' to RubyGems.org" "${gem_file}"
    rm -f "${gem_config}"
}

# Publish gem to the hashigems repository
#
# $1: Path to gem file to publish
function publish_to_hashigems() {
    local path="${1}"
    if [ -z "${path}" ]; then
        failure "Path to built gem required for publishing to hashigems"
    fi

    debug "publishing '%s' to hashigems" "${path}"

    # Define all the variables we'll need
    local user_bin
    local reaper
    local invalid
    local invalid_id

    wrap_stream gem install --user-install --no-document reaper-man \
        "Failed to install dependency for hashigem generation"
    user_bin="$(ruby -e 'puts Gem.user_dir')/bin"
    reaper="${user_bin}/reaper-man"

    debug "using reaper-man installation at: %s" "${reaper}"

    # Create a temporary directory to work from
    local tmpdir
    tmpdir="$(mktemp -d -p ./)" ||
        failure "Failed to create working directory for hashigems publish"
    mkdir -p "${tmpdir}/hashigems/gems" ||
        failure "Failed to create gems directory"
    wrap cp "${path}" "${tmpdir}/hashigems/gems" \
        "Failed to copy gem to working directory"
    pushd "${tmpdir}"

    # Run quick test to ensure bucket is accessible
    wrap aws s3 ls "s3://${HASHIGEMS_METADATA_BUCKET}" \
        "Failed to access hashigems asset bucket"

    # Grab our remote metadata. If the file doesn't exist, that is always an error.
    debug "fetching hashigems metadata file from %s" "${HASHIGEMS_METADATA_BUCKET}"
    wrap aws s3 cp "s3://${HASHIGEMS_METADATA_BUCKET}/vagrant-rubygems.list" ./ \
        "Failed to retrieve hashigems metadata list"

    # Add the new gem to the metadata file
    debug "adding new gem to the metadata file"
    wrap_stream "${reaper}" package add -S rubygems -p vagrant-rubygems.list ./hashigems/gems/*.gem \
        "Failed to add new gem to hashigems metadata list"
    # Generate the repository
    debug "generating the new hashigems repository content"
    wrap_stream "${reaper}" repo generate -p vagrant-rubygems.list -o hashigems -S rubygems \
        "Failed to generate the hashigems repository"
    # Upload the updated repository
    pushd ./hashigems
    debug "uploading new hashigems repository content to %s" "${HASHIGEMS_PUBLIC_BUCKET}"
    wrap_stream aws s3 sync . "s3://${HASHIGEMS_PUBLIC_BUCKET}" \
        "Failed to upload the hashigems repository"
    # Store the updated metadata
    popd
    debug "uploading updated hashigems metadata file to %s" "${HASHIGEMS_METADATA_BUCKET}"
    wrap_stream aws s3 cp vagrant-rubygems.list "s3://${HASHIGEMS_METADATA_BUCKET}/vagrant-rubygems.list" \
        "Failed to upload the updated hashigems metadata file"

    # Invalidate cloudfront so the new content is available
    local invalid
    debug "invalidating hashigems cloudfront distribution (%s)" "${HASHIGEMS_CLOUDFRONT_ID}"
    invalid="$(aws cloudfront create-invalidation --distribution-id "${HASHIGEMS_CLOUDFRONT_ID}" --paths "/*")" ||
        failure "Invalidation of hashigems CDN distribution failed"
    local invalid_id
    invalid_id="$(printf '%s' "${invalid}" | jq -r ".Invalidation.Id")"
    if [ -z "${invalid_id}" ]; then
        failure "Failed to determine the ID of the hashigems CDN invalidation request"
    fi
    debug "hashigems cloudfront distribution invalidation identifer - %s" "${invalid_id}"

    # Wait for the invalidation process to complete
    debug "starting wait for hashigems cloudfront distribution invalidation to complete (id: %s)" "${invalid_id}"
    wrap aws cloudfront wait invalidation-completed --distribution-id "${HASHIGEMS_CLOUDFRONT_ID}" --id "${invalid_id}" \
        "Failure encountered while waiting for hashigems CDN invalidation request to complete (ID: ${invalid_id})"
    debug "hashigems cloudfront distribution invalidation complete (id: %s)" "${invalid_id}"

    # Clean up and we are done
    popd
    rm -rf "${tmpdir}"
}

# Configures git for hashibot usage
function hashibot_git() {
    wrap git config user.name "${HASHIBOT_USERNAME}" \
        "Failed to setup git for hashibot usage (username)"
    wrap git config user.email "${HASHIBOT_EMAIL}" \
        "Failed to setup git for hashibot usage (email)"
    wrap git remote set-url origin "https://${HASHIBOT_USERNAME}:${HASHIBOT_TOKEN}@github.com/${repository}" \
        "Failed to setup git for hashibot usage (remote)"
}

# Get the default branch name for the current repository
function default_branch() {
    local s
    s="$(git symbolic-ref refs/remotes/origin/HEAD)" ||
        failure "Failed to determine default branch (is working directory git repository?)"
    printf "%s" "${s##*origin/}"
}


# Send a notification to slack. All flag values can be set with
# environment variables using the upcased name prefixed with SLACK_,
# for example: --channel -> SLACK_CHANNEL
#
# -c --channel CHAN        Send to channel
# -u --username USER       Send as username
# -i --icon URL            User icon image
# -s --state STATE         Message state (success, warn, error, or color code)
# -m --message MESSAGE     Message to send
# -M --message-file PATH   Use file contents as message
# -f --file PATH           Send raw contents of file in message (displayed in code block)
# -t --title TITLE         Message title
# -T --tail NUMBER         Send last NUMBER lines of content from raw message file
# -w --webhook URL         Slack webhook
function slack() {
    # Convert any long names to short names
    for arg in "$@"; do
        shift
        case "${arg}" in
            "--channel") set -- "${@}" "-c" ;;
            "--username") set -- "${@}" "-u" ;;
            "--icon") set -- "${@}" "-i" ;;
            "--state") set -- "${@}" "-s" ;;
            "--message") set -- "${@}" "-m" ;;
            "--message-file") set -- "${@}" "-M" ;;
            "--file") set -- "${@}" "-f" ;;
            "--title") set -- "${@}" "-t" ;;
            "--tail") set -- "${@}" "-T" ;;
            "--webhook") set -- "${@}" "-w"  ;;
            *) set -- "${@}" "${arg}" ;;
        esac
    done
    local OPTIND opt
    # Default all options to values provided by environment variables
    local channel="${SLACK_CHANNEL}"
    local username="${SLACK_USERNAME}"
    local icon="${SLACK_ICON}"
    local state="${SLACK_STATE}"
    local message="${SLACK_MESSAGE}"
    local message_file="${SLACK_MESSAGE_FILE}"
    local file="${SLACK_FILE}"
    local title="${SLACK_TITLE}"
    local tail="${SLACK_TAIL}"
    local webhook="${SLACK_WEBHOOK}"
    while getopts ":c:u:i:s:m:M:f:t:T:w:" opt; do
        case "${opt}" in
            "c") channel="${OPTARG}" ;;
            "u") username="${OPTARG}" ;;
            "i") icon="${OPTARG}" ;;
            "s") state="${OPTARG}" ;;
            "m") message="${OPTARG}" ;;
            "M") message_file="${OPTARG}" ;;
            "f") file="${OPTARG}" ;;
            "t") title="${OPTARG}" ;;
            "T") tail="${OPTARG}" ;;
            "w") webhook="${OPTARG}" ;;
            *) failure "Invalid flag provided to slack" ;;
        esac
    done
    shift $((OPTIND-1))

    # If we don't have a webhook provided, stop here
    if [ -z "${webhook}" ]; then
        (>&2 echo "ERROR: Cannot send Slack notification, webhook unset")
        return 1
    fi

    local footer footer_icon ts

    # If we are using GitHub actions, format the footer
    if [ -n "${GITHUB_ACTIONS}" ]; then
        if [ -z "${icon}" ]; then
            icon="https://ca.slack-edge.com/T024UT03C-WG8NDATGT-f82ae03b9fca-48"
        fi
        if [ -z "${username}" ]; then
            username="GitHub"
        fi
        footer_icon="https://ca.slack-edge.com/T024UT03C-WG8NDATGT-f82ae03b9fca-48"
        footer="Actions - <https://github.com/${GITHUB_REPOSITORY}/commit/${GITHUB_SHA}/checks|${GITHUB_REPOSITORY}>"
    fi

    # If no state was provided, default to good state
    if [ -z "${state}" ]; then
        state="good"
    fi

    # Convert state aliases
    case "${state}" in
        "success" | "good")
            state="good";;
        "warn" | "warning")
            state="warning";;
        "error" | "danger")
            state="danger";;
    esac

    # If we have a message file, read it
    if [ -n "${message_file}" ]; then
        local message_file_content
        message_file_content="$(<"${message_file}")"
        if [ -z "${message}" ]; then
            message="${message_file_content}"
        else
            message="${message}\n\n${message_file_content}"
        fi
    fi

    # If we have a file to include, add it now. Files are
    # displayed as raw content, so be sure to wrap with
    # backticks
    if [ -n "${file}" ]; then
        local file_content
        # If tail is provided, then only include the last n number
        # of lines in the file
        if [ -n "${tail}" ]; then
            if ! file_content="$(tail -n "${tail}" "${file}")"; then
                file_content="UNEXPECTED ERROR: Failed to tail content in file ${file}"
            fi
        else
            file_content="$(<"${file}")"
        fi
        if [ -n "${file_content}" ]; then
            message="${message}\n\n\`\`\`\n${file_content}\n\`\`\`"
        fi
    fi

    local attach attach_template payload payload_template ts
    ts="$(date '+%s')"

    # shellcheck disable=SC2016
    attach_template='{text: $msg, color: $state, mrkdwn_in: ["text"], ts: $time'
    if [ -n "${title}" ]; then
        # shellcheck disable=SC2016
        attach_template+=', title: $title'
    fi
    if [ -n "${footer}" ]; then
        # shellcheck disable=SC2016
        attach_template+=', footer: $footer'
    fi
    if [ -n "${footer_icon}" ]; then
        # shellcheck disable=SC2016
        attach_template+=', footer_icon: $footer_icon'
    fi
    attach_template+='}'

    attach=$(jq -n \
        --arg msg "$(printf "%b" "${message}")" \
        --arg title "${title}" \
        --arg state "${state}" \
        --arg time "${ts}" \
        --arg footer "${footer}" \
        --arg footer_icon "${footer_icon}" \
        "${attach_template}" \
        )

    # shellcheck disable=SC2016
    payload_template='{attachments: [$attachment]'
    if [ -n "${username}" ]; then
        # shellcheck disable=SC2016
        payload_template+=', username: $username'
    fi
    if [ -n "${channel}" ]; then
        # shellcheck disable=SC2016
        payload_template+=', channel: $channel'
    fi
    if [ -n "${icon}" ]; then
        # shellcheck disable=SC2016
        payload_template+=', icon_url: $icon'
    fi
    payload_template+='}'

    payload=$(jq -n \
        --argjson attachment "${attach}" \
        --arg username "${username}" \
        --arg channel "${channel}" \
        --arg icon "${icon}" \
        "${payload_template}" \
        )

    debug "sending slack message with payload: %s" "${payload}"
    wrap curl -SsL --fail -X POST -H "Content-Type: application/json" -d "${payload}" "${webhook}" \
        "Failed to send slack notification"
}

# Install internal HashiCorp tools. These tools are expected to
# be located in private (though not required) HashiCorp repositories.
# It will attempt to download the correct artifact for the current
# platform based on HashiCorp naming conventions. It expects that
# the name of the repository is the name of the tool.
#
# $1: Name of repository
function install_hashicorp_tool() {
    local tool_name="${1}"
    local extensions=("zip" "tar.gz")
    local asset release_content tmp

    if [ -z "${tool_name}" ]; then
        failure "Repository name is required for hashicorp tool install"
    fi

    debug "installing hashicorp tool: %s" "${tool_name}"

    # Swap out repository to force correct github token
    local repository_bak="${repository}"
    repository="${repo_owner}/${release_repo}"

    tmp="$(mktemp -d --tmpdir vagrantci-XXXXXX)" ||
        failure "Failed to create temporary working directory"
    pushd "${tmp}"

    local platform
    platform="$(uname -s)" || failure "Failed to get local platform name"
    platform="${platform,,}" # downcase the platform name

    local arches=()

    local arch
    arch="$(uname -m)" || failure "Failed to get local platform architecture"
    arches+=("${arch}")

    # If the architecture is listed as x86_64, add amd64 to the
    # arches collection. Hashicorp naming scheme is to use amd64 in
    # the file name, but isn't always followed
    if [ "${arch}" = "x86_64" ]; then
        arches+=("amd64")
    fi

    release_content=$(github_request -H "Content-Type: application/json" \
        "https://api.github.com/repos/hashicorp/${tool_name}/releases/latest") ||
      failure "Failed to request latest releases for hashicorp/${tool_name}"

    local exten
    for exten in "${extensions[@]}"; do
        for arch in "${arches[@]}"; do
            local suffix="${platform}_${arch}.${exten}"
            debug "checking for release artifact with suffix: %s" "${suffix}"
            asset=$(printf "%s" "${release_content}" | jq -r \
                '.assets[] | select(.name | contains("'"${suffix}"'")) | .url')
            if [ -n "${asset}" ]; then
                debug "release artifact found: %s" "${asset}"
               break
            fi
        done
        if [ -n "${asset}" ]; then
            break
        fi
    done

    if [ -z "${asset}" ]; then
        failure "Failed to find release of hashicorp/${tool_name} for ${platform} ${arch[0]}"
    fi

    debug "tool artifact match found for install: %s" "${asset}"

    github_request -o "${tool_name}.${exten}" \
        -H "Accept: application/octet-stream" "${asset}" ||
        "Failed to download latest release for hashicorp/${tool_name}"

    if [ "${exten}" = "zip" ]; then
        wrap unzip "${tool_name}.${exten}" \
            "Failed to unpack latest release for hashicorp/${tool_name}"
    else
        wrap tar xf "${tool_name}.${exten}" \
            "Failed to unpack latest release for hashicorp/${tool_name}"
    fi

    rm -f "${tool_name}.${exten}"

    local files=( ./* )
    wrap chmod 0755 ./* \
        "Failed to change mode on latest release for hashicorp/${tool_name}"

    wrap mv ./* "${ci_bin_dir}" \
        "Failed to install latest release for hashicorp/${tool_name}"

    debug "new files added to path: %s" "${files[*]}"
    popd
    rm -rf "${tmp}"

    repository="${repository_bak}" # restore the repository value
}

# Install tool from GitHub releases. It will fetch the latest release
# of the tool and install it. The proper release artifact will be matched
# by a "linux_amd64" string. This command is best effort and may not work.
#
# $1: Organization name
# $2: Repository name
# $3: Tool name (optional)
function install_github_tool() {
    local org_name="${1}"
    local r_name="${2}"
    local tool_name="${3}"

    if [ -z "${tool_name}" ]; then
        tool_name="${r_name}"
    fi

    local asset release_content tmp
    local artifact_list artifact basen

    tmp="$(mktemp -d --tmpdir vagrantci-XXXXXX)" ||
        failure "Failed to create temporary working directory"
    pushd "${tmp}"

    debug "installing github tool %s from %s/%s" "${tool_name}" "${org_name}" "${r_name}"

    release_content=$(github_request -H "Content-Type: application/json" \
        "https://api.github.com/repos/${org_name}/${r_name}/releases/latest") ||
        failure "Failed to request latest releases for ${org_name}/${r_name}"

    asset=$(printf "%s" "${release_content}" | jq -r \
        '.assets[] | select( ( (.name | contains("amd64")) or (.name | contains("x86_64")) or (.name | contains("x86-64")) ) and (.name | contains("linux")) and (.name | endswith("sha256") | not) and (.name | endswith("sig") | not))  | .url') ||
        failure "Failed to detect latest release for ${org_name}/${r_name}"

    artifact="${asset##*/}"
    github_request -o "${artifact}" -H "Accept: application/octet-stream" "${asset}" ||
        "Failed to download latest release for ${org_name}/${r_name}"

    basen="${artifact##*.}"
    if [ "${basen}" = "zip" ]; then
        wrap unzip "${artifact}" \
            "Failed to unpack latest release for ${org_name}/${r_name}"
        rm -f "${artifact}"
    elif [ -n "${basen}" ]; then
        wrap tar xf "${artifact}" \
            "Failed to unpack latest release for ${org_name}/${r_name}"
        rm -f "${artifact}"
    fi

    artifact_list=(./*)

    # If the artifact only contained a directory, get
    # the contents of the directory
    if [ "${#artifact_list[@]}" -eq "1" ] && [ -d "${artifact_list[0]}" ]; then
        debug "unpacked artifact contained only directory, inspecting contents"
        artifact_list=( "${artifact_list[0]}/"* )
    fi

    local tool_match tool_glob_match executable_match
    local item
    for item in "${artifact_list[@]}"; do
        if [ "${item##*/}" = "${tool_name}" ]; then
            debug "tool name match found: %s" "${item}"
            tool_match="${item}"
        elif [ -e "${item}" ]; then
            debug "executable match found: %s" "${item}"
            executable_match="${item}"
        elif [[ "${item}" = "${tool_name}"* ]]; then
            debug "tool name glob match found: %s" "${item}"
            tool_glob_match="${item}"
        fi
    done

    # Install based on best match to worst match
    if [ -n "${tool_match}" ]; then
        debug "installing %s from tool name match (%s)" "${tool_name}" "${tool_match}"
        mv -f "${tool_match}" "${ci_bin_dir}/${tool_name}" ||
            "Failed to install latest release of %s from %s/%s" "${tool_name}" "${org_name}" "${r_name}"
    elif [ -n "${tool_glob_match}" ]; then
        debug "installing %s from tool name glob match (%s)" "${tool_name}" "${tool_glob_match}"
        mv -f "${tool_glob_match}" "${ci_bin_dir}/${tool_name}" ||
            "Failed to install latest release of %s from %s/%s" "${tool_name}" "${org_name}" "${r_name}"
    elif [ -n "${executable_match}" ]; then
        debug "installing %s from executable file match (%s)" "${tool_name}" "${executable_match}"
        mv -f "${executable_match}" "${ci_bin_dir}/${tool_name}" ||
            "Failed to install latest release of %s from %s/%s" "${tool_name}" "${org_name}" "${r_name}"
    else
        failure "Failed to locate tool '%s' in latest release from %s/%s" "${org_name}" "${r_name}"
    fi

    popd
    rm -rf "${tmp}"
}

# Prepare host for packet use. It will validate the
# required environment variables are set, ensure
# packet-exec is installed, and setup the SSH key.
function packet-setup() {
    # First check that we have the environment variables
    if [ -z "${PACKET_EXEC_TOKEN}" ]; then
        failure "Cannot setup packet, missing token"
    fi
    if [ -z "${PACKET_EXEC_PROJECT_ID}" ]; then
        failure "Cannot setup packet, missing project"
    fi
    if [ -z "${PACKET_SSH_KEY_CONTENT}" ]; then
        failure "Cannot setup packet, missing ssh key"
    fi

    install_hashicorp_tool "packet-exec"

    # Write the ssh key to disk
    local content
    content="$(base64 --decode - <<< "${PACKET_SSH_KEY_CONTENT}")" ||
        failure "Cannot setup packet, failed to decode key"
    touch ./packet-key
    chmod 0600 ./packet-key
    printf "%s" "${content}" > ./packet-key
    local working_directory
    working_directory="$(pwd)" ||
        failure "Cannot setup packet, failed to determine working directory"
    export PACKET_EXEC_SSH_KEY="${working_directory}/packet-key"
}

# Download artifact(s) from GitHub release. The artifact pattern is simply
# a substring that is matched against the artifact download URL. Artifact(s)
# will be downloaded to the working directory.
#
# $1: repository name
# $2: release tag name
# $3: artifact pattern (optional, all artifacts downloaded if omitted)
function github_release_assets() {
    local req_args
    req_args=()

    local  asset_pattern
    local release_repo="${1}"
    local release_name="${2}"
    local asset_pattern="${3}"

    # Swap out repository to force correct github token
    local repository_bak="${repository}"
    repository="${repo_owner}/${release_repo}"

    req_args+=("-H" "Accept: application/vnd.github+json")
    req_args+=("https://api.github.com/repos/${repository}/releases/tags/${release_name}")

    debug "fetching release asset list for release %s on %s" "${release_name}" "${repository}"

    local release_content
    release_content=$(github_request "${req_args[@]}") ||
        failure "Failed to request release (${release_name}) for ${repository}"

    local query=".assets[]"
    if [ -n "${asset_pattern}" ]; then
        debug "applying release asset list filter %s" "${asset_pattern}"
        query+="$(printf ' | select(.name | contains("%s"))' "${asset_pattern}")"
    fi

    local asset_list
    asset_list=$(printf "%s" "${release_content}" | jq -r "${query} | .url") ||
        failure "Failed to detect asset in release (${release_name}) for ${release_repo}"

    local name_list
    name_list=$(printf "%s" "${release_content}" | jq -r "${query} | .name") ||
        failure "Failed to detect asset in release (${release_name}) for ${release_repo}"

    req_args=()
    req_args+=("-H" "Accept: application/octet-stream")

    local assets asset_names
    readarray -t assets <  <(printf "%s" "${asset_list}")
    readarray -t asset_names < <(printf "%s" "${name_list}")

    local idx
    for ((idx=0; idx<"${#assets[@]}"; idx++ )); do
        local asset="${assets[$idx]}"
        local artifact="${asset_names[$idx]}"

        github_request "${req_args[@]}" -o "${artifact}" "${asset}" ||
            "Failed to download asset (${artifact}) in release ${release_name} for ${repository}"
        printf "downloaded release asset %s from release %s on %s\n" "${artifact}" "${release_name}" "${repository}"
    done

    repository="${repository_bak}" # restore the repository value
}

# Basic helper to create a GitHub prerelease
#
# $1: repository name
# $2: tag name for release
# $3: path to artifact(s) - single file or directory
function github_prerelease() {
    local prerelease_repo="${1}"
    local tag_name="${2}"
    local artifacts="${3}"

    if [ -z "${prerelease_repo}" ]; then
        failure "Name of repository required for prerelease release"
    fi

    if [ -z "${tag_name}" ]; then
        failure "Name is required for prerelease release"
    fi

    if [ -z "${artifacts}" ]; then
        failure "Artifacts path is required for prerelease release"
    fi

    if [ ! -e "${artifacts}" ]; then
        failure "No artifacts found at provided path (${artifacts})"
    fi

    local prerelease_target="${repo_owner}/${prerelease_repo}"

    # Create the prerelease
    local response
    response="$(github_create_release -p -t "${tag_name}" -o "${repo_owner}" -r "${prerelease_repo}" )" ||
        failure "Failed to create prerelease on %s/%s" "${repo_owner}" "${prerelease_repo}"

    # Extract the release ID from the response
    local release_id
    release_id="$(printf "%s" "${response}" | jq -r '.id')" ||
        failure "Failed to extract prerelease ID from response for ${tag_name} on ${prerelease_target}"

    github_upload_release_artifacts "${prerelease_repo}" "${release_id}" "${artifacts}"

}

# Upload artifacts to a release
#
# $1: target repository name
# $2: release ID
# $3: path to artifact(s) - single file or directory
function github_upload_release_artifacts() {
    local target_repo_name="${1}"
    local release_id="${2}"
    local artifacts="${3}"

    if [ -z "${target_repo_name}" ]; then
        failure "Repository name required for release artifact upload"
    fi

    if [ -z "${release_id}" ]; then
        failure "Release ID require for release artifact upload"
    fi

    if [ -z "${artifacts}" ]; then
        failure "Artifacts required for release artifact upload"
    fi

    if [ ! -e "${artifacts}" ]; then
        failure "No artifacts found at provided path for release artifact upload (%s)" "${artifacts}"
    fi

    # Swap out repository to force correct github token
    local repository_bak="${repository}"
    repository="${repo_owner}/${target_repo_name}"

    local req_args=("-X" "POST" "-H" "Content-Type: application/octet-stream")

    # Now upload the artifacts to the draft release
    local artifact_name
    if [ -f "${artifacts}" ]; then
        debug "uploading %s to release ID %s on %s" "${artifact}" "${release_id}" "${repository}"
        artifact_name="${artifacts##*/}"
        req_args+=("https://uploads.github.com/repos/${repository}/releases/${release_id}/assets?name=${artifact_name}"
                   "--data-binary" "@${artifacts}")
        if ! github_request "${req_args[@]}" > /dev/null ; then
            failure "Failed to upload artifact '${artifacts}' to draft release on ${repository}"
        fi
        printf "Uploaded release artifact: %s\n" "${artifact_name}" >&2
        # Everything is done so get on outta here
        return 0
    fi

    # Push into the directory
    pushd "${artifacts}"

    local artifact_path
    # Walk through each item and upload
    for artifact_path in * ; do
        if [ ! -f "${artifact_path}" ]; then
            debug "skipping '%s' as it is not a file" "${artifact_path}"
            continue
        fi
        artifact_name="${artifact_path##*/}"
        debug "uploading %s/%s to release ID %s on %s" "${artifacts}" "${artifact_name}" "${release_id}" "${repository}"
        local r_args=( "${req_args[@]}" )
        r_args+=("https://uploads.github.com/repos/${repository}/releases/${release_id}/assets?name=${artifact_name}"
                   "--data-binary" "@${artifact_path}")
        if ! github_request "${r_args[@]}" > /dev/null ; then
            failure "Failed to upload artifact '${artifact_name}' in '${artifacts}' to draft release on ${repository}"
        fi
        printf "Uploaded release artifact: %s\n" "${artifact_name}" >&2
    done

    repository="${repository_bak}"
}

# Basic helper to create a GitHub draft release
#
# $1: repository name
# $2: tag name for release
# $3: path to artifact(s) - single file or directory
function github_draft_release() {
    local draft_repo="${1}"
    local tag_name="${2}"
    local artifacts="${3}"

    if [ -z "${draft_repo}" ]; then
        failure "Name of repository required for draft release"
    fi

    if [ -z "${tag_name}" ]; then
        failure "Name is required for draft release"
    fi

    if [ -z "${artifacts}" ]; then
        failure "Artifacts path is required for draft release"
    fi

    if [ ! -e "${artifacts}" ]; then
        failure "No artifacts found at provided path (%s)" "${artifacts}"
    fi

    # Create the draft release
    local response
    response="$(github_create_release -d -t "${tag_name}" -o "${repo_owner}" -r "${draft_repo}" )" ||
        failure "Failed to create draft release on %s" "${repo_owner}/${draft_repo}"

    # Extract the release ID from the response
    local release_id
    release_id="$(printf "%s" "${response}" | jq -r '.id')" ||
        failure "Failed to extract draft release ID from response for %s on %s" "${tag_name}" "${repo_owner}/${draft_repo}"

    github_upload_release_artifacts "${draft_repo}" "${release_id}" "${artifacts}"
}

# Create a GitHub release
#
# -b BODY - body of release
# -c COMMITISH - commitish of release
# -n NAME - name of the release
# -o OWNER - repository owner (required)
# -r REPO - repository name (required)
# -t TAG_NAME - tag name for release (required)
# -d - draft release
# -p - prerelease
# -g - generate release notes
# -m - make release latest
#
# NOTE: Artifacts for release must be uploaded using `github_upload_release_artifacts`
function github_create_release() {
    local OPTIND opt owner repo tag_name
    # Values that can be null
    local body commitish name
    # Values we default
    local draft="false"
    local generate_notes="false"
    local make_latest="false"
    local prerelease="false"

    while getopts ":b:c:n:o:r:t:dpgm" opt; do
        case "${opt}" in
            "b") body="${OPTARG}" ;;
            "c") commitish="${OPTARG}" ;;
            "n") name="${OPTARG}" ;;
            "o") owner="${OPTARG}" ;;
            "r") repo="${OPTARG}" ;;
            "t") tag_name="${OPTARG}" ;;
            "d") draft="true" ;;
            "p") prerelease="true" ;;
            "g") generate_notes="true" ;;
            "m") make_latest="true" ;;
            *) failure "Invalid flag provided to github_create_release" ;;
        esac
    done
    shift $((OPTIND-1))

    # Sanity check
    if [ -z "${owner}" ]; then
        failure "Repository owner value is required for GitHub release"
    fi

    if [ -z "${repo}" ]; then
        failure "Repository name is required for GitHub release"
    fi

    if [ -z "${tag_name}" ] && [ "${draft}" != "true" ]; then
        failure "Tag name is required for GitHub release"
    fi

    if [ "${draft}" = "true" ] && [ "${prerelease}" = "true" ]; then
        failure "Release cannot be both draft and prerelease"
    fi

    # If no name is provided, use the tag name value
    if [ -z "${name}" ]; then
        name="${tag_name}"
    fi

    # shellcheck disable=SC2016
    local payload_template='{tag_name: $tag_name, draft: $draft, prerelease: $prerelease, generate_release_notes: $generate_notes, make_latest: $make_latest'
    local jq_args=("-n"
                   "--arg" "tag_name" "${tag_name}"
                   "--arg" "make_latest" "${make_latest}"
                   "--argjson" "draft" "${draft}"
                   "--argjson" "generate_notes" "${generate_notes}"
                   "--argjson" "prerelease" "${prerelease}"
                  )

    if [ -n "${commitish}" ]; then
        # shellcheck disable=SC2016
        payload_template+=', target_commitish: $commitish'
        jq_args+=("--arg" "commitish" "${commitish}")
    fi
    if [ -n "${name}" ]; then
        # shellcheck disable=SC2016
        payload_template+=', name: $name'
        jq_args+=("--arg" "name" "${name}")
    fi
    if [ -n "${body}" ]; then
        # shellcheck disable=SC2016
        payload_template+=', body: $body'
        jq_args+=("--arg" "body" "${body}")
    fi
    payload_template+='}'

    # Generate the payload
    local payload
    payload="$(jq "${jq_args[@]}" "${payload_template}" )" ||
        failure "Could not generate GitHub release JSON payload"

    local target_repo="${owner}/${repo}"
    # Set repository to get correct token behavior on request
    local repository_bak="${repository}"
    repository="${target_repo}"

    # Craft our request arguments
    local req_args=("-X" "POST" "https://api.github.com/repos/${target_repo}/releases" "-d" "${payload}")

    # Create the draft release
    local response
    if ! response="$(github_request "${req_args[@]}")"; then
        failure "Could not create github release on ${target_repo}"
    fi

    # Restore the repository
    repository="${repository_bak}"

    local rel_type
    if [ "${draft}" = "true" ]; then
        rel_type="draft release"
    elif [ "${prerelease}" = "true" ]; then
        rel_type="prerelease"
    else
        rel_type="release"
    fi

    # Report new draft release was created
    printf "New %s '%s' created on '%s'\n" "${rel_type}" "${tag_name}" "${target_repo}" >&2

    # Print the response
    printf "%s" "${response}"
}

# Check if a github release exists by tag name
# NOTE: This can be used for release and prerelease checks.
#       Draft releases must use the github_draft_release_exists
#       function.
#
# $1: repository name
# $2: release tag name
function github_release_exists() {
    local release_repo="${1}"
    local release_name="${2}"

    if [ -z "${release_repo}" ]; then
        failure "Repository name required for release lookup"
    fi
    if [ -z "${release_name}" ]; then
        failure "Release name required for release lookup"
    fi

    # Override repository value to get correct token automatically
    local repository_bak="${repository}"
    repository="${repo_owner}/${release_repo}"

    local result="1"
    if github_request \
        -H "Accept: application/vnd.github+json" \
        "https://api.github.com/repos/${repository}/releases/tags/${release_name}" > /dev/null; then
        debug "release '${release_name}' found in ${repository}"
        result="0"
    else
        debug "release '${release_name}' not found in ${repository}"
    fi

    # Restore repository value
    repository="${repository_bak}"

    return "${result}"
}

# Check if a github release exists using fuzzy match
#
# $1: repository name
# $2: release name
function github_release_exists_fuzzy() {
    local release_repo="${1}"
    local release_name="${2}"

    if [ -z "${release_repo}" ]; then
        failure "Repository name required for draft release lookup"
    fi
    if [ -z "${release_name}" ]; then
        failure "Release name required for draft release lookup"
    fi

    # Override repository value to get correct token automatically
    local repository_bak="${repository}"
    repository="${repo_owner}/${release_repo}"

    local page=$((1))
    local matched_name

    while [ -z "${release_content}" ]; do
        local release_list
        release_list="$(github_request \
            -H "Content-Type: application/json" \
            "https://api.github.com/repos/${repository}/releases?per_page=100&page=${page}")" ||
            failure "Failed to request releases list for ${repository}"

        # If there's no more results, just bust out of the loop
        if [ "$(jq 'length' <( printf "%s" "${release_list}" ))" -lt "1" ]; then
            break
        fi

        local names name_list n matched_name
        name_list="$(printf "%s" "${release_list}" | jq '.[] | .name')" ||
            failure "Could not generate name list"

        # shellcheck disable=SC2206
        names=( $name_list )
        for n in "${names[@]}"; do
            if [[ "${n}" =~ $release_name ]]; then
                matched_name="${n}"
                break
            fi
        done

        if [ -n "${matched_name}" ]; then
            break
        fi

        ((page++))
    done

    # Restore the $repository value
    repository="${repository_bak}"

    if [ -z "${matched_name}" ]; then
        debug "did not locate release named %s for %s" "${release_name}" "${repo_owner}/${release_repo}"
        return 1
    fi

    debug "found release name %s in %s (pattern: %s)" "${matched_name}" "${repo_owner}/${release_repo}" "${release_name}"
    return 0
}

# Check if a draft release exists by name
#
# $1: repository name
# $2: release name
function github_draft_release_exists() {
    local release_repo="${1}"
    local release_name="${2}"

    if [ -z "${release_repo}" ]; then
        failure "Repository name required for draft release lookup"
    fi
    if [ -z "${release_name}" ]; then
        failure "Release name required for draft release lookup"
    fi

    # Override repository value to get correct token automatically
    local repository_bak="${repository}"
    repository="${repo_owner}/${release_repo}"

    local page=$((1))
    local release_content

    while [ -z "${release_content}" ]; do
        local release_list
        release_list="$(github_request \
            -H "Content-Type: application/json" \
            "https://api.github.com/repos/${repository}/releases?per_page=100&page=${page}")" ||
            failure "Failed to request releases list for ${repository}"

        # If there's no more results, just bust out of the loop
        if [ "$(jq 'length' <( printf "%s" "${release_list}" ))" -lt "1" ]; then
            break
        fi

        query="$(printf '.[] | select(.name == "%s")' "${release_name}")"

        release_content=$(printf "%s" "${release_list}" | jq -r "${query}")

        ((page++))
    done

    # Restore the $repository value
    repository="${repository_bak}"

    if [ -z "${release_content}" ]; then
        debug "did not locate draft release named %s for %s" "${release_name}" "${repo_owner}/${release_repo}"
        return 1
    fi

    debug "found draft release name %s in %s" "${release_name}" "${repo_owner}/${release_repo}"
    return 0
}

# Download artifact(s) from GitHub draft release. A draft release is not
# attached to a tag and therefore is referenced by the release name directly.
# The artifact pattern is simply a substring that is matched against the
# artifact download URL. Artifact(s) will be downloaded to the working directory.
#
# $1: repository name
# $2: release name
# $3: artifact pattern (optional, all artifacts downloaded if omitted)
function github_draft_release_assets() {
    local release_repo_name="${1}"
    local release_name="${2}"
    local asset_pattern="${3}"

    if [ -z "${release_repo_name}" ]; then
        failure "Repository name is required for draft release asset fetching"
    fi
    if [ -z "${release_name}" ]; then
        failure "Draft release name is required for draft release asset fetching"
    fi

    # Override repository value to get correct token automatically
    local repository_bak="${repository}"
    repository="${repo_owner}/${release_repo_name}"

    local page=$((1))
    local release_content query
    while [ -z "${release_content}" ]; do
        local release_list
        release_list=$(github_request -H "Content-Type: application/json" \
            "https://api.github.com/repos/${repository}/releases?per_page=100&page=${page}") ||
            failure "Failed to request releases list for ${repository}"

        # If there's no more results, just bust out of the loop
        if [ "$(jq 'length' <( printf "%s" "${release_list}" ))" -lt "1" ]; then
            debug "did not locate draft release named %s in %s" "${release_name}" "${repository}"
            break
        fi

        query="$(printf '.[] | select(.name == "%s")' "${release_name}")"
        release_content=$(printf "%s" "${release_list}" | jq -r "${query}")

        ((page++))
    done

    query=".assets[]"
    if [ -n "${asset_pattern}" ]; then
        debug "apply pattern filter to draft assets: %s" "${asset_pattern}"
        query+="$(printf ' | select(.name | contains("%s"))' "${asset_pattern}")"
    fi

    local asset_list
    asset_list=$(printf "%s" "${release_content}" | jq -r "${query} | .url") ||
        failure "Failed to detect asset in release (${release_name}) for ${repository}"

    local name_list
    name_list=$(printf "%s" "${release_content}" | jq -r "${query} | .name") ||
        failure "Failed to detect asset in release (${release_name}) for ${repository}"

    debug "draft release assets list: %s" "${name_list}"

    local assets asset_names
    readarray -t assets <  <(printf "%s" "${asset_list}")
    readarray -t asset_names < <(printf "%s" "${name_list}")

    if [ "${#assets[@]}" -ne "${#asset_names[@]}" ]; then
        failure "Failed to match download assets with names in release list for ${repository}"
    fi

    local idx
    for ((idx=0; idx<"${#assets[@]}"; idx++ )); do
        local asset="${assets[$idx]}"
        local artifact="${asset_names[$idx]}"
        github_request -o "${artifact}" \
            -H "Accept: application/octet-stream" "${asset}" ||
            "Failed to download asset in release (${release_name}) for ${repository} - ${artifact}"

        printf "downloaded draft release asset at %s\n" "${artifact}" >&2
    done

    repository_bak="${repository}" # restore repository value
}

# This function is identical to the github_draft_release_assets
# function above with one caveat: it does not download the files.
# Each file that would be downloaded is simply touched in the
# current directory. This provides an easy way to check the
# files that would be downloaded without actually downloading
# them.
#
# An example usage of this can be seen in the vagrant package
# building where we use this to enable building missing substrates
# or packages on re-runs and only download the artifacts if
# actually needed.
function github_draft_release_asset_names() {
    local release_reponame="${1}"
    local release_name="${2}"
    local asset_pattern="${3}"

    if [ -z "${release_reponame}" ]; then
        failure "Repository name is required for draft release assets names"
    fi

    if [ -z "${release_name}" ]; then
        failure "Release name is required for draft release asset names"
    fi

    # Override repository value to get correct token automatically
    local repository_bak="${repository}"
    repository="${repo_owner}/${release_reponame}"

    local page=$((1))
    local release_content query
    while [ -z "${release_content}" ]; do
        local release_list
        release_list=$(github_request H "Content-Type: application/json" \
            "https://api.github.com/repos/${repository}/releases?per_page=100&page=${page}") ||
            failure "Failed to request releases list for ${repository}"

        # If there's no more results, just bust out of the loop
        if [ "$(jq 'length' <( printf "%s" "${release_list}" ))" -lt "1" ]; then
            debug "did not locate draft release named %s in %s" "${release_name}" "${repository}"
            break
        fi

        query="$(printf '.[] | select(.name == "%s")' "${release_name}")"
        release_content=$(printf "%s" "${release_list}" | jq -r "${query}")

        ((page++))
    done

    query=".assets[]"
    if [ -n "${asset_pattern}" ]; then
        debug "apply pattern filter to draft assets: %s" "${asset_pattern}"
        query+="$(printf ' | select(.name | contains("%s"))' "${asset_pattern}")"
    fi

    local name_list
    name_list=$(printf "%s" "${release_content}" | jq -r "${query} | .name") ||
        failure "Failed to detect asset in release (${release_name}) for ${repository}"

    debug "draft release assets list: %s" "${name_list}"

    local asset_names
    readarray -t asset_names < <(printf "%s" "${name_list}")

    local idx
    for ((idx=0; idx<"${#asset_names[@]}"; idx++ )); do
        local artifact="${asset_names[$idx]}"
        touch "${artifact}" ||
            failure "Failed to touch release asset at path: %s" "${artifact}"
        printf "touched draft release asset at %s\n" "${artifact}" >&2
    done

    repository_bak="${repository}" # restore repository value
}

# Delete a github release by tag name
# NOTE: Releases and prereleases can be deleted using this
#       function. For draft releases use github_delete_draft_release
#
# $1: tag name of release
# $2: repository name (optional, defaults to current repository name)
function github_delete_release() {
    local release_name="${1}"
    local release_repo="${2:-$repo_name}"

    if [ -z "${release_name}" ]; then
        failure "Release name is required for deletion"
    fi
    if [ -z "${release_repo}" ]; then
        failure "Repository is required for release deletion"
    fi

    # Override repository value to get correct token automatically
    local repository_bak="${repository}"
    repository="${repo_owner}/${release_repo}"

    # Fetch the release first
    local release_content
    release_content="$(github_request \
        -H "Accept: application/vnd.github+json" \
        "https://api.github.com/repos/${repository}/releases/tags/${release_name}")" ||
        failure "Failed to fetch release information for '${release_name}' in ${repository}"

    # Get the release id to reference in delete request
    local rel_id
    rel_id="$(jq -r '.id' <( printf "%s" "${release_content}" ) )" ||
        failure "Failed to read release id for '${release_name}' in ${repository}"

    debug "deleting github release '${release_name}' in ${repository} with id ${rel_id}"

    # Send the deletion request
    github_request \
        -X "DELETE" \
        -H "Accept: application/vnd.github+json" \
        "https://api.github.com/repos/${repository}/releases/${rel_id}" > /dev/null ||
        failure "Failed to delete release '${release_name}' in ${repository}"

    # Restore repository value
    repository="${repository_bak}"
}

# Delete draft release with given name
#
# $1: name of draft release
# $2: repository name (optional, defaults to current repository name)
function github_delete_draft_release() {
    local draft_name="${1}"
    local delete_repo="${2:-$repo_name}"

    if [ -z "${draft_name}" ]; then
        failure "Draft name is required for deletion"
    fi

    if [ -z "${delete_repo}" ]; then
        failure "Repository is required for draft deletion"
    fi

    # Override repository value to get correct token automatically
    local repository_bak="${repository}"
    repository="${repo_owner}/${delete_repo}"

    local draft_ids=()
    local page=$((1))
    while true; do
        local release_list list_length
        release_list=$(github_request -H "Content-Type: application/json" \
            "https://api.github.com/repos/${repository}/releases?per_page=100&page=${page}") ||
            failure "Failed to request releases list for draft deletion on ${repository}"
        list_length="$(jq 'length' <( printf "%s" "${release_list}" ))" ||
            failure "Failed to calculate release length for draft deletion on ${repository}"

        # If the list is empty then there are no more releases to process
        if [ -z "${list_length}" ] || [ "${list_length}" -lt 1 ]; then
            debug "no releases returned for page %d in repository %s" "${page}" "${repository}"
            break
        fi

        local entry i release_draft release_id release_name
        for (( i=0; i < "${list_length}"; i++ )); do
            entry="$(jq ".[$i]" <( printf "%s" "${release_list}" ))" ||
                failure "Failed to read entry for draft deletion on ${repository}"
            release_draft="$(jq -r '.draft' <( printf "%s" "${entry}" ))" ||
                failure "Failed to read entry draft for draft deletion on ${repository}"
            release_id="$(jq -r '.id' <( printf "%s" "${entry}" ))" ||
                failure "Failed to read entry ID for draft deletion on ${repository}"
            release_name="$(jq -r '.name' <( printf "%s" "${entry}" ))" ||
                failure "Failed to read entry name for draft deletion on ${repository}"

            # If the names don't match, skip
            if [ "${release_name}" != "${draft_name}" ]; then
                debug "skipping release deletion, name mismatch (%s != %s)" "${release_name}" "${draft_name}"
                continue
            fi

            # If the release is not a draft, fail
            if [ "${release_draft}" != "true" ]; then
                debug "skipping release '%s' (ID: %s) from '%s' - release is not a draft" "${draft_name}" "${release_id}" "${repository}"
                continue
            fi

            # If we are here, we found a match
            draft_ids+=( "${release_id}" )
        done
        ((page++))
    done

    # If no draft ids were found, the release was not found
    # so we can just return success
    if [ "${#draft_ids[@]}" -lt "1" ]; then
        debug "no draft releases found matching name %s in %s" "${draft_name}" "${repository}"
        repository="${repository_bak}" # restore repository value before return
        return 0
    fi

    # Still here? Okay! Delete the draft(s)
    local draft_id
    for draft_id in "${draft_ids[@]}"; do
        info "Deleting draft release %s from %s (ID: %d)\n" "${draft_name}" "${repository}" "${draft_id}"
        github_request -X DELETE "https://api.github.com/repos/${repository}/releases/${draft_id}" ||
            failure "Failed to prune draft release ${draft_name} from ${repository}"
    done

    repository="${repository_bak}" # restore repository value before return
}

# Delete prerelease with given name
#
# $1: tag name of prerelease
# $2: repository name (optional, defaults to current repository name)
function github_delete_prerelease() {
    local tag_name="${1}"
    local delete_repo="${2:-$repo_name}"

    if [ -z "${tag_name}" ]; then
        failure "Tag name is required for deletion"
    fi

    if [ -z "${delete_repo}" ]; then
        failure "Repository is required for prerelease deletion"
    fi

    # Override repository value to get correct token automatically
    local repository_bak="${repository}"
    repository="${repo_owner}/${delete_repo}"

    local prerelease
    prerelease=$(github_request -H "Content-Type: application/vnd.github+json" \
        "https://api.github.com/repos/${repository}/releases/tags/${tag_name}") ||
        failure "Failed to get prerelease %s from %s" "${tag_name}" "${repository}"
    local prerelease_id
    prerelease_id="$(jq -r '.id' <( printf "%s" "${prerelease}" ))" ||
        failure "Failed to read prerelease ID for %s on %s" "${tag_name}" "${repository}"
    local is_prerelease
    is_prerelease="$(jq -r '.prerelease' <( printf "%s" "${prerelease}" ))" ||
        failure "Failed to read prerelease status for %s on %s" "${tag_name}" "${repository}"

    # Validate the matched release is a prerelease
    if [ "${is_prerelease}" != "true" ]; then
        failure "Prerelease %s on %s is not marked as a prerelease, cannot delete" "${tag_name}" "${repository}"
    fi

    info "Deleting prerelease %s from repository %s" "${tag_name}" "${repository}"
    github_request -X DELETE "https://api.github.com/repos/${repository}/releases/${prerelease_id}" ||
        failure "Failed to delete prerelease %s from %s" "${tag_name}" "${repository}"

    repository="${repository_bak}" # restore repository value before return
}

# Delete any draft releases that are older than the
# given number of days
#
# $1: days
# $2: repository name (optional, defaults to current repository name)
function github_draft_release_prune() {
    github_release_prune "draft" "${@}"
}

# Delete any prereleases that are older than the
# given number of days
#
# $1: days
# $2: repository name (optional, defaults to current repository name)
function github_prerelease_prune() {
    github_release_prune "prerelease" "${@}"
}

# Delete any releases of provided type that are older than the
# given number of days
#
# $1: type (prerelease or draft)
# $2: days
# $3: repository name (optional, defaults to current repository name)
function github_release_prune() {
    local prune_type="${1}"
    if [ -z "${prune_type}" ]; then
        failure "Type is required for release pruning"
    fi
    if [ "${prune_type}" != "draft" ] && [ "${prune_type}" != "prerelease" ]; then
        failure "Invalid release pruning type provided '%s' (supported: draft or prerelease)" "${prune_type}"
    fi

    local days="${2}"
    if [ -z "${days}" ]; then
        failure "Number of days to retain is required for pruning"
    fi
    if [[ "${days}" = *[!0123456789]* ]]; then
        failure "Invalid value provided for days to retain when pruning (%s)" "${days}"
    fi

    local prune_repo="${3:-$repo_name}"
    if [ -z "${prune_repo}" ]; then
        failure "Repository name is required for pruning"
    fi

    local prune_seconds now
    now="$(date '+%s')"
    prune_seconds=$(("${now}"-("${days}" * 86400)))

    # Override repository value to get correct token automatically
    local repository_bak="${repository}"
    repository="${repo_owner}/${prune_repo}"

    debug "deleting %ss over %d days old from %s" "${prune_type}" "${days}" "${repository}"

    local page=$((1))
    while true; do
        local release_list list_length

        release_list=$(github_request -H "Accept: application/vnd.github+json" \
            "https://api.github.com/repos/${repository}/releases?per_page=100&page=${page}") ||
            failure "Failed to request releases list for pruning on ${repository}"

        list_length="$(jq 'length' <( printf "%s" "${release_list}" ))" ||
            failure "Failed to calculate release length for pruning on ${repository}"

        if [ -z "${list_length}" ] || [ "${list_length}" -lt "1" ]; then
            debug "releases listing page %d for %s is empty" "${page}" "${repository}"
            break
        fi

        local entry i release_type release_name release_id release_create date_check
        for (( i=0; i < "${list_length}"; i++ )); do
            entry="$(jq ".[${i}]" <( printf "%s" "${release_list}" ))" ||
                failure "Failed to read entry for pruning on %s" "${repository}"
            release_type="$(jq -r ".${prune_type}" <( printf "%s" "${entry}" ))" ||
                failure "Failed to read entry %s for pruning on %s" "${prune_type}" "${repository}"
            release_name="$(jq -r '.name' <( printf "%s" "${entry}" ))" ||
                failure "Failed to read entry name for pruning on %s" "${repository}"
            release_id="$(jq -r '.id' <( printf "%s" "${entry}" ))" ||
                failure "Failed to read entry ID for pruning on %s" "${repository}"
            release_create="$(jq -r '.created_at' <( printf "%s" "${entry}" ))" ||
                failure "Failed to read entry created date for pruning on %s" "${repository}"
            date_check="$(date --date="${release_create}" '+%s')" ||
                failure "Failed to parse entry created date for pruning on %s" "${repository}"

            if [ "${release_type}" != "true" ]; then
                debug "Skipping %s on %s because release is not a %s" "${release_name}" "${repository}" "${prune_type}"
                continue
            fi

            if [ "$(( "${date_check}" ))" -lt "${prune_seconds}" ]; then
                info "Deleting release %s from %s\n" "${release_name}" "${prune_repo}"
                github_request -X DELETE "https://api.github.com/repos/${repository}/releases/${release_id}" ||
                    failure "Failed to prune %s %s from %s" "${prune_type}" "${release_name}" "${repository}"
            fi
        done
        ((page++))
    done

    repository="${repository_bak}" # restore the repository value
}

# Delete all but the latest N number of releases of the provided type
#
# $1: type (prerelease or draft)
# $2: number of releases to retain
# $3: repository name (optional, defaults to current repository name)
function github_release_prune_retain() {
    local prune_type="${1}"
    if [ -z "${prune_type}" ]; then
        failure "Type is required for release pruning"
    fi
    if [ "${prune_type}" != "draft" ] && [ "${prune_type}" != "prerelease" ]; then
        failure "Invalid release pruning type provided '%s' (supported: draft or prerelease)" "${prune_type}"
    fi

    local retain="${2}"
    if [ -z "${retain}" ]; then
        failure "Number of releases to retain is required for pruning"
    fi
    if [[ "${retain}" = *[!0123456789]* ]]; then
        failure "Invalid value provided for number of releases to retain when pruning (%s)" "${days}"
    fi

    local prune_repo="${3:-$repo_name}"
    if [ -z "${prune_repo}" ]; then
        failure "Repository name is required for pruning"
    fi

    # Override repository value to get correct token automatically
    local repository_bak="${repository}"
    repository="${repo_owner}/${prune_repo}"

    debug "pruning all %s type releases except latest %d releases" "${prune_type}" "${retain}"
    local prune_list=()
    local page=$((1))
    while true; do
        local release_list list_length

        release_list=$(github_request -H "Accept: application/vnd.github+json" \
            "https://api.github.com/repos/${repository}/releases?per_page=100&page=${page}&sort=created_at&direction=desc") ||
            failure "Failed to request releases list for pruning on ${repository}"
        list_length="$(jq 'length' <( printf "%s" "${release_list}" ))" ||
            failure "Failed to calculate release length for pruning on ${repository}"
        if [ -z "${list_length}" ] || [ "${list_length}" -lt "1" ]; then
            debug "releases listing page %d for %s is empty" "${page}" "${repository}"
            break
        fi

        local entry i release_type release_name release_id release_create date_check
        for (( i=0; i < "${list_length}"; i++ )); do
            entry="$(jq ".[${i}]" <( printf "%s" "${release_list}" ))" ||
                failure "Failed to read entry for pruning on %s" "${repository}"
            release_type="$(jq -r ".${prune_type}" <( printf "%s" "${entry}" ))" ||
                failure "Failed to read entry %s for pruning on %s" "${prune_type}" "${repository}"
            release_name="$(jq -r '.name' <( printf "%s" "${entry}" ))" ||
                failure "Failed to read entry name for pruning on %s" "${repository}"
            release_id="$(jq -r '.id' <( printf "%s" "${entry}" ))" ||
                failure "Failed to read entry ID for pruning on %s" "${repository}"

            if [ "${release_type}" != "true" ]; then
                debug "Skipping %s on %s because release is not a %s" "${release_name}" "${repository}" "${prune_type}"
                continue
            fi

            debug "adding %s '%s' to prune list (ID: %s)" "${prune_type}" "${release_name}" "${release_id}"
            prune_list+=( "${release_id}" )
        done
        (( page++ ))
    done

    local prune_count="${#prune_list[@]}"
    local prune_trim=$(( "${prune_count}" - "${retain}" ))

    # If there won't be any remaining items in the list, bail
    if [ "${prune_trim}" -le 0 ]; then
        debug "no %ss in %s to prune" "${prune_type}" "${repository}"
        repository="${repository_bak}" # restore the repository value
        return 0
    fi

    # Trim down the list to what should be deleted
    prune_list=("${prune_list[@]:$retain:$prune_trim}")

    # Now delete what is left in the list
    local r_id
    for r_id in "${prune_list[@]}"; do
        debug "deleting release (ID: %s) from %s" "${r_id}" "${repository}"
        github_request -X DELETE "https://api.github.com/repos/${repository}/releases/${r_id}" ||
            failure "Failed to prune %s %s from %s" "${prune_type}" "${r_id}" "${repository}"
    done

    repository="${repository_bak}" # restore the repository value
}

# Grab the correct github token to use for authentication. The
# rules used for the token to return are as follows:
#
# * only $GITHUB_TOKEN is set: $GITHUB_TOKEN
# * only $HASHIBOT_TOKEN is set: $HASHIBOT_TOKEN
#
# when both $GITHUB_TOKEN and $HASHIBOT_TOKEN are set:
#
# * $repository value matches $GITHUB_REPOSITORY: $GITHUB_TOKEN
# * $repository value does not match $GITHUB_REPOSITORY: $HASHIBOT_TOKEN
#
# Will return `0` when a token is returned, `1` when no token is returned
function github_token() {
    local gtoken

    # Return immediately if no tokens are available
    if [ -z "${GITHUB_TOKEN}" ] && [ -z "${HASHIBOT_TOKEN}" ]; then
        debug "no github or hashibot token set"
        return 1
    fi

    # Return token if only one token exists
    if [ -n "${GITHUB_TOKEN}" ] && [ -z "${HASHIBOT_TOKEN}" ]; then
        debug "only github token set"
        printf "%s\n" "${GITHUB_TOKEN}"
        return 0
    elif [ -n "${HASHIBOT_TOKEN}" ] && [ -z "${GITHUB_TOKEN}" ]; then
        debug "only hashibot token set"
        printf "%s\n" "${HASHIBOT_TOKEN}"
        return 0
    fi

    # If the $repository matches the original $GITHUB_REPOSITORY use the local token
    if [ "${repository}" = "${GITHUB_REPOSITORY}" ]; then
        debug "prefer github token "
        printf "%s\n" "${GITHUB_TOKEN}"
        return 0
    fi

    # Still here, then we send back that hashibot token
    printf "%s\n" "${HASHIBOT_TOKEN}"
    return 0
}

# This function is used to make requests to the GitHub API. It
# accepts the same argument list that would be provided to the
# curl executable. It will check the response status and if a
# 429 is received (rate limited) it will pause until the defined
# rate limit reset time and then try again.
#
# NOTE: Informative information (like rate limit pausing) will
# be printed to stderr. The response body will be printed to
# stdout. Return value of the function will be the exit code
# from the curl process.
function github_request() {
    local request_exit=0
    local info_prefix="__info__"
    local info_tmpl="${info_prefix}:code=%{response_code}:header=%{size_header}:download=%{size_download}:file=%{filename_effective}"
    local raw_response_content

    local curl_cmd=("curl" "-w" "${info_tmpl}" "-i" "-SsL" "--fail")
    local gtoken

    # Only add the authentication token if we have one
    if gtoken="$(github_token)"; then
        curl_cmd+=("-H" "Authorization: token ${gtoken}")
    fi

    # Attach the rest of the arguments
    curl_cmd+=("${@#}")

    debug "initial request: %s" "${curl_cmd[*]}"

    # Make our request
    raw_response_content="$("${curl_cmd[@]}")" || request_exit="${?}"

    # Define the status here since we will set it in
    # the conditional below of something weird happens
    local status

    # Check if our response content starts with the info prefix.
    # If it does, we need to extract the headers from the file.
    if [[ "${raw_response_content}" = "${info_prefix}"* ]]; then
        debug "extracting request information from: %s" "${raw_response_content}"
        raw_response_content="${raw_response_content#"${info_prefix}":code=}"
        local response_code="${raw_response_content%%:*}"
        debug "response http code: %s" "${response_code}"
        raw_response_content="${raw_response_content#*:header=}"
        local header_size="${raw_response_content%%:*}"
        debug "response header size: %s" "${header_size}"
        raw_response_content="${raw_response_content#*:download=}"
        local download_size="${raw_response_content%%:*}"
        debug "response file size: %s" "${download_size}"
        raw_response_content="${raw_response_content#*:file=}"
        local file_name="${raw_response_content}"
        debug "response file name: %s" "${file_name}"
        if [ -f "${file_name}" ]; then
            # Read the headers from the file and place them in the
            # raw_response_content to be processed
            local download_fd
            exec {download_fd}<"${file_name}"
            debug "file descriptor created for header grab (source: %s): %q" "${file_name}" "${download_fd}"
            debug "reading response header content from %s" "${file_name}"
            read -r -N "${header_size}" -u "${download_fd}" raw_response_content
            # Close our descriptor
            debug "closing file descriptor: %q" "${download_fd}"
            exec {download_fd}<&-
            # Now trim the headers from the file content
            debug "trimming response header content from %s" "${file_name}"
            tail -c "${download_size}" "${file_name}" > "${file_name}.trimmed" ||
                failure "Could not trim headers from downloaded file (%s)" "${file_name}"
            mv -f "${file_name}.trimmed" "${file_name}" ||
                failure "Could not replace downloaded file with trimmed file (%s)" "${file_name}"
        else
            debug "expected file not found (%s)" "${file_name}"
            status="${response_code}"
        fi
    else
        # Since the response wasn't written to a file, trim the
        # info from the end of the response
        if [[ "${raw_response_content}" != *"${info_prefix}"* ]]; then
            debug "github request response does not include information footer"
            failure "Unexpected error encountered, partial GitHub response returned"
        fi
        raw_response_content="${raw_response_content%"${info_prefix}"*}"
    fi

    local ratelimit_reset
    local ratelimit_remaining
    local response_content=""

    # Read the response into lines for processing
    local lines
    mapfile -t lines < <( printf "%s" "${raw_response_content}" )

    # Process the lines to extract out status and rate
    # limit information. Populate the response_content
    # variable with the actual response value
    local i
    for (( i=0; i < "${#lines[@]}"; i++ )); do
        # The line will have a trailing `\r` so just
        # trim it off
        local line="${lines[$i]%%$'\r'*}"
        # strip any leading/trailing whitespace characters
        read -rd '' line <<< "${line}"

        if [ -z "${line}" ] && [[ "${status}" = "2"* ]]; then
            local start="$(( i + 1 ))"
            local remain="$(( "${#lines[@]}" - "${start}" ))"
            local response_lines=("${lines[@]:$start:$remain}")
            response_content="${response_lines[*]}"
            break
        fi

        if [[ "${line}" == "HTTP/"* ]]; then
            status="${line##* }"
            debug "http status found: %d" "${status}"
        fi
        if [[ "${line}" == "x-ratelimit-reset"* ]]; then
            ratelimit_reset="${line##*ratelimit-reset: }"
            debug "ratelimit reset time found: %s" "${ratelimit_reset}"
        fi
        if [[ "${line}" == "x-ratelimit-remaining"* ]]; then
            ratelimit_remaining="${line##*ratelimit-remaining: }"
            debug "ratelimit requests remaining: %d" "${ratelimit_remaining}"
        fi
    done

    # If the status was not detected, force an error
    if [ -z "${status}" ]; then
        failure "Failed to detect response status for GitHub request"
    fi

    # If the status was a 2xx code then everything is good
    # and we can return the response and be done
    if [[ "${status}" = "2"* ]]; then
        printf "%s" "${response_content}"
        return 0
    fi

    # If we are being rate limited, print a notice and then
    # wait until the rate limit will be reset
    if [[ "${status}" = "429" ]] || [[ "${status}" = "403" ]]; then
        debug "request returned %d status, checking for rate limiting" "${status}"

        # If the ratelimit reset was not detected force an error
        if [ -z "${ratelimit_reset}" ]; then
            failure "Failed to detect rate limit reset time for GitHub request"
        fi

        # If there are requests still available against the ratelimt
        # and the status is a 403, then it's an actual 403 and not
        # a ratelimit
        if [[ "${status}" = "403" ]] && [[ "${ratelimit_remaining}" -gt 0 ]]; then
            failure "Request failed with 403 status response"
        fi

        debug "rate limiting has been detected on request"

        local reset_date
        reset_date="$(date --date="@${ratelimit_reset}")" ||
            failure "Failed to GitHub parse ratelimit reset timestamp (${ratelimit_reset})"

        local now
        now="$( date '+%s' )" || failure "Failed to get current timestamp in ratelimit check"
        local reset_wait="$(( "${ratelimit_reset}" - "${now}" + 2))"

        printf "GitHub rate limit encountered, reset at %s (waiting %d seconds)\n" \
            "${reset_date}" "${reset_wait}" >&2

        sleep "${reset_wait}" || failure "Pause for GitHub rate limited request retry failed"

        github_request "${@}"
        return "${?}"
    fi

    # At this point we just need to return error information
    printf "GitHub request returned HTTP status: %d\n" "${status}" >&2
    printf "Response body: %s\n" "${response_content}" >&2

    return "${request_exit}"
}

# Lock issues which have been closed for longer than
# provided number of days. A date can optionally be
# provided which will be used as the earliest date to
# search. A message can optionally be provided which
# will be added as a comment in the issue before locking.
#
# -d: number of days
# -m: message to include when locking the issue (optional)
# -s: date to begin searching from (optional)
function lock_issues() {
    local OPTIND opt days start since message
    while getopts ":d:s:m:" opt; do
        case "${opt}" in
            "d") days="${OPTARG}" ;;
            "s") start="${OPTARG}" ;;
            "m") message="${OPTARG}" ;;
            *) failure "Invalid flag provided to lock_issues" ;;
        esac
    done
    shift $((OPTIND-1))

    # If days where not provided, return error
    if [ -z "${days}" ]; then
        failure "Number of days since closed required for locking issues"
    fi
    # If a start date was provided, check that it is a format we can read
    if [ -n "${start}" ]; then
        if ! since="$(date --iso-8601=seconds --date="${start}" 2> /dev/null)"; then
            failure "$(printf "Start date provided for issue locking could not be parsed (%s)" "${start}")"
        fi
    fi

    debug "locking issues that have been closed for at least %d days" "${days}"

    local req_args=()
    # Start with basic setup
    req_args+=("-H" "Accept: application/vnd.github+json")
    # Add authorization header
    req_args+=("-H" "Authorization: token ${GITHUB_TOKEN}")
    # Construct our request endpoint
    local req_endpoint="https://api.github.com/repos/${repository}/issues"
    # Page counter for requests
    local page=$(( 1 ))
    # Request arguments
    local req_params=("per_page=20" "state=closed")

    # If we have a start time, include it
    if [ -n "${since}" ]; then
        req_params+=("since=${since}")
    fi

    # Compute upper bound for issues we can close
    local lock_seconds now
    now="$(date '+%s')"
    lock_seconds=$(("${now}"-("${days}" * 86400)))

    while true; do
        # Join all request parameters with '&'
        local IFS_BAK="${IFS}"
        IFS="&"
        local all_params=("${req_params[*]}" "page=${page}")
        local params="${all_params[*]}"
        IFS="${IFS_BAK}"

        local issue_list issue_count
        # Make our request to get a page of issues
        issue_list="$(github_request "${req_args[@]}" "${req_endpoint}?${params}")" ||
            failure "Failed to get repository issue list for ${repository}"
        issue_count="$(jq 'length' <( printf "%s" "${issue_list}" ))" ||
            failure "Failed to compute count of issues in list for ${repository}"

        if [ -z "${issue_count}" ] || [ "${issue_count}" -lt 1 ]; then
            break
        fi

        # Iterate through the list
        local i
        for (( i=0; i < "${issue_count}"; i++ )); do
            # Extract the issue we are going to process
            local issue
            issue="$(jq ".[${i}]" <( printf "%s" "${issue_list}" ))" ||
                failure "Failed to extract issue from list for ${repository}"

            # Grab the ID of this issue
            local issue_id
            issue_id="$(jq -r '.id' <( printf "%s" "${issue}" ))" ||
                failure "Failed to read ID of issue for ${repository}"

            # First check if issue is already locked
            local issue_locked
            issue_locked="$(jq -r '.locked' <( printf "%s" "${issue}" ))" ||
                failure "Failed to read locked state of issue for ${repository}"

            if [ "${issue_locked}" == "true" ]; then
                debug "Skipping %s#%s because it is already locked" "${repository}" "${issue_id}"
                continue
            fi

            # Get the closed date
            local issue_closed
            issue_closed="$(jq -r '.closed_at' <( printf "%s" "${issue}" ))" ||
                failure "Failed to read closed at date of issue for ${repository}"

            # Convert closed date to unix timestamp
            local date_check
            date_check="$( date --date="${issue_closed}" '+%s' )" ||
                failure "Failed to parse closed at date of issue for ${repository}"

            # Check if the issue is old enough to be locked
            if [ "$(( "${date_check}" ))" -lt "${lock_seconds}" ]; then
                printf "Locking issue %s#%s\n" "${repository}" "${issue_id}" >&2

                # If we have a comment to add before locking, do that now
                if [ -n "${message}" ]; then
                    local message_json
                    message_json=$(jq -n \
                        --arg msg "$(printf "%b" "${message}")" \
                        '{body: $msg}'
                        ) || failure "Failed to create issue comment JSON content for ${repository}"

                    debug "adding issue comment before locking on %s#%s" "${repository}" "${issue_id}"

                    github_request "${req_args[@]}" -X POST "${req_endpoint}/${issue_id}/comments" -d "${message_json}" ||
                        failure "Failed to create issue comment on ${repository}#${issue_id}"
                fi

                # Lock the issue
                github_request "${req_args[@]}" -X PUT "${req_endpoint}/${issue_id}/lock" -d '{"lock_reason":"resolved"}' ||
                    failure "Failed to lock issue ${repository}#${issue_id}"
            fi
        done

        ((page++))
    done
}

# Send a repository dispatch to the defined repository
#
# $1: repository name
# $2: event type (single word string)
# $n: "key=value" pairs to build payload (optional)
#
function github_repository_dispatch() {
    local drepo_name="${1}"
    local event_type="${2}"

    if [ -z "${drepo_name}" ]; then
        failure "Repository name is required for repository dispatch"
    fi

    # shellcheck disable=SC2016
    local payload_template='{"vagrant-ci": $vagrant_ci'
    local jqargs=("--arg" "vagrant_ci" "true")
    local arg
    for arg in "${@:3}"; do
        local payload_key="${arg%%=*}"
        local payload_value="${arg##*=}"
        payload_template+=", \"${payload_key}\": \$${payload_key}"
        # shellcheck disable=SC2089
        jqargs+=("--arg" "${payload_key}" "${payload_value}")
    done
    payload_template+="}"

    # NOTE: we want the arguments to be expanded below
    local payload
    payload=$(jq -n "${jqargs[@]}" "${payload_template}" ) ||
        failure "Failed to generate repository dispatch payload"

    # shellcheck disable=SC2016
    local msg_template='{event_type: $event_type, client_payload: $payload}'
    local msg
    msg=$(jq -n \
        --argjson payload "${payload}" \
        --arg event_type "${event_type}" \
        "${msg_template}" \
        ) || failure "Failed to generate repository dispatch message"

    # Update repository value to get correct token
    local repository_bak="${repository}"
    repository="${repo_owner}/${drepo_name}"

    github_request -X "POST" \
        -H 'Accept: application/vnd.github.everest-v3+json' \
        --data "${msg}" \
        "https://api.github.com/repos/${repo_owner}/${drepo_name}/dispatches" ||
        failure "Repository dispatch to ${repo_owner}/${drepo_name} failed"

    # Restore the repository value
    repository="${repository_bak}"
}

# Copy a function to a new name
#
# $1: Original function name
# $2: Copy function name
function copy_function() {
    local orig="${1}"
    local new="${2}"
    local fn
    fn="$(declare -f "${orig}")" ||
        failure "Orignal function (${orig}) not defined"
    fn="${new}${fn#*"${orig}"}"
    eval "${fn}"
}

# Rename a function to a new name
#
# $1: Original function name
# $2: New function name
function rename_function() {
    local orig="${1}"
    copy_function "${@}"
    unset -f "${orig}"
}

# Cleanup wrapper so we get some output that cleanup is starting
function _cleanup() {
    debug "* Running cleanup task..."
    # Always restore this value for cases where a failure
    # happened within a function while this value was in
    # a modified state
    repository="${_repository_backup}"
    cleanup
}

# Stub cleanup method which can be redefined
# within actual script
function cleanup() {
    debug "** No cleanup tasks defined"
}

# Only setup our cleanup trap and fail alias when not in testing
if [ -z "${BATS_TEST_FILENAME}" ]; then
    trap _cleanup EXIT
    # This is a compatibility alias for existing scripts which
    # use the common.sh library. BATS support defines a `fail`
    # function so it has been renamed `failure` to prevent the
    # name collision. When not running under BATS we enable the
    # `fail` function so any scripts that have not been updated
    # will not be affected.
    copy_function "failure" "fail"
fi

# Make sure the CI bin directory exists
if [ ! -d "${ci_bin_dir}" ]; then
    wrap mkdir -p "${ci_bin_dir}" \
        "Failed to create CI bin directory"
fi

# Always ensure CI bin directory is in PATH
if [[ "${PATH}" != *"${ci_bin_dir}"* ]]; then
    export PATH="${PATH}:${ci_bin_dir}"
fi

# Enable debugging. This needs to be enabled with
# extreme caution when used on public repositories.
# Output with debugging enabled will likely include
# secret values which should not be publicly exposed.
#
# If repository is public, FORCE_PUBLIC_DEBUG environment
# variable must also be set.

priv_args=("-H" "Accept: application/json")
# If we have a token available, use it for the check query
if [ -n "${HASHIBOT_TOKEN}" ]; then
    priv_args+=("-H" "Authorization: token ${GITHUB_TOKEN}")
elif [ -n "${GITHUB_TOKEN}" ]; then
    priv_args+=("-H" "Authorization: token ${HASHIBOT_TOKEN}")
fi

if [ -n "${GITHUB_ACTIONS}" ]; then
    priv_check="$(curl "${priv_args[@]}" -s "https://api.github.com/repos/${GITHUB_REPOSITORY}" | jq .private)" ||
        failure "Repository visibility check failed"
fi

# If the value wasn't true we unset it to indicate not private. The
# repository might actually be private but we weren't supplied a
# token (or one with correct permissions) so we fallback to the safe
# assumption of not private.
if [ "${priv_check}" != "true" ]; then
    readonly is_public="1"
    readonly is_private=""
else
    # shellcheck disable=SC2034
    readonly is_public=""
    # shellcheck disable=SC2034
    readonly is_private="1"
fi

# Check if we are running a job created by a tag. If so,
# mark this as being a release job and set the release_version
if [[ "${GITHUB_REF}" == *"refs/tags/"* ]]; then
    export tag="${GITHUB_REF##*tags/}"
    if valid_release_version "${tag}"; then
        readonly release=1
        export release_version="${tag##*v}"
    else
        readonly release
    fi
else
    # shellcheck disable=SC2034
    readonly release
fi

# Seed an initial output file
output_file > /dev/null 2>&1
