# Zoomies
#
# The Go binary is fully usable without Node. Node is a build-time dependency
# only, wrapped by `make ui`, and the built assets are embedded with go:embed.
SHELL := /bin/bash
.SHELLFLAGS := -eu -o pipefail -c
.DEFAULT_GOAL := help
# Without the leading v: the tag is v1.2.3, the version the binary reports is
# 1.2.3, and the release workflow stamps the images the same way.
VERSION ?= $(patsubst v%,%,$(shell git describe --tags --always --dirty 2>/dev/null || echo dev))
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
PKG := github.com/eyupio/zoomies
LDFLAGS := -s -w \
-X $(PKG)/internal/version.Version=$(VERSION) \
-X $(PKG)/internal/version.Commit=$(COMMIT) \
-X $(PKG)/internal/version.Date=$(DATE)
BIN := zoomies
DIST := dist
UI_DIR := web
UI_OUT := internal/api/webdist
RUNNER_VERSION ?= 2.338.2
GO ?= go
NPM ?= npm
# Package-wide budget, including race instrumentation and SQLite migrations.
# Sized for the fleet runner's one-CPU share with -coverpkg, where the API
# package alone has taken 38 minutes and the controller package is larger:
# a package that hits this limit is hung, not slow.
GO_TEST_TIMEOUT ?= 120m
# The packages a test target runs. CI splits the suite by package across
# runners -- the controller, API and store packages are each a large share of
# the whole -- and this is how a shard says which share it is. Local runs
# leave it alone and get everything.
TEST_PKGS ?= ./...
# A -run regexp, for CI to run half of one package on each runner.
TEST_RUN ?=
# What `make bench` runs: every package that has a benchmark, found rather than
# listed so that a new one is not left out of a list that went stale, six runs
# of each so that benchstat can say how far apart they were, with the
# allocations beside the times. BENCH narrows it by benchmark name (a -bench
# regexp, matched a slash-separated part at a time, so BENCH=Decide/quiet works;
# leave out any $, which make would read as one of its own), BENCH_PKGS by
# package, and BENCH_COUNT sets the runs. go test runs the packages of a -bench
# run one at a time, because benchmarks that share the CPU contaminate each
# other; leave the machine alone while it works.
BENCH ?= .
BENCH_COUNT ?= 6
BENCH_PKGS ?= $(sort $(patsubst %/,./%,$(dir $(shell grep -rl --include='*_test.go' '^func Benchmark' cmd internal))))
##@ Build
.PHONY: build
build: ui ## Build the binary with the UI embedded
CGO_ENABLED=0 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o $(BIN) ./cmd/zoomies
.PHONY: build-nogui
build-nogui: $(UI_OUT)/.placeholder ## Build without rebuilding the UI (fast inner loop)
CGO_ENABLED=0 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o $(BIN) ./cmd/zoomies
# The placeholder alone, with nothing compiled: what CI needs before go vet,
# go test or a scanner can load internal/api. Building the binary to get it
# cost a cold compile of every dependency on an ephemeral runner, minutes of
# work in a step that only has to write one file.
.PHONY: ui-stub
ui-stub: $(UI_OUT)/.placeholder ## Write the placeholder UI that go:embed needs, and build nothing
$(UI_OUT)/.placeholder:
@mkdir -p $(UI_OUT)
@# go:embed fails on an empty directory, so keep a placeholder for builds
@# that skip the UI. `make ui` overwrites the directory wholesale, and with
@# it this marker, so the marker's absence does not mean the UI is absent:
@# write the page only when there is none, or a test run after a real build
@# would replace the built UI with the placeholder.
@test -e $(UI_OUT)/index.html || printf '
ZoomiesThe UI was not built. Run make ui.' > $(UI_OUT)/index.html
@touch $@
.PHONY: ui
ui: ## Build the Svelte UI into the embed directory
cd $(UI_DIR) && $(NPM) ci --no-audit --no-fund
cd $(UI_DIR) && $(NPM) run build
.PHONY: ui-dev
ui-dev: | $(UI_DIR)/node_modules ## Run the Vite dev server against a controller on :8080
cd $(UI_DIR) && $(NPM) run dev
.PHONY: dist
dist: ui ## Cross-compile release binaries into dist/
@mkdir -p $(DIST)
@for platform in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64; do \
os=$${platform%/*}; arch=$${platform#*/}; \
case $$os in windows) ext=.exe ;; *) ext= ;; esac; \
echo " building $$os/$$arch"; \
CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch $(GO) build -trimpath \
-ldflags "$(LDFLAGS)" -o $(DIST)/$(BIN)_$${os}_$${arch}$$ext ./cmd/zoomies; \
done
cd $(DIST) && sha256sum $(BIN)_* > checksums.txt
@echo " wrote $(DIST)/checksums.txt"
##@ Test
.PHONY: test
test: | $(UI_OUT)/.placeholder ## Run Go unit and integration tests
$(GO) test -race -count=1 -timeout $(GO_TEST_TIMEOUT) $(if $(TEST_RUN),-run '$(TEST_RUN)') $(TEST_PKGS)
.PHONY: test-short
test-short: | $(UI_OUT)/.placeholder ## Run only fast tests
$(GO) test -short -count=1 ./...
.PHONY: bench
bench: | $(UI_OUT)/.placeholder ## Run the benchmarks, six runs each (BENCH, BENCH_PKGS, BENCH_COUNT narrow it); compare two runs with benchstat
$(GO) test -run '^$$' -bench '$(BENCH)' -benchmem -count=$(BENCH_COUNT) -timeout $(GO_TEST_TIMEOUT) $(BENCH_PKGS)
.PHONY: cover
cover: | $(UI_OUT)/.placeholder ## Run tests with a coverage report
# -coverpkg is what makes the number honest. Without it a package is only
# credited for the lines its own _test.go files run, so code exercised
# through the layer above it -- the controller's migration service, driven
# entirely by internal/api's handler tests -- reports zero and sends
# whoever reads the dashboard to write tests for the best-covered file in
# the tree.
$(GO) test -race -count=1 -timeout $(GO_TEST_TIMEOUT) -coverpkg=./... -coverprofile=coverage.out -covermode=atomic $(TEST_PKGS)
$(GO) tool cover -func=coverage.out | tail -1
# One shard of the coverage run, in the binary format `go tool covdata` merges.
# A text profile cannot be joined to another one line by line -- the same block
# appears in each with its own count -- so a shard leaves its counters in
# COVERDIR and `cover-merge` turns the set into the one coverage.out that
# `make cover` writes directly.
.PHONY: cover-shard
cover-shard: | $(UI_OUT)/.placeholder ## Run one shard of the coverage run into COVERDIR (see cover-merge)
@test -n "$(COVERDIR)" || { echo "cover-shard needs COVERDIR="; exit 2; }
@mkdir -p $(COVERDIR)
$(GO) test -race -count=1 -timeout $(GO_TEST_TIMEOUT) -cover -coverpkg=./... -covermode=atomic $(if $(TEST_RUN),-run '$(TEST_RUN)') $(TEST_PKGS) -args -test.gocoverdir=$(abspath $(COVERDIR))
.PHONY: cover-merge
cover-merge: ## Merge every cover-shard directory under COVERDIRS into coverage.out
@test -n "$(COVERDIRS)" || { echo "cover-merge needs COVERDIRS=,,..."; exit 2; }
$(GO) tool covdata textfmt -i=$(COVERDIRS) -o coverage.out
$(GO) tool cover -func=coverage.out | tail -1
.PHONY: test-unit
test-unit: | $(UI_DIR)/node_modules ## Run the UI's unit tests (Node's own runner, no browser, a few seconds)
cd $(UI_DIR) && $(NPM) run test:unit
.PHONY: test-ui
test-ui: ## Run the Playwright suite (builds the binary first)
# Built as CI builds it, which is a plain `go build` with no version stamp
# and so the "dev" default. A working tree is normally dirty while the UI
# is being worked on, and `git describe --dirty` then stamps a version that
# is neither a release nor a main build -- so the controller correctly
# declines to pin new hosts to a channel that does not exist, and the hosts
# spec, which asserts the pin CI produces, fails for no reason anyone can
# see from the diff they are testing.
$(MAKE) build VERSION=dev
cd $(UI_DIR) && $(NPM) run test:e2e
.PHONY: test-e2e
test-e2e: | $(UI_OUT)/.placeholder ## Docker-based end-to-end test; needs GitHub credentials, skipped without
$(GO) test -count=1 -tags e2e -timeout 40m ./test/e2e/...
.PHONY: test-eli-eval
test-eli-eval: | $(UI_OUT)/.placeholder ## Play Eli's evaluation scenarios against a real provider; needs ELI_EVAL_URL, ELI_EVAL_KEY and ELI_EVAL_MODEL, skipped without
$(GO) test -count=1 -v -run 'TestEliScenariosAgainstARealProvider' ./internal/api/
.PHONY: test-e2e-proxmox
test-e2e-proxmox: ## Live Proxmox qualification: needs a cluster and ZOOMIES_PROXMOX_*, skipped without. Hours, not minutes -- not in CI.
$(GO) test -count=1 -v -tags e2e -timeout 10h ./test/e2e/proxmox/...
.PHONY: test-drill
test-drill: build-nogui ## Runtime drills: the built binary as a real controller and agent, against a fake GitHub
$(GO) test -count=1 -v -tags drill -timeout 20m ./test/drill/...
# Both binaries land under dist/, which is already ignored: a fixed directory
# rather than a mktemp -d so a failed run leaves the two binaries that produced
# it where they can be run again by hand.
UPGRADE_DIR := $(DIST)/upgrade
.PHONY: test-upgrade
test-upgrade: build-nogui ## Install the last published release, then upgrade it in place to this build; then run the update helper against a fake systemctl
@mkdir -p $(UPGRADE_DIR)/old
$(GO) build -o $(UPGRADE_DIR)/new/zoomies ./cmd/zoomies
sh install.sh --no-init --yes --prefix $(UPGRADE_DIR)/old
sh test/upgrade/upgrade-check.sh $(UPGRADE_DIR)/old/zoomies $(UPGRADE_DIR)/new/zoomies
GO=$(GO) sh test/upgrade/helper-check.sh
# The load measurement writes evidence, not an exit code: the figures are
# recorded and compared with the last run, never turned into a threshold a
# slower runner would fail a pull request on.
# Absolute, because `go test` runs in the package's own directory.
LOAD_RECORD ?= $(CURDIR)/roadmap/validation/load-$(shell git rev-parse --short HEAD).md
.PHONY: measure
measure: | $(UI_OUT)/.placeholder ## Build a fleet with a month of history and time the reads a page makes
ZOOMIES_LOAD_RECORD=$(LOAD_RECORD) \
$(GO) test -count=1 -v -tags load -timeout 20m ./test/load/...
@echo "record: $(LOAD_RECORD)"
E2E_RESULTS ?= roadmap/validation/e2e
.PHONY: test-e2e-required
test-e2e-required: ## The same run, but a missing prerequisite is a failure and every scenario must pass
rm -rf $(E2E_RESULTS)
ZOOMIES_E2E_REQUIRED=1 ZOOMIES_E2E_RESULTS_DIR=$(E2E_RESULTS) \
$(GO) test -count=1 -v -tags e2e -timeout 40m ./test/e2e/... || true
$(GO) run ./test/e2e/verify -dir $(E2E_RESULTS)
.PHONY: screenshots
screenshots: build ## Recapture docs/screenshots from the real UI (needs Pillow: pip install pillow)
cd $(UI_DIR) && node tests/support/screenshots.mjs
.PHONY: screenshots-gallery
screenshots-gallery: | $(UI_DIR)/node_modules ## Recapture the component gallery in both styles and modes (needs Pillow)
cd $(UI_DIR) && node tests/support/gallery-screenshots.mjs
# The UI's dependencies, as an order-only prerequisite of anything that checks
# the UI.
#
# Without them `prettier --check` fails with "Cannot find package
# 'prettier-plugin-svelte'", which reads like an npm hiccup and is easy to
# scroll past -- and the UI is then not checked at all. That is the one check
# that matters for a change to .svelte files, and it went unrun twice in a
# fresh worktree before anybody noticed. So a missing tree is installed rather
# than reported: the failure mode of this step should be slow, not silent.
#
# Order-only, so an install already there is never redone because something
# touched its mtime.
$(UI_DIR)/node_modules:
cd $(UI_DIR) && $(NPM) ci --no-audit --no-fund
##@ Quality
.PHONY: lint
lint: | $(UI_DIR)/node_modules $(UI_OUT)/.placeholder ## Vet, format check, and staticcheck when available
$(GO) vet ./...
@$(MAKE) --no-print-directory vet-tags
@out=$$(gofmt -l $$(git ls-files '*.go')); \
if [ -n "$$out" ]; then echo "gofmt needed:"; echo "$$out"; exit 1; fi
@if command -v staticcheck >/dev/null 2>&1; then staticcheck ./...; \
else echo " staticcheck not installed, skipping"; fi
cd $(UI_DIR) && $(NPM) run lint
# The tagged suites. An ordinary `go vet ./...` never compiles them, which is how
# test/e2e sat with a non-test file referring to a symbol only its _test.go half
# defined: every gate was green and the package did not build under its own
# tag. Vet rather than build, because vet compiles the tests as well and it is
# the test halves that carry most of this code. CI runs this target too, and a
# test in internal/docs fails when a build tag is used that it does not cover.
.PHONY: vet-tags
vet-tags: | $(UI_OUT)/.placeholder ## Vet the suites behind a build tag (e2e, drill, load), which a plain go vet never compiles
$(GO) vet -tags e2e ./...
$(GO) vet -tags drill ./...
$(GO) vet -tags load ./...
.PHONY: fmt
fmt: | $(UI_DIR)/node_modules ## Format Go and UI sources
gofmt -w $$(git ls-files '*.go')
cd $(UI_DIR) && $(NPM) run format
.PHONY: tidy
tidy: ## Tidy go.mod
$(GO) mod tidy
##@ Run
.PHONY: run
run: build-nogui ## Run a controller against a local database
./$(BIN) controller --config ./zoomies.yaml
.PHONY: dev
dev: build-nogui ## Run a controller with auth off, for local UI development
ZOOMIES_DISABLE_AUTH=true ZOOMIES_LOG_FORMAT=text ZOOMIES_LOG_LEVEL=debug \
ZOOMIES_DB_PATH=./zoomies.db ./$(BIN) controller
.PHONY: gallery
gallery: | $(UI_DIR)/node_modules ## Serve the development-only component gallery on :5173/gallery.html
@echo "component gallery: http://127.0.0.1:5173/gallery.html?style=mission-control&mode=dark"
cd $(UI_DIR) && $(NPM) run dev
##@ Images
.PHONY: image
image: ## Build the controller image (deploy/Dockerfile, target controller)
docker build -f deploy/Dockerfile --target controller \
--build-arg VERSION=$(VERSION) --build-arg COMMIT=$(COMMIT) --build-arg DATE=$(DATE) \
-t ghcr.io/eyupio/zoomies:$(VERSION) -t ghcr.io/eyupio/zoomies:latest .
# The bounded Git worker conflict repair merges in. It is not published yet: no
# workflow names the target, so nothing pushes it to GHCR and nothing would
# notice if its base image moved. The Dockerfile's last step in the target runs
# the worker's own toolchain check, so a build that succeeds is a worker whose
# Git can merge.
.PHONY: image-gitworker
image-gitworker: ## Build the Git worker image (deploy/Dockerfile, target gitworker)
docker build -f deploy/Dockerfile --target gitworker \
--build-arg VERSION=$(VERSION) --build-arg COMMIT=$(COMMIT) --build-arg DATE=$(DATE) \
-t ghcr.io/eyupio/zoomies-git:$(VERSION) .
.PHONY: image-nixpacks
image-nixpacks: ## Build the controller image from source with Nixpacks (needs the nixpacks CLI)
@command -v nixpacks >/dev/null 2>&1 || { \
echo "nixpacks is not installed: https://nixpacks.com/docs/install"; exit 1; }
nixpacks build . --name ghcr.io/eyupio/zoomies:$(VERSION)-nixpacks
# The runner image's variants. Each one is a base image, the package family
# deploy/Dockerfile.runner installs with, and the platform it claims to be.
#
# The rows below are generated from internal/naming's catalogue, which is the
# one place an operating system is added or swapped. Do not edit them by hand:
# run `make generate` and commit what it writes.
RUNNER_IMAGE ?= ghcr.io/eyupio/zoomies-runner
RUNNER_VARIANT ?= $(RUNNER_VARIANT_DEFAULT)
# zoomies:catalogue-begin
RUNNER_VARIANTS := ubuntu-2404 ubuntu-2604 ubuntu-2204 debian-13 debian-12 fedora-42 rocky-9
RUNNER_VARIANT_DEFAULT := ubuntu-2404
# base | family | os | version
variant.ubuntu-2404 := ubuntu:24.04@sha256:224a1869083a311ef3f13648a154ba79832fbef6364d31493642ca03082da254 apt ubuntu 24.04
variant.ubuntu-2604 := ubuntu:26.04@sha256:da6fc2be547864451aa253836dd926da33623312df4a9a243e35dc877c378a78 apt ubuntu 26.04
variant.ubuntu-2204 := ubuntu:22.04@sha256:829f6df217bcbae2b371026e81711d1a787c61b2967ad09d015063663ebafbf7 apt ubuntu 22.04
variant.debian-13 := debian:13-slim@sha256:a99cfc517144bc59b1978475ec53b46ecabec7e43635402ee5b77cc54cd1b20a apt debian 13
variant.debian-12 := debian:12-slim@sha256:88200866dfff7ea7f5cbcb6ec7c8a701889efe6fe859fe64d6990e4b07ea4171 apt debian 12
variant.fedora-42 := fedora:42@sha256:99e203b80b1c3d8f7e161ec10a68fd02b081ef83a3963553e513c82846b97814 dnf fedora 42
variant.rocky-9 := rockylinux/rockylinux:9@sha256:8101994123cf3d0a8fee517bee7f39e555c7d92bd2d9eb3303cc988a0eeed00f dnf rocky 9
# zoomies:catalogue-end
# variant-args renders one variant's row as docker build arguments.
variant-args = \
--build-arg BASE=$(word 1,$(variant.$(1))) \
--build-arg OS_FAMILY=$(word 2,$(variant.$(1))) \
--build-arg OS_ID=$(word 3,$(variant.$(1))) \
--build-arg OS_VERSION=$(word 4,$(variant.$(1)))
.PHONY: check-variant
check-variant:
@if [ -z "$(variant.$(RUNNER_VARIANT))" ]; then \
echo "unknown RUNNER_VARIANT '$(RUNNER_VARIANT)'; one of: $(RUNNER_VARIANTS)" >&2; \
exit 1; \
fi
.PHONY: image-runner
image-runner: check-variant ## Build one runner variant for the host architecture (RUNNER_VARIANT=debian-12, RUNNER_TARGET=runner-docker for the Docker one)
docker build -f deploy/Dockerfile.runner --target $(or $(RUNNER_TARGET),runner) \
--build-arg RUNNER_VERSION=$(RUNNER_VERSION) \
$(call variant-args,$(RUNNER_VARIANT)) \
-t $(RUNNER_IMAGE):$(RUNNER_VARIANT) .
.PHONY: images-runner
images-runner: ## Build every runner variant for the host architecture
@for v in $(RUNNER_VARIANTS); do \
echo " building $(RUNNER_IMAGE):$$v"; \
$(MAKE) --no-print-directory image-runner RUNNER_VARIANT=$$v; \
done
.PHONY: check-image-runner
check-image-runner: check-variant ## Check a locally built runner variant has what Zoomies relies on (RUNNER_VARIANT=debian-12 RUNNER_TARGET=runner-docker)
RUNNER_VERSION=$(RUNNER_VERSION) test/runner-image/check.sh $(RUNNER_IMAGE):$(RUNNER_VARIANT) \
$(word 3,$(variant.$(RUNNER_VARIANT))) $(word 4,$(variant.$(RUNNER_VARIANT))) $(or $(RUNNER_TARGET),runner)
.PHONY: image-runner-multiarch
image-runner-multiarch: check-variant ## Build one runner variant for amd64 and arm64 (needs buildx)
docker buildx build --platform linux/amd64,linux/arm64 \
-f deploy/Dockerfile.runner --build-arg RUNNER_VERSION=$(RUNNER_VERSION) \
$(call variant-args,$(RUNNER_VARIANT)) \
-t $(RUNNER_IMAGE):$(RUNNER_VARIANT) .
.PHONY: images-multiarch
images-multiarch: ## Build the controller and every runner variant for amd64 and arm64 (needs buildx)
docker buildx build --platform linux/amd64,linux/arm64 \
-f deploy/Dockerfile --target controller \
--build-arg VERSION=$(VERSION) -t ghcr.io/eyupio/zoomies:$(VERSION) .
@for v in $(RUNNER_VARIANTS); do \
echo " building $(RUNNER_IMAGE):$$v for amd64 and arm64"; \
$(MAKE) --no-print-directory image-runner-multiarch RUNNER_VARIANT=$$v; \
done
##@ Housekeeping
.PHONY: openapi
openapi: | $(UI_DIR)/node_modules ## Regenerate both copies of the contract: the embedded Go source and the TypeScript client
@# Two files are generated from api/openapi.yaml and a test fails on either
@# when it is stale (the embedded copy in internal/api, the client in CI),
@# so one command does both. The Go half reads the spec with the standard
@# library only and does not need the UI.
$(GO) run internal/api/gen_openapi.go
cd $(UI_DIR) && $(NPM) run generate:api
.PHONY: generate
generate: | $(UI_OUT)/.placeholder ## Rewrite everything generated from a table in Go: the image catalogue, the problem and check catalog, the command and MCP references
$(GO) run internal/naming/gen_catalogue.go
$(GO) run internal/catalog/gen_catalog.go
mkdir -p skills/zoomies && $(GO) run ./cmd/zoomies commands --output markdown > skills/zoomies/reference.md
$(GO) run ./cmd/zoomies mcp --describe --output markdown > skills/zoomies/mcp-reference.md
.PHONY: runner-bases
runner-bases: ## Move each runner image's base-image pin to what its tag points at today, and rewrite what is derived from it (needs network)
@# Only what the table feeds: not `make generate`, which also restamps the
@# problem catalogue and rewrites the skill references, and would put that
@# noise into the weekly pull request that runs this.
$(GO) run internal/naming/gen_base_digests.go
$(GO) run internal/naming/gen_catalogue.go
.PHONY: marketplace-lock
marketplace-lock: ## Re-resolve deploy/marketplace/images.lock against the registry (needs network)
$(GO) run deploy/marketplace/gen_lock.go
.PHONY: clean
clean: ## Remove build output
rm -rf $(BIN) $(DIST) $(UI_OUT) coverage.out
rm -rf $(UI_DIR)/node_modules $(UI_DIR)/dist
.PHONY: help
help: ## Show this help
@awk 'BEGIN {FS = ":.*##"; printf "\nZoomies -- off the lead, on the job.\n\nUsage:\n make \033[36m\033[0m\n"} \
/^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-18s\033[0m %s\n", $$1, $$2 } \
/^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) }' $(MAKEFILE_LIST)