Last active
January 28, 2022 14:34
-
-
Save r2evans/aed2053eebf5435223b54f0d93c2a933 to your computer and use it in GitHub Desktop.
Run GitLab CI jobs locally
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/bin/bash | |
# gitlabci, v0.2, 2022-01-28 | |
# Run GitLab-CI jobs (e.g., tests, coverage), locally. | |
# (c) 2022 Bill Evans [email protected] | |
# 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