orchard
mirrorEvery 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
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