Skip to content

Instantly share code, notes, and snippets.

@r2evans
Last active January 28, 2022 14:34
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save r2evans/aed2053eebf5435223b54f0d93c2a933 to your computer and use it in GitHub Desktop.
Save r2evans/aed2053eebf5435223b54f0d93c2a933 to your computer and use it in GitHub Desktop.
Run GitLab CI jobs locally
#!/bin/bash
# gitlabci, v0.2, 2022-01-28
# Run GitLab-CI jobs (e.g., tests, coverage), locally.
# (c) 2022 Bill Evans bill@8pawexpress.com
# This script is intended to preempt the vicious cycle of:
# - Commit. Push. Watch the online CI test fail.
# - Commit a small typo. Push. Watch the CI test fail.
# - Commit two more characters. Push. Glare at the CI test.
#
# It is better to be able to test locally before pushing each commit
# (or even use commit-amend) quickly before clogging the git-logs and
# ssh-logs with extra traffic. It is far better to test locally using
# the same tests used in CI.
#
# It is *not* intended to be a formal testing system, nor can it
# handle complex situations.
#
# Notes:
#
# - If there is natural filesystem-based caching between job blocks,
# and one block creates artifacts that another needs, then that
# should still work here since the local directory is mounted within
# the docker image (as '/quux/' or user-defined mount point). A
# common way to utilize this is to have a './ci' directory within
# the project (perhaps listed in '.gitignore') in which dependencies
# are downloaded and artifacts are stored.
#
# - The '.gitlab-ci.yml' file must have the required blocks starting
# with the block name in column 1, the 'script:' portion in column
# 3, and all portions of the script block starting in column 5 or
# later with no empty lines. As soon as there is an empty line or
# characters before column 5, the script section is stopped. (This
# is for simplicity of parsing in this script, nothing more.)
#
# - GL supports dozens of EnvVars, this script only tries to know
# about two of them: CI_PROJECT_DIR and CI_PROJECT_NAME. If there
# are others that make sense to automatically infer (from something
# unambiguous), let me know.
#
# - The docker image is only sought once, and only in the 'default:'
# section. It is possible this may be a per-Job determination at
# some point. The workaround for different images is to run each
# job individually, specifying the specific docker image for each.
#
# - All EnvVars are available to other users and processes on the same
# system. EnvVars set with '-e key=val' are always shown with the
# real value; those set with '-E envfile' have the value (to the
# right of the '=') masked, but this is a weak defense. Be careful.
CI_PROJECT_DIR="."
MOUNTDOUBLESLASH=1
MOUNTDIR="/quux"
shortusage() {
cat <<EOF 1>&2
Usage: $(basename "$0") [ options ] JOB [ JOB ... ]
Pre-docker opts: -C dir -F cifile -D image -u user -M -L
Inside-docker opts: -m mount -N ciname
Job params/cmds: -V -E envfile -e key=val -x cmd
Miscellany: -h -Z -n -v
EOF
}
usage() {
cat <<EOF 1>&2
Usage: $(basename "$0") [ options ] JOB [ JOB ... ]
Options:
# Pre-docker options
-C dir Change working directory, default: '${CI_PROJECT_DIR}'
-D image Name of the docker image to use; default: look for
the 'default:' section, 'image:' assignment, fail
if not found
-u user User to use within docker; default: not set
-F cifile YAML file, default: '\${dir}/.gitlab-ci.yml'
-M Do not double-slash the mount point (which is
typically required on windows/wsl, and automatically
determined/added unless this argument is set)
-L List blocks (with scripts) in the YAML file and exit
# Control inside the docker image
-m mount Mount point (inside the container) for the project
directory; default '${MOUNTDIR}'
-N ciname Name of the project, according to GitLab CI, set as
'CI_PROJECT_NAME'; default: basename of cifile's dir
# Job parameters/commands
-V Do not include the 'variables:' section as EnvVars;
default behavior is to export all 'variables:'
section variables as EnvVars to the script
-E envfile File of EnvVars to include (not yet implemented)
-e key=val Additional EnvVars to be exported within the container
-x cmd Commands to run after EnvVars, before the job script
# Miscellany
-Z Continue to next job(s) even if a job fails
(returns a non-zero exit status); default is to exit
without running subsequent jobs if one fails
-n Dry run, implies -v
-v Verbose
Note: the .gitlab-ci.yml file must be strictly formatted for this
script to work; some requirements are not required by YAML, just for
this script:
- Indentation: jobs (and 'variables:', 'default:') must not be
indented, and first-level sub-sections ('script:', 'image:') must be
indented exactly 2 spaces;
- there can be no empty lines between the Job section name and the
last line of the desired 'script:' subsection; and
- 'script:' commands must be indented at least 4 spaces, and will be
unindented those 4 spaces; if there is also a dash/hyphen, it is
also removed
Examples: (assume three jobs available: 'Build', 'Test', and 'Coverage')
# list the available/found jobs with scripts
gitlabci -L
# run the current-directory project, normal '.gitlab-ci.yml',
# using the default image: in the yaml file
gitlabci Build
# set the project name and docker image explicitly
# run two tests concurrently, do not run Test if Build fails
gitlabci -N projname -D rocker/shiny-verse:4.1.2 Build Test
# troubleshoot: add two manual EnvVars, and prepend the 'set' shell
# command to run before the job itself runs
gitlabci -e EXTRAVAR=something -e QUUX=42 -x set Test
# continue to run Coverage even if Test fails
gitlabci -Z Test Coverage
EOF
}
err() {
echo -e "ERR: ${@}" 1>&2
exit 1
}
warn() {
echo -e "WARN: ${@}" 1>&2
}
verb() {
if [ -n "${VERBOSE}" ]; then
echo -e "${@}"
fi
}
countdown() {
if [ -z "${DRYRUN}" ]; then
for sec in $(seq 3 -1 0) ; do
echo -ne "\rContinuing in ${sec} seconds ..."
[ "${sec}" -gt 0 ] && sleep 1
done
echo
fi
}
unset CI_PROJECT_NAME CIFILE DOCKERIMAGE DRYRUN ENVVARS_SHOW ENVVARS_HIDE NO_CI_ENVVARS LISTTESTS VERBOSE PREBLOCK NOEXITIFFAIL DOCKERUSER
while getopts "C:D:u:LVE:e:F:t:Mm:N:x:nvZh" OPT ; do
case "${OPT}" in
C) CI_PROJECT_DIR="${OPTARG}" ;;
D) DOCKERIMAGE="${OPTARG}" ;;
u) DOCKERUSER="${OPTARG}" ;;
V) NO_CI_ENVVARS=1 ;;
E)
ENVFILE=$(< "${OPTARG}" )
NEWENVVARS_HIDE=$( echo "${ENVFILE}" | sed -E 's/^/export /' )
ENVVARS_HIDE="${ENVVARS_HIDE:+\n}${NEWENVVARS_HIDE}"
# a modest attempt at best at hiding passwords
NEWENVVARS_SHOW=$( echo "${ENVFILE}" | sed -E 's/^([^=]*)=.*/export \1=********/' )
ENVVARS_SHOW="${ENVVARS_SHOW:+\n}${NEWENVVARS_SHOW}"
;;
e)
ENVVARS_SHOW="${ENVVARS_SHOW:+}export ${OPTARG}"
;;
F) CIFILE="${OPTARG}" ;;
L) LISTTESTS=1 ;;
M) unset MOUNTDOUBLESLASH ;;
m) MOUNTDIR="${OPTARG}" ;;
N) CI_PROJECT_NAME="${OPTARG}" ;;
n)
DRYRUN=1
VERBOSE=1
;;
v) VERBOSE=1 ;;
x) PREBLOCK="${PREBLOCK}\n${OPTARG}" ;;
Z) NOEXITIFFAIL=1 ;;
h)
usage
exit 0
;;
*)
shortusage
exit 1
;;
esac
done
shift $((OPTIND-1))
if [ -z "${CIFILE}" ]; then
for cif in "${CI_PROJECT_DIR}/.gitlab-ci.yaml" "${CI_PROJECT_DIR}/.gitlab-ci.yml" ; do
if [ -e "${cif}" ]; then
CIFILE="${cif}"
break
fi
done
fi
if [[ ! "${MOUNTDIR}" == /* ]]; then
warn "Prepending '/' to the mount dir"
MOUNTDIR="/${MOUNTDIR}"
fi
if [ ! -f "${CIFILE}" ]; then
err "CI file not found: ${CIFILE}"
fi
if [ -z "${CI_PROJECT_NAME}" ]; then
CI_PROJECT_NAME=$(realpath "${CI_PROJECT_DIR}" | xargs basename)
fi
if [ -z "${CI_PROJECT_NAME}" ]; then
warn "CI_PROJECT_NAME is empty"
countdown
fi
if [ -z "${DOCKERIMAGE}" ]; then
DOCKERIMAGE=$( sed -nE '/^default:/,/^ *$/ { s/^ +image: *(.*)$/\1/p; }' "${CIFILE}" )
fi
if [ -n "${MOUNTDOUBLESLASH}" ]; then
if [ "${OSTYPE}" = "msys" ] || [ ! "${PATH}" = "${PATH//System32/}" ]; then
MOUNTDIR=$( echo -n "${MOUNTDIR}" | sed -e s,/,//,g )
fi
fi
if [ -n "${VERBOSE}" ]; then
for JOB in "${@}" ; do
ALLJOB="${ALLJOB}${ALLJOB:+, }'${JOB}'"
done
cat <<EOF
### gitlabci
# DOCKERIMAGE="${DOCKERIMAGE}"
# MOUNTDIR='${MOUNTDIR}'
# CIFILE='${CIFILE}'
# CI_PROJECT_DIR='${CI_PROJECT_DIR}'
# CI_PROJECT_NAME='${CI_PROJECT_NAME}'
# JOBS=${ALLJOB}
EOF
fi
if [ -n "${LISTTESTS}" ]; then
verb "### Jobs with scripts:"
sed -nE '/^[^ ]+:/,/^ *$/ { /^[^ ]+:/ { s/^([^:]*):.*/\1/;h;} ; /^ script:/{x;p;} ; }' "${CIFILE}"
exit 0
fi
if [ -z "${DRYRUN}" ] && [ -z "${DOCKERIMAGE}" ]; then
err "missing docker image, use '-D' to specify the docker image"
fi
if [ -z "${NO_CI_ENVVARS}" ]; then
PRE_ENVVARS="export CI_PROJECT_DIR=\"${MOUNTDIR}\"\nexport CI_PROJECT_NAME=\"${CI_PROJECT_NAME}\"\n"
CI_ENVVARS=$( sed -nE '/^variables:/,/^ *$/ { /^variables:/d; s/^ *([^ ]+) *: *([^ ]*)$/export \1=\2/p; }' "${CIFILE}" )
if [ -n "${CI_ENVVARS}" ]; then
CI_ENVVARS="${CI_ENVVARS}\n"
fi
fi
if [ -n "${ENVVARS_SHOW}" ]; then
ENVVARS_SHOW="${ENVVARS_SHOW}\n"
ENVVARS_HIDE="${ENVVARS_HIDE}\n"
fi
#ENVVARS="${PRE_ENVVARS}${CI_ENVVARS}${ENVVARS}"
# precheck all jobs, for convenience and option to interrupt
unset NOTFOUND
for JOB in "${@}" ; do
BLOCK=$( sed -nE '/'"${JOB}"':/,/^ *$/ { /^ script:/,/^ [-A-Za-z]/ { s/^ {4}[- ]{0,2}//p } }' "${CIFILE}" )
if [ -z "${BLOCK}" ]; then
NOTFOUND="${NOTFOUND}${NOTFOUND:+, }'${JOB}'"
fi
done
if [ -n "${NOTFOUND}" ]; then
warn "Could not find job(s): ${NOTFOUND}"
countdown
fi
if [ ! ${#@} -gt 0 ]; then
shortusage
exit
fi
CURDIR=$(pwd)
trap 'cd "${CURDIR}"' EXIT
cd "${CI_PROJECT_DIR}" || err "Could not cd to: '${CI_PROJECT_DIR}'"
# iterate through each JOB
for JOB in "${@}" ; do
echo -e "\n### ----------- Job: ${JOB}"
BLOCK=$( sed -nE '/'"${JOB}"':/,/^ *$/ { /^ script:/,/^ [-A-Za-z]/ { s/^ {4}[- ]{0,2}//p } }' "${CIFILE}" )
verb -e "${PRE_ENVVARS}${CI_ENVVARS}${ENVVARS_SHOW}\n${PREBLOCK}\n${BLOCK}"
if [ -n "${DRYRUN}" ]; then
continue
elif [ -n "${BLOCK}" ]; then
echo -e "${PRE_ENVVARS}${CI_ENVVARS}${ENVVARS_HIDE}\n${PREBLOCK}\n${BLOCK}" \
| docker run ${DOCKERUSER:+-u} ${DOCKERUSER} -v "$(pwd)":"${MOUNTDIR}" -w "${MOUNTDIR}" -i --rm "${DOCKERIMAGE}" bash
ret=$?
if [ $ret -ne 0 ]; then
if [ -n "${NOEXITIFFAIL}" ]; then
warn "### job failed ... but continuing anyway"
else
err "### job failed"
fi
fi
else
warn "### Job-script not found: '${JOB}'"
fi
done
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment