repos
/ orchard main

orchard

mirror

Every site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.

blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics

21.3 KB · 506 lines · Makefile Raw History
  1# orchard
  2#
  3# Everything is a make target, including the once-per-machine setup, so there is
  4# nothing here to invoke by hand. A machine that has never run this wants:
  5#
  6#   make install                         tunnel, secrets, containers, alerts
  7#
  8# Three cover the running system after that:
  9#
 10#   make up                              bring everything up, from nothing or from broken
 11#   make deploy SITE=blog.bythewood.me   rebuild one site and replace it
 12#   make edge                            rebuild and replace the edge itself
 13#   make doctor                          what is running, what is not, what to type
 14#
 15# And four for development, none of which touch Docker at all:
 16#
 17#   make run SITE=blog.bythewood.me      vite watch + go run, on :8000
 18#   make build SITE=blog.bythewood.me    release binary into bin/
 19#   make check                           gofmt, then vet and build every site
 20#   make test                            every site's tests
 21#
 22# `make help` lists the setup targets. Nothing below may contain $$(MAKE) inside
 23# a recipe that must not run under `make -n`, since GNU make runs any recipe
 24# line carrying that string even on a dry run.
 25#
 26# Every docker command goes through sudo, because the socket in the webdev
 27# container is root:root mode 660 and being in the docker group does not help.
 28# On a host where docker needs no sudo, turn it off:  make up SUDO=
 29#
 30# Each site is its own Go module and there is no module at this level, which is
 31# why every loop below runs its command inside the site directory. go.work
 32# exists so these repo-wide targets and an editor can see all six at once.
 33
 34SUDO   ?= sudo
 35DOCKER ?= $(SUDO) docker
 36
 37SITES    = $(notdir $(wildcard sites/*))
 38SITE_DIR = sites/$(SITE)
 39
 40# orchard-blog, orchard-analytics, and so on. Every compose file names its
 41# container after the site's first label, so the mapping needs no table and
 42# `docker ps --filter name=orchard` shows the whole system.
 43CONTAINER = orchard-$(firstword $(subst ., ,$(SITE)))
 44
 45EDGE_COMPOSE = cd edge && $(DOCKER) compose
 46
 47# Secrets come from a .env file in the site's own directory, which compose reads
 48# by itself because that directory is the project directory. Nothing is
 49# forwarded through this Makefile and nothing is read from the deploying shell,
 50# because sudo runs with env_reset and strips an exported password before
 51# compose ever sees it.
 52#
 53# .env is in .gitignore by bare name, so it is ignored at any depth. This is a
 54# public repository, so verify with `git check-ignore -v sites/<name>/.env`
 55# rather than trusting it. Each site that needs one commits a .env.example.
 56COMPOSE      = $(DOCKER) compose
 57COMPOSE_DOWN = $(DOCKER) compose
 58
 59.DEFAULT_GOAL := help
 60.PHONY: help install up up-one deploy edge doctor down down-one run build check fmt fmt-check vet test \
 61	env password tunnel tunnel-login tunnel-status ntfy ntfy-token ntfy-status ntfy-passwd \
 62	auth-init auth-recovery llm-key wiki require-site require-env require-tunnel
 63
 64help:
 65	@echo "running system"
 66	@echo "  make up                    bring everything up; safe to re-run, and the repair command"
 67	@echo "  make deploy SITE=<site>    rebuild one site and replace it"
 68	@echo "  make edge                  rebuild and replace caddy, ntfy and the tunnel"
 69	@echo "  make doctor                what is running, what is broken, what to type"
 70	@echo "  make down                  stop everything"
 71	@echo ""
 72	@echo "development, no docker involved"
 73	@echo "  make run SITE=<site>       vite watch + go run, on :8000"
 74	@echo "  make build SITE=<site>     release binary into bin/"
 75	@echo "  make check                 gofmt, then vet and build every site"
 76	@echo "  make test                  every site's tests"
 77	@echo ""
 78	@echo "once per machine"
 79	@echo "  make install               all of the below, in order, from nothing"
 80	@echo "  make tunnel-login          browser auth for one Cloudflare zone"
 81	@echo "  make tunnel                create the tunnel, route DNS, write config"
 82	@echo "  make env                   write every missing .env, passwords filled in"
 83	@echo "  make ntfy                  create the two alert accounts"
 84	@echo "  make ntfy-token            mint the publishers' tokens into the .env files"
 85	@echo "  make auth-init             create the login account, printing its recovery codes"
 86	@echo "  make llm-key NAME=chat     mint an api key for the model gateway, printed once"
 87	@echo "  make wiki                  download the offline wikipedia into its volume, 12.5GB"
 88	@echo "  make auth-recovery         replace the recovery codes when locked out"
 89	@echo ""
 90	@echo "  make password              print a suggested password, writing nothing"
 91	@echo "  make tunnel-status         what the tunnel has right now"
 92	@echo "  make ntfy-status           accounts, access and tokens"
 93	@echo "  make ntfy-passwd           change the reading account's password"
 94	@echo ""
 95	@echo "sites"
 96	@for s in $(SITES); do echo "  $$s"; done
 97
 98# ---------------------------------------------------------------- the system
 99
100# Idempotent, so it repairs as readily as it installs. It does not pass --build,
101# so an image that already exists is reused. Use `deploy` when code changed.
102up: require-tunnel
103	$(EDGE_COMPOSE) up --detach
104	for s in $(SITES); do \
105		$(MAKE) --no-print-directory up-one SITE=$$s || exit 1; \
106	done
107	@echo ""
108	$(MAKE) --no-print-directory doctor
109
110up-one: require-site require-env
111	cd $(SITE_DIR) && $(COMPOSE) up --detach
112
113# --force-recreate because compose will otherwise leave the old container in
114# place and still report success, which it did on 2026-08-31: the image built,
115# nothing was replaced, and the deploy read as done. The checks after it are
116# there because a deploy that says it worked and did not is worse than one that
117# fails loudly.
118deploy: require-site require-env
119	cd $(SITE_DIR) && $(COMPOSE) up --build --force-recreate --detach
120	@echo ""
121	@running=$$($(DOCKER) inspect $(CONTAINER) --format '{{.Image}}' 2>/dev/null); \
122	built=$$($(DOCKER) image inspect $(CONTAINER)-app --format '{{.Id}}' 2>/dev/null); \
123	if [ -z "$$running" ]; then \
124		echo "$(SITE): $(CONTAINER) is not there after the deploy" >&2; \
125		exit 1; \
126	fi; \
127	if [ -n "$$built" ] && [ "$$running" != "$$built" ]; then \
128		echo "$(SITE): $(CONTAINER) is still on the image it had, so the deploy did not take" >&2; \
129		exit 1; \
130	fi; \
131	st=none; \
132	i=0; \
133	while [ $$i -lt 45 ]; do \
134		st=$$($(DOCKER) inspect $(CONTAINER) --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' 2>/dev/null); \
135		case "$$st" in healthy|none) break ;; esac; \
136		i=$$((i + 1)); \
137		sleep 2; \
138	done; \
139	if [ "$$st" != "healthy" ] && [ "$$st" != "none" ]; then \
140		echo "$(SITE): $(CONTAINER) came up $$st" >&2; \
141		exit 1; \
142	fi; \
143	echo "$(SITE) rebuilt and replaced as $(CONTAINER), $$st"
144
145# The edge equivalent of deploy. The Caddyfile and ntfy's server.yml are baked
146# into images and `make up` does not pass --build, so editing one and running
147# `make up` is a quiet no-op.
148#
149# cloudflared is the exception and is restarted explicitly. Its config comes
150# from a volume rather than its image, so compose sees nothing changed and would
151# leave the tunnel serving the old ingress, where a newly added hostname 404s
152# with nothing to say why. Changing that config means `make tunnel` first.
153edge: require-tunnel
154	$(EDGE_COMPOSE) up --build --detach
155	$(DOCKER) restart orchard-cloudflared
156	@echo ""
157	@echo "edge rebuilt: caddy, ntfy, cloudflared"
158
159down:
160	for s in $(SITES); do \
161		$(MAKE) --no-print-directory down-one SITE=$$s || exit 1; \
162	done
163	$(EDGE_COMPOSE) down
164
165down-one: require-site
166	cd $(SITE_DIR) && $(COMPOSE_DOWN) down
167
168# Read only. Every line is either fine or carries the command that fixes it.
169# The alerts section reads ntfy's health endpoint rather than publishing, since
170# nobody runs a doctor that pushes a notification every time. To test the path
171# end to end:
172#
173#   sudo docker run --rm --network container:orchard-ntfy curlimages/curl \
174#     -d "test" http://127.0.0.1:8000/status
175#
176# `ps -a` rather than `ps`, so a container that exited reads as stopped rather
177# than vanishing from the report.
178#
179# Volume and container names are both read out of the compose files rather than
180# listed here, so a site that gains state, or a second service, is picked up
181# without editing this. search has two containers for that reason.
182doctor:
183	@probe() { \
184		st=$$($(DOCKER) ps -a --filter "name=^$$1$$" --format '{{.Status}}' 2>/dev/null | head -1); \
185		case "$$st" in \
186		"")          printf '  %-22s %-22s %s\n' "$$1" "not created" "$$2" ;; \
187		*unhealthy*) printf '  %-22s %-22s %s\n' "$$1" "unhealthy"   "$$2" ;; \
188		Up*)         printf '  %-22s %s\n'       "$$1" "$$st" ;; \
189		*)           printf '  %-22s %-22s %s\n' "$$1" "stopped"     "$$2" ;; \
190		esac; \
191	}; \
192	if $(DOCKER) volume inspect orchard-cloudflared >/dev/null 2>&1; then \
193		echo "tunnel   credentials present"; \
194	else \
195		echo "tunnel   NOT SET UP           -> make tunnel-login, then make tunnel"; \
196	fi; \
197	if $(DOCKER) network inspect orchard-edge >/dev/null 2>&1; then \
198		echo "network  orchard-edge up"; \
199	else \
200		echo "network  MISSING              -> make up, the edge stack owns it"; \
201	fi; \
202	echo ""; \
203	echo "edge"; \
204	probe orchard-caddy       "-> make up"; \
205	probe orchard-cloudflared "-> make up"; \
206	probe orchard-ntfy        "-> make up"; \
207	echo ""; \
208	echo "alerts"; \
209	if $(DOCKER) ps --filter "name=^orchard-ntfy$$" --format '{{.Names}}' 2>/dev/null | grep -q .; then \
210		if $(DOCKER) run --rm --network container:orchard-ntfy curlimages/curl:latest \
211			-s --max-time 5 http://127.0.0.1:8000/v1/health 2>/dev/null | grep -q '"healthy":true'; then \
212			printf '  %-22s answering, topics status, logging and auth\n' "ntfy"; \
213		else \
214			printf '  %-22s %-22s %s\n' "ntfy" "NOT ANSWERING" "-> docker logs orchard-ntfy"; \
215		fi; \
216	fi; \
217	echo ""; \
218	echo "sites"; \
219	for s in $(SITES); do \
220		for c in $$(awk '/^ *container_name:/{print $$2}' sites/$$s/docker-compose.yml); do \
221			if [ "$$c" = "orchard-wiki" ]; then \
222				probe "$$c" "-> make wiki, then make deploy SITE=$$s"; \
223			else \
224				probe "$$c" "-> make deploy SITE=$$s"; \
225			fi; \
226		done; \
227	done; \
228	echo ""; \
229	echo "ingest"; \
230	if $(DOCKER) ps --filter "name=^orchard-logging$$" --format '{{.Names}}' 2>/dev/null | grep -q .; then \
231		$(DOCKER) exec orchard-logging /app -healthcheck >/dev/null 2>&1 && \
232		out=$$($(DOCKER) run --rm --network container:orchard-logging curlimages/curl:latest \
233			-s --max-time 5 'http://127.0.0.1:8000/healthz?verbose' 2>/dev/null); \
234		if [ -n "$$out" ]; then \
235			age=$$(echo "$$out" | sed -n 's/.*"newest_record_age_s": *\([0-9]*\).*/\1/p'); \
236			failed=$$(echo "$$out" | sed -n 's/.*"failed": *\([0-9]*\).*/\1/p'); \
237			queued=$$(echo "$$out" | sed -n 's/.*"queued": *\([0-9]*\).*/\1/p'); \
238			if [ "$${failed:-0}" -gt 0 ]; then \
239				printf '  %-22s %-22s %s\n' "writes" "$$failed DISCARDED" "-> docker logs orchard-logging"; \
240			elif [ -n "$$age" ] && [ "$$age" -gt 900 ]; then \
241				printf '  %-22s %-22s %s\n' "freshness" "$${age}s STALE" "-> nothing has shipped in 15 min; check the sites"; \
242			else \
243				printf '  %-22s newest record %ss old, %s queued, 0 discarded\n' "logging" "$${age:-?}" "$${queued:-?}"; \
244			fi; \
245		else \
246			printf '  %-22s %-22s %s\n' "logging" "unreachable" "-> make deploy SITE=logging.bythewood.me"; \
247		fi; \
248	else \
249		printf '  %-22s %-22s %s\n' "logging" "not created" "-> make deploy SITE=logging.bythewood.me"; \
250	fi; \
251	echo ""; \
252	echo "login"; \
253	if $(DOCKER) ps --filter "name=^orchard-auth$$" --format '{{.Names}}' 2>/dev/null | grep -q .; then \
254		out=$$($(DOCKER) exec orchard-auth /app -check 2>/dev/null); \
255		case "$$out" in \
256		"not initialized") printf '  %-22s %-22s %s\n' "auth" "NOT INITIALIZED" "-> make auth-init" ;; \
257		*", 0 recovery"*|*", 1 recovery"*|*", 2 recovery"*) \
258			printf '  %-22s %-22s %s\n' "auth" "$$out" "-> nearly out; replace them on /security" ;; \
259		"")                printf '  %-22s %-22s %s\n' "auth" "unreachable" "-> docker logs orchard-auth" ;; \
260		*)                 printf '  %-22s %s\n' "auth" "$$out" ;; \
261		esac; \
262	else \
263		printf '  %-22s %-22s %s\n' "auth" "not created" "-> make deploy SITE=auth.bythewood.me"; \
264	fi; \
265	echo ""; \
266	echo "state"; \
267	sizes=$$($(DOCKER) system df -v 2>/dev/null | awk '/^VOLUME NAME/{v=1;next} v && NF==3{print $$1"="$$3}'); \
268	for s in $(SITES); do \
269		for vol in $$(awk '/^volumes:/{v=1;next} /^[a-z]/{v=0} v && /name:/{print $$2}' sites/$$s/docker-compose.yml); do \
270			if $(DOCKER) volume inspect $$vol >/dev/null 2>&1; then \
271				size=$$(echo "$$sizes" | sed -n "s/^$$vol=//p"); \
272				printf '  %-22s %s\n' "$$vol" "ok, $${size:-size unknown}"; \
273			elif [ "$$vol" = "orchard-wiki-data" ]; then \
274				printf '  %-22s %-22s %s\n' "$$vol" "MISSING" "-> make wiki"; \
275			else \
276				printf '  %-22s %-22s %s\n' "$$vol" "MISSING" "-> make deploy SITE=$$s"; \
277			fi; \
278		done; \
279	done
280
281# ------------------------------------------------------------------ the setup
282
283# Once per machine, in this order. Each step is also its own target, so a run
284# that stopped halfway wants the one that failed rather than all of it again.
285#
286# `up` has to come before `ntfy`, since the accounts are created inside a
287# running container, and the second `up` is what hands the freshly minted token
288# to the two sites that publish with it.
289install: tunnel-login tunnel env
290	$(MAKE) --no-print-directory up
291	$(MAKE) --no-print-directory ntfy
292	$(MAKE) --no-print-directory ntfy-token
293	$(MAKE) --no-print-directory up
294	$(MAKE) --no-print-directory auth-init
295	@echo ""
296	@echo "point the ntfy app at https://ntfy.bythewood.me with the reading"
297	@echo "account above, and subscribe to status, logging and auth."
298
299# A password out of /dev/urandom, in groups of eight so it can be read back off
300# a screen. Nothing is written, it is a suggestion to paste into 1Password.
301GEN_PASSWORD = LC_ALL=C tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 32 | sed 's/.\{8\}/&-/g; s/-$$//'
302
303password:
304	@$(GEN_PASSWORD); echo ""
305
306# A site needs a .env exactly when it ships a .env.example, and the passwords in
307# one are machine-local and never committed, so they are generated here rather
308# than invented. Only an empty *_PASSWORD is filled, which leaves NTFY_TOKEN for
309# `make ntfy-token` and REPOS_MIRROR unset, where unset means on.
310#
311# An existing .env is never touched. Rewriting one would sign every open session
312# out of repos and take the ntfy token with it.
313env:
314	@for ex in sites/*/.env.example; do \
315		d=$$(dirname "$$ex"); \
316		if [ -f "$$d/.env" ]; then \
317			echo "$$(basename $$d) has a .env already, left alone"; \
318			continue; \
319		fi; \
320		cp "$$ex" "$$d/.env"; \
321		chmod 600 "$$d/.env"; \
322		echo "$$(basename $$d)"; \
323		for var in $$(sed -n 's/^\([A-Z_]*_PASSWORD\)=$$/\1/p' "$$d/.env"); do \
324			pw=$$($(GEN_PASSWORD)); \
325			sed -i "s|^$$var=|$$var=$$pw|" "$$d/.env"; \
326			printf '  %-20s %s\n' "$$var" "$$pw"; \
327		done; \
328	done
329	@echo ""
330	@echo "those are the only copies, so put them in 1Password now."
331
332# The browser step, and it covers one Cloudflare zone at a time because cert.pem
333# carries a single zone. Two zones means running this again for the second and
334# then `make tunnel` again.
335tunnel-login:
336	@SUDO="$(SUDO)" sh edge/setup-tunnel.sh login
337
338tunnel:
339	@SUDO="$(SUDO)" sh edge/setup-tunnel.sh up
340
341tunnel-status:
342	@SUDO="$(SUDO)" sh edge/setup-tunnel.sh status
343
344# Both accounts, with generated passwords printed once. Needs orchard-ntfy
345# running, so it comes after `make up`.
346ntfy:
347	@SUDO="$(SUDO)" sh edge/setup-ntfy.sh up
348
349ntfy-token:
350	@SUDO="$(SUDO)" sh edge/setup-ntfy.sh token
351
352ntfy-status:
353	@SUDO="$(SUDO)" sh edge/setup-ntfy.sh status
354
355ntfy-passwd:
356	@SUDO="$(SUDO)" sh edge/setup-ntfy.sh passwd
357
358# The snapshot behind chat's wikipedia tool. Too big for git and too big for an
359# image, so it is downloaded into the volume once and left there.
360#
361# The checksum is checked here rather than found at startup, because kiwix
362# refuses a damaged file with "Unable to add the ZIM file", which reads like a
363# path or a permissions problem and is not. A resumed download is what produced
364# one, so this writes a .part and only moves it into place once it matches.
365WIKI_ZIM = wikipedia_en_all_mini_2026-06.zim
366WIKI_SHA = 1d0f8178709481c831272d95f95dccc030e9193e38e732b86b1938ae2606226e
367
368wiki:
369	@$(SUDO) docker volume create orchard-wiki-data >/dev/null
370	@$(SUDO) docker run --rm -v orchard-wiki-data:/data alpine sh -c '\
371		if [ -f /data/wikipedia.zim ] && \
372		   echo "$(WIKI_SHA)  /data/wikipedia.zim" | sha256sum -c - >/dev/null 2>&1; then \
373			echo "the snapshot is already there and matches"; \
374			exit 0; \
375		fi; \
376		echo "downloading $(WIKI_ZIM), 12.5GB, this takes a while"; \
377		rm -f /data/wikipedia.zim.part; \
378		wget -O /data/wikipedia.zim.part "https://download.kiwix.org/zim/wikipedia/$(WIKI_ZIM)" && \
379		echo "$(WIKI_SHA)  /data/wikipedia.zim.part" | sha256sum -c - && \
380		mv /data/wikipedia.zim.part /data/wikipedia.zim && \
381		echo "in place" || { \
382			rm -f /data/wikipedia.zim.part; \
383			echo "the download failed or did not match its checksum, nothing was replaced"; \
384			exit 1; \
385		}'
386	@echo "now: make deploy SITE=chat.bythewood.me"
387
388# The login account, and the recovery codes that are what the first sign in
389# uses. It needs orchard-auth running, so it comes after `make up`, and it is
390# idempotent: an account that already exists keeps the codes somebody wrote
391# down rather than being handed a fresh set that was never applied.
392#
393# The binary generates them rather than this file, because only it can write the
394# Argon2id hashes, and they must never be generated at boot and printed to
395# stdout, since container stdout ships to logging.bythewood.me.
396auth-init:
397	@$(DOCKER) ps --filter "name=^orchard-auth$$" --format '{{.Names}}' 2>/dev/null | grep -q . || { \
398		echo "orchard-auth is not running:" >&2; \
399		echo "" >&2; \
400		echo "  make up" >&2; \
401		exit 1; \
402	}
403	@$(DOCKER) exec orchard-auth /app -init
404
405# The first key for a service, before there is a browser session to make one in.
406# llm.bythewood.me's own UI is behind auth, which is reached over the tunnel this
407# gateway feeds, so there has to be a way in that does not need any of that
408# working yet. The plaintext is printed once and nothing keeps it.
409llm-key:
410	@test -n "$(NAME)" || { \
411		echo "which service is the key for?" >&2; \
412		echo "" >&2; \
413		echo "  make llm-key NAME=chat" >&2; \
414		exit 1; \
415	}
416	@$(DOCKER) ps --filter "name=^orchard-llm$$" --format '{{.Names}}' 2>/dev/null | grep -q . || { \
417		echo "orchard-llm is not running:" >&2; \
418		echo "" >&2; \
419		echo "  make up" >&2; \
420		exit 1; \
421	}
422	@$(DOCKER) exec orchard-llm /app -newkey "$(NAME)"
423
424# The way back in when there are no recovery codes left and ntfy or the tunnel
425# is down, so the browser cannot reach a sign in. It needs the Docker socket,
426# which is the point: nothing about this account has to be written down, because
427# every credential in it can be replaced from the machine.
428auth-recovery:
429	@$(DOCKER) exec orchard-auth /app -recovery
430
431# ----------------------------------------------------------------- the guards
432
433# No default SITE, so a bare `make deploy` asks rather than rebuilding and
434# replacing whichever site happened to be first.
435require-site:
436	@test -n "$(SITE)" || { \
437		echo "SITE is not set. one of:" >&2; \
438		for s in $(SITES); do echo "  make $(firstword $(MAKECMDGOALS)) SITE=$$s" >&2; done; \
439		exit 1; \
440	}
441	@test -d "$(SITE_DIR)" || { \
442		echo "there is no site called '$(SITE)'. one of:" >&2; \
443		for s in $(SITES); do echo "  $$s" >&2; done; \
444		exit 1; \
445	}
446
447# A site needs a .env exactly when it ships a .env.example, so adding a secret
448# is one committed example file and no change here. Compose would catch it too,
449# but it reports an unset variable, which reads like a bug in the compose file.
450require-env:
451	@if [ -f "$(SITE_DIR)/.env.example" ] && [ ! -f "$(SITE_DIR)/.env" ]; then \
452		echo "$(SITE) has no .env. it is gitignored and machine-local, so a" >&2; \
453		echo "fresh checkout never has one:" >&2; \
454		echo "" >&2; \
455		echo "  make env" >&2; \
456		echo "" >&2; \
457		echo "that writes one for every site that is missing it, with a" >&2; \
458		echo "generated password in each, and prints them. if this machine is" >&2; \
459		echo "rejoining something that already exists, the old values are in" >&2; \
460		echo "1Password and go in by hand instead." >&2; \
461		exit 1; \
462	fi
463
464# The tunnel's credentials live in a named volume that setup-tunnel.sh creates.
465# Without it compose fails with "external volume not found", which does not say
466# what to do about it.
467require-tunnel:
468	@$(DOCKER) volume inspect orchard-cloudflared >/dev/null 2>&1 || { \
469		echo "the tunnel is not set up on this machine yet. once, in order:" >&2; \
470		echo "" >&2; \
471		echo "  make tunnel-login" >&2; \
472		echo "  make tunnel" >&2; \
473		exit 1; \
474	}
475
476# ------------------------------------------------------------------- the code
477
478run: require-site
479	$(MAKE) -C $(SITE_DIR) run
480
481build: require-site
482	$(MAKE) -C $(SITE_DIR) build
483
484# -o build/ matters. A bare `go build ./...` drops each site's executable into
485# the working directory, and build/ is gitignored.
486# check reports and does not repair, so it must not depend on fmt, which runs
487# gofmt -w and would make the answer yes by rewriting. Use `make fmt` for that.
488check: fmt-check vet
489	for s in $(SITES); do echo "build $$s"; \
490		(cd sites/$$s && mkdir -p build && go build -o build/ ./...) || exit 1; done
491
492fmt-check:
493	@out=$$(gofmt -l sites); \
494	if [ -n "$$out" ]; then \
495		echo "not gofmt clean, run make fmt:"; echo "$$out"; exit 1; \
496	fi
497
498fmt:
499	gofmt -l -w sites
500
501vet:
502	for s in $(SITES); do echo "vet $$s"; (cd sites/$$s && go vet ./...) || exit 1; done
503
504test:
505	for s in $(SITES); do echo "test $$s"; (cd sites/$$s && go test ./...) || exit 1; done