Skip to content

Instantly share code, notes, and snippets.

@onecrayon
Last active February 4, 2024 12:30
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save onecrayon/deec0fc13128be7eb0b5c19f767f732c to your computer and use it in GitHub Desktop.
Save onecrayon/deec0fc13128be7eb0b5c19f767f732c to your computer and use it in GitHub Desktop.
Load dotenv files into Make environment
# This comment shouldn't affect anything
SINGLE_QUOTED='just a string'
DOUBLE_QUOTED="another string"
UNQUOTED=don't strip my quote!
DOLLARS=$3.50
MULTILINE_SINGLE_QUOTE='[
"hello",
"world"
]'
MULTILINE_DOUBLE_QUOTE="{
\"look_ma": \"it's JSON\"
}"
# RUH-ROH A TAB!
# EOF
# This is an example Makefile stub that demonstrates how to load variables from a dotenv file into
# your Make environment. Note that it isn't perfect; for instance, the double-quoted JSON string
# in the example file will probably not parse correctly because the escape characters will remain
# as part of the string when it is passed through Make, non-JSON multiline strings will cause the
# file to fail to parse with an obscure Make error, and some advanced features such as interpolation
# are not supported.
# I always start my Makefiles with this command, because then they're effectively self-documenting
help: cmd-exists-grep cmd-exists-sed
@grep -F -h "##" $(MAKEFILE_LIST) | grep -F -v @grep | sed -e 's/\\$$//' | sed -e 's/: [a-zA-Z0-9_][ a-zA-Z0-9_-]*[a-zA-Z0-9]\([[:space:]]*\)##/:\1##/' | sed -e 's/## //' -e 's/##//'
# This snazzy little target allows dynamically defining a variable that is required by a given target.
# For instance, `some-target: guard-ENV` would ensure that the ENV variable is defined before executing
# the `some-target` make target. Source: https://lithic.tech/blog/2020-05/makefile-wildcards
guard-%:
@if [ -z '${${*}}' ]; then echo 'ERROR: environment variable $* not set' && exit 1; fi
# Similar to the above (and with the same source), although in this instance we ensure a particular
# command exists in the user's PATH.
cmd-exists-%:
@hash $(*) > /dev/null 2>&1 || \
(echo "ERROR: '$(*)' must be installed and available on your PATH."; exit 1)
# This pair of targets relies on sed and awk, but allows loading dotenv environment files into
# environment variables by passing through Make. This is necessary for mixing specific variables
# from one environment into local execution or alternately for grabbing specific variables for
# use in deployment-based Make targets.
#
# This logic assumes that you have environment-specific files like `dev.env` or `prod.env`.
# You'll need to adjust paths in the targets to match your actual usage, as necessary.
#
# This logic could also be used to parse an actual `.env` file with a little tweaking.
#
# Thanks to its reliance on sed, the logic is completely opaque so let's break it down:
#
# 1. `prep-tmp-env`: this target uses sed to generate a file like ENV.env.tmp which
# can be safely parsed as Make variables. This is an unusual step, but is necessary because
# our environment files might rely on quoted strings, have dollar signs in values, or include
# multi-line strings. We must route through sed three times; the first, we handle changes that
# apply within a single line. The second and third we handle multiline strings (have to do it
# twice to grab both quotation types). Aside from sed's opaque syntax, this is complicated by
# the need to double escape all dollar signs lest Make transform them into variable lookups.
#
# Sed commands explained:
#
# * 's/\$$/$$$$/g' (actually 's/\$/$$/g'): replace all single dollar signs with double dollar signs
# * 's/="\(.*\)"$$/=\1/': strip double quotes from values (on a single line). Capture group
# parentheses must be marked with a backslash. `\1` is a back-reference.
# * 's/='"'"'\(.*\)'"'"'$$/=\1/': strip single quotes from values (on a single line). This is
# complicated by the fact that we can't include an escaped single quote without shell string
# concatenation (e.g. we close the single quoted string, open a double quoted string with a
# single quote in it, and then re-open the single quoted string leading to '"'"')
# * '/^[[:space:]]*$$/d': delete all lines that are nothing but whitespace. Not technically
# necessary, but it ensures that stray tab characters don't screw with us.
# * -e :a -e N -e '$$!ba': this based on a recipe from https://stackoverflow.com/a/1252191/38666
# which effectively concatenates every line in the file so that we can run replacements based
# on start and end delimiters. Uses multiple `-e` commands to run on macOS.
# * 's/\([a-zA-Z0-9_]*\)="\[\([^]]*\)\]"/define \1\n[\2]\nendef/g' (and the other similar variants):
# These find multi-line JSON-like strings and replaces them with Makefile `define VAR_NAME ... endef`
#
# 2. `load-env`: this target includes the temporary file as a nested Makefile (effectively
# loading everything in it into local variables), parses the file with sed to grab
# the variable names, exports those as environment variables, and then cleans up the
# temporary file.
#
# Sed and awk commands explained:
#
# * '/^\(define [a-zA-Z0-9_]\|[a-zA-Z0-9_]*=\)/!d': this deletes all lines that don't start
# with either `define VARIABLE` or `VARIABLE=`.
# * 's/=.*//': this removes everything after an equals sign, leaving the variable names
# * 's/^define //': this strips out the `define ` keyword
# * awk 1 ORS=' ': sourced from https://stackoverflow.com/a/14853319/38666 this strips
# all line breaks from the output, leaving a space-delimited list of variable names
prep-tmp-env: guard-ENV cmd-exists-sed
@sed -e 's/\$$/$$$$/g' -e 's/="\(.*\)"$$/=\1/' -e 's/='"'"'\(.*\)'"'"'$$/=\1/' -e '/^[[:space:]]*$$/d' $(ENV).env \
| sed -e :a -e N -e '$$!ba' \
-e 's/\([a-zA-Z0-9_]*\)='"'"'\[\([^]]*\)\]'"'"'/define \1\n[\2]\nendef/g' \
-e 's/\([a-zA-Z0-9_]*\)='"'"'{\([^}]*\)}'"'"'/define \1\n[\2]\nendef/g' \
-e 's/\([a-zA-Z0-9_]*\)="\[\([^]]*\)\]"/define \1\n[\2]\nendef/g' \
-e 's/\([a-zA-Z0-9_]*\)="{\([^}]*\)}"/define \1\n[\2]\nendef/g' \
> $(ENV).env.tmp
load-env: prep-tmp-env guard-ENV cmd-exists-sed cmd-exists-awk cmd-exists-rm
@echo "Using env: $(ENV).env"
$(eval include $(ENV).env.tmp)
$(eval export $(shell sed -e '/^\(define [a-zA-Z0-9_]\|[a-zA-Z0-9_]*=\)/!d' -e 's/=.*//' -e 's/^define //' $(ENV).env.tmp | awk 1 ORS=' '))
@rm $(ENV).env.tmp
test-env: load-env ## Verify those environment variables! Try `make test-env ENV=example`
@echo SINGLE_QUOTED="$$SINGLE_QUOTED"
@echo DOUBLE_QUOTED="$$DOUBLE_QUOTED"
@echo UNQUOTED="$$UNQUOTED"
@echo DOLLARS="$$DOLLARS"
@echo MULTILINE_SINGLE_QUOTE="$$MULTILINE_SINGLE_QUOTE"
@echo MULTILINE_DOUBLE_QUOTE="$$MULTILINE_DOUBLE_QUOTE"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment