-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathMakefile
More file actions
557 lines (474 loc) · 28.3 KB
/
Copy pathMakefile
File metadata and controls
557 lines (474 loc) · 28.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
# sqi Makefile
# Targets: build, test, lint, run, clean, release, docs
# ── Variables ────────────────────────────────────────────────────────────────
# npm places third-party JS packages in web/node_modules/. Some of those
# packages contain Go source files (e.g. flatted) that are not part of the sqi
# codebase. The three variables below exclude that directory from all Go tool
# invocations so that linting, testing, and formatting never touch it.
#
# GO_PKGS: filtered package list used by test, vet, bench.
# LINT_PKGS: explicit directory patterns for golangci-lint (no ./... recursion).
# FMT_DIRS: explicit paths for gofumpt/goimports (web/embed.go is listed
# individually so ./web does not accidentally recurse into node_modules/).
#
# GO_PKGS shells out, so it is assigned with the deferred `=` described under
# "Deferred shell-outs" below rather than `:=`.
GO_PKGS = $(eval GO_PKGS := $(shell go list ./... | grep -v '/node_modules/'))$(GO_PKGS)
LINT_PKGS := ./cmd/... ./internal/... ./pkg/... ./test/... ./web
FMT_DIRS := ./cmd ./internal ./pkg ./test web/embed.go
MODULE := github.com/uberware/sqi
BINARY := sqi-server
CMD_DIR := ./cmd/sqi-server
WORKER_BINARY := sqi-worker
WORKER_CMD_DIR := ./cmd/sqi-worker
BUILD_DIR := ./bin
# ── Deferred shell-outs ───────────────────────────────────────────────────────
# Every $(shell ...) below runs a POSIX one-liner, and make runs it through its
# shell — cmd.exe on Windows, which understands none of them (no grep, no awk,
# no /dev/null, and its internal DATE command prompts for a new system date).
# With `:=` all of them run at parse time, on *every* make invocation, so
# `make test-isolation-windows` — the one target meant to be run from a Windows
# shell, and one that references none of these — printed four unrelated shell
# errors before doing anything. `=` defers each value until a recipe actually
# references it, which keeps the errors on the targets that genuinely need a
# POSIX shell.
#
# The `$(eval X := ...)` wrapper caches the result on first reference so each
# command still runs at most once per make run, as `:=` did. That is not just
# an optimization: without it BUILD_DATE would be re-evaluated per reference
# and sqi-server and sqi-worker could be stamped with different timestamps.
#
# Version embedding — use git tag if available, fall back to "dev"
VERSION = $(eval VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev"))$(VERSION)
COMMIT = $(eval COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown"))$(COMMIT)
BUILD_DATE = $(eval BUILD_DATE := $(shell date -u +%Y-%m-%dT%H:%M:%SZ))$(BUILD_DATE)
GO_VERSION = $(eval GO_VERSION := $(shell go version | awk '{print $$3}'))$(GO_VERSION)
# Deferred (`=`) so it does not force the four variables above to expand at
# parse time, which would defeat the deferral described above.
LDFLAGS = -s -w \
-X $(MODULE)/internal/version.Version=$(VERSION) \
-X $(MODULE)/internal/version.Commit=$(COMMIT) \
-X $(MODULE)/internal/version.BuildDate=$(BUILD_DATE) \
-X $(MODULE)/internal/version.GoVersion=$(GO_VERSION)
# Race detector on by default for tests; override with RACE=off
RACE ?= on
ifeq ($(RACE),on)
TEST_FLAGS := -race
else
TEST_FLAGS :=
endif
COVERAGE_OUT := coverage.out
# Raise in 5-point increments as new test suites land.
# 2026-06-13: measured 74.5% (race) after the phase-1 unit-test backfill;
# gate set ~5 points below for headroom against per-platform fluctuation.
COVERAGE_MIN ?= 70
# ── Default ───────────────────────────────────────────────────────────────────
.DEFAULT_GOAL := help
# ── Help ──────────────────────────────────────────────────────────────────────
.PHONY: help
help: ## Show this help
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| sort \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}'
# ── Build ─────────────────────────────────────────────────────────────────────
.PHONY: build
build: build-server build-worker ## Build both sqi-server and sqi-worker into ./bin/
# The web bundle is a prerequisite of build-server (not just build) so the
# embedded web/dist/ is rebuilt from current source on every server build,
# including under `make -j` and via run-server.
#
# npm ci is the slow step (it wipes and reinstalls node_modules), so it is
# gated on a stamp file keyed to the npm manifests and only re-runs when
# dependencies change. The vite build itself is sub-second and always runs,
# so web/dist/ always matches current source without make having to track
# individual web source files. npm ci deletes the stamp along with
# node_modules, so an interrupted install re-runs from scratch.
web/node_modules/.make-stamp: web/package.json web/package-lock.json
cd web && npm ci
touch $@
.PHONY: build-web
build-web: web/node_modules/.make-stamp ## Build the web UI bundle (web/dist/) embedded by sqi-server
cd web && npm run build
.PHONY: build-server
build-server: build-web ## Build sqi-server binary into ./bin/
@mkdir -p $(BUILD_DIR)
go build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY) $(CMD_DIR)
.PHONY: build-worker
build-worker: ## Build sqi-worker binary into ./bin/
@mkdir -p $(BUILD_DIR)
go build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(WORKER_BINARY) $(WORKER_CMD_DIR)
.PHONY: build-all
build-all: build-web ## Cross-compile both binaries for linux/darwin/windows × amd64/arm64
@mkdir -p $(BUILD_DIR)
@for bin_name in $(BINARY) $(WORKER_BINARY); do \
cmd_dir="./cmd/$$bin_name"; \
for os in linux darwin windows; do \
for arch in amd64 arm64; do \
ext=""; [ "$$os" = "windows" ] && ext=".exe"; \
echo "Building $$bin_name $$os/$$arch..."; \
GOOS=$$os GOARCH=$$arch go build \
-ldflags "$(LDFLAGS)" \
-o $(BUILD_DIR)/$$bin_name-$$os-$$arch$$ext \
$$cmd_dir; \
done; \
done; \
done
# ── Run ───────────────────────────────────────────────────────────────────────
.PHONY: run
run: run-server ## Build then run sqi-server (alias for run-server)
.PHONY: run-server
run-server: build-server ## Build then run sqi-server with default config
$(BUILD_DIR)/$(BINARY) serve
.PHONY: run-worker
run-worker: build-worker ## Build then run sqi-worker with default config
$(BUILD_DIR)/$(WORKER_BINARY) start
# run-workers spins up several sqi-worker instances on this one host to simulate
# a multi-worker farm locally. Each instance gets a unique identity so they do
# not collide: its own data dir (which holds the persistent worker.id UUID and
# session dirs), its own metrics/health port, and a distinct UI name. They all
# discover/connect to the same sqi-server (run `make run` in another terminal).
# Inherited SQI_WORKER_* env vars still apply, so e.g.
# SQI_WORKER_NATS_URL=nats://127.0.0.1:4222 make run-workers N=5
# overrides discovery and starts five workers. Ctrl-C stops all of them.
# Number of workers to start, and a short `N=` alias so `make run-workers N=5` works.
WORKERS ?= 3
N ?= $(WORKERS)
# First worker's metrics/health port; each subsequent worker increments by one.
WORKER_METRICS_BASE_PORT ?= 9091
# Per-instance state (worker.id UUID + session dirs) lives under here, one subdir
# per worker, so instances never share an identity.
WORKER_DATA_ROOT ?= ./.run/workers
.PHONY: run-workers
run-workers: build-worker ## Spin up N sqi-worker instances locally (N=3 default; Ctrl-C stops all)
@echo "Starting $(N) workers (Ctrl-C to stop all)..."
@pids=""; \
trap 'echo; echo "Stopping workers..."; kill $$pids 2>/dev/null; wait $$pids 2>/dev/null; exit 0' INT TERM; \
for i in $$(seq 1 $(N)); do \
port=$$(( $(WORKER_METRICS_BASE_PORT) + i - 1 )); \
data_dir="$(WORKER_DATA_ROOT)/worker-$$i"; \
mkdir -p "$$data_dir"; \
echo " worker-$$i data_dir=$$data_dir metrics=127.0.0.1:$$port"; \
SQI_WORKER_NAME="worker-$$i" \
SQI_WORKER_DATA_DIR="$$data_dir" \
SQI_WORKER_METRICS_ADDR="127.0.0.1:$$port" \
$(BUILD_DIR)/$(WORKER_BINARY) start & \
pids="$$pids $$!"; \
done; \
wait
# ── Test ──────────────────────────────────────────────────────────────────────
.PHONY: test
test: ## Run all tests (race detector on by default; override with RACE=off)
go test $(TEST_FLAGS) $(GO_PKGS)
.PHONY: test-cover
test-cover: ## Run tests and emit coverage report
go test $(TEST_FLAGS) -coverprofile=$(COVERAGE_OUT) -covermode=atomic $(GO_PKGS)
go tool cover -func=$(COVERAGE_OUT) | tail -1
@cov=$$(go tool cover -func=$(COVERAGE_OUT) | tail -1 | awk '{print int($$3)}'); \
echo "Coverage: $$cov% (minimum: $(COVERAGE_MIN)%)"; \
if [ $$cov -lt $(COVERAGE_MIN) ]; then \
echo "ERROR: coverage below minimum threshold"; exit 1; \
fi
.PHONY: test-cover-html
test-cover-html: test-cover ## Open HTML coverage report in the browser
go tool cover -html=$(COVERAGE_OUT)
.PHONY: test-integration
# INTEGRATION_TEST_FLAGS is empty by default so a local run gets go test's
# ordinary defaults; CI sets it to add a timeout sized for the suite's own
# LDAP/Keycloak containers without changing what a local run does.
INTEGRATION_TEST_FLAGS ?=
test-integration: ## Run integration tests (tagged 'integration')
go test $(TEST_FLAGS) -tags integration -v $(INTEGRATION_TEST_FLAGS) ./test/...
.PHONY: test-conformance
test-conformance: ## Run the official OpenJD conformance suite (needs the pinned submodule)
go test $(TEST_FLAGS) -tags conformance -v ./test/conformance/
# The OpenJD expression-language reference implementation, pinned. It ships as
# the openjd.expr namespace of openjd-model, which is a thin re-export layer
# over a compiled Rust crate rather than a Python implementation. Pinned
# because it is Beta (0.x, breaking changes permitted in minor bumps), so an
# unpinned upgrade could turn the differential test red without a single sqi
# commit — and because a divergence report is meaningless without knowing
# which build of the reference produced it.
OPENJD_MODEL_VERSION ?= 0.11.5
ORACLE_VENV := .venv-oracle
.PHONY: expr-oracle-venv
expr-oracle-venv: ## Create the venv holding the pinned OpenJD reference implementation
@python3 -m venv $(ORACLE_VENV)
@$(ORACLE_VENV)/bin/python3 -m pip install --quiet --upgrade pip
@$(ORACLE_VENV)/bin/python3 -m pip install --quiet "openjd-model==$(OPENJD_MODEL_VERSION)"
@echo "reference implementation ready: openjd-model $(OPENJD_MODEL_VERSION) in $(ORACLE_VENV)"
# Differential test against the reference implementation. Like test-isolation,
# this exits 0 when its dependency is absent, so A LOCAL PASS PROVES NOTHING
# ON ITS OWN — look for the "--- PASS: TestExprOracle" line. CI asserts it by
# name for that reason.
.PHONY: test-expr-oracle
test-expr-oracle: ## Differential-test the EXPR evaluator against the OpenJD reference (needs python3)
@if [ ! -x "$(ORACLE_VENV)/bin/python3" ] && [ -z "$$SQI_EXPR_ORACLE_PYTHON" ]; then \
if ! command -v python3 >/dev/null 2>&1; then \
echo "python3 unavailable — skipping the expression oracle"; exit 0; fi; \
echo "no $(ORACLE_VENV) — creating it (run 'make expr-oracle-venv' to do this explicitly)"; \
$(MAKE) --no-print-directory expr-oracle-venv || \
{ echo "could not install the reference implementation — skipping the expression oracle"; exit 0; }; \
fi
# An EXISTING venv is not evidence of the RIGHT venv: the guard above only
# creates one when it is missing, so before this check a raised
# OPENJD_MODEL_VERSION left the suite grading against the previous reference
# with nothing red to say so. Reinstall on mismatch, and let the test itself
# assert the version it actually spoke to (SQI_EXPR_ORACLE_EXPECT_VERSION) so
# the guarantee survives a hand-run `go test` too. Skipped entirely when
# SQI_EXPR_ORACLE_PYTHON points the harness at an interpreter we do not own.
@if [ -x "$(ORACLE_VENV)/bin/python3" ] && [ -z "$$SQI_EXPR_ORACLE_PYTHON" ]; then \
have=$$($(ORACLE_VENV)/bin/python3 -c \
'import importlib.metadata as m; print(m.version("openjd-model"))' 2>/dev/null); \
if [ "$$have" != "$(OPENJD_MODEL_VERSION)" ]; then \
echo "$(ORACLE_VENV) has openjd-model $$have, pin is $(OPENJD_MODEL_VERSION) — reinstalling"; \
$(MAKE) --no-print-directory expr-oracle-venv || \
{ echo "could not install the pinned reference implementation"; exit 1; }; \
fi; \
fi
SQI_EXPR_ORACLE_EXPECT_VERSION=$$([ -n "$$SQI_EXPR_ORACLE_PYTHON" ] || echo $(OPENJD_MODEL_VERSION)) \
go test $(TEST_FLAGS) -tags oracle -run 'TestExprOracle' -v -timeout 5m ./test/oracle/
# Validates the PUBLISHED preset library against the validator in this working
# tree. It exists because a validator change can silently invalidate content
# already published: 2cdef4f tightened parameter-control validation and fixed
# every preset in this repo, but the copy at uberware.github.io/sqi-presets is
# only refreshed on release, so every preset there failed to load in between --
# with no signal until a user clicked one.
#
# Needs the network. SKIPS when the library is unreachable and FAILS when it is
# reachable but invalid, so an offline runner never masks a real breakage. A
# SKIP VERIFIES NOTHING -- look for the "--- PASS: TestPublishedPresets" line.
# CI asserts it by name for that reason.
#
# SQI_TEST_PRESET_LIBRARY_URL points it at a staging index instead.
.PHONY: test-preset-library
test-preset-library: ## Validate the published preset library against this tree (needs network)
go test $(TEST_FLAGS) -tags presetlib -run 'TestPublishedPresets' -v -timeout 5m ./test/presetlib/
.PHONY: test-ldap
test-ldap: ## Run the LDAP tests against a real directory in a container (needs Docker)
go test $(TEST_FLAGS) -tags integration -run 'TestLDAP_' -v -timeout 15m ./test/integration/
.PHONY: test-oidc
test-oidc: ## Run the SSO tests against a real Keycloak in a container (needs Docker)
go test $(TEST_FLAGS) -tags integration -run 'TestOIDC_' -v -timeout 15m ./test/integration/
# Unlike test-ldap/test-oidc (which run natively on the host and connect OUT to
# a container), test-isolation must run the go test binary ITSELF as root
# inside the container: the whole point is exercising real setuid/setgid
# transitions, real directory permission bits, and a real symlink-preserving
# rsync against real unprivileged accounts, none of which a fake Provider can
# see (internal/worker/isolation/fake.go).
#
# The image is built from a STAGED COPY of the repo (rsync'd into a scratch
# directory, filtered by test/integration/isolation/.dockerignore, then passed
# to `docker build` as the context) rather than either (a) bind-mounting the
# repo at `docker run` time, or (b) using the repo root directly as the build
# context. (a) broke outright on this project's own dev machines: Colima
# (common on macOS) only virtiofs-shares $HOME by default, so a repo living
# elsewhere (e.g. /Volumes/...) resolves to an EMPTY bind mount and `go test`
# fails with "go.mod file not found" before a single test runs — `docker
# build`, by contrast, has no such dependency, since the CLI reads its context
# from wherever it runs and streams it to the daemon regardless of what the
# daemon's host shares. (b) doesn't work either: the repo-root .dockerignore
# (shared with deploy/docker/Dockerfile's production build) excludes test/
# entirely, and this image needs test/integration/**; the classic
# (non-BuildKit) builder this project's Docker install runs has no per-
# Dockerfile ignore-file override to give this build its own rules on that
# same context. A staged copy sidesteps both problems at once — PROVIDED the
# repo-root .dockerignore is not itself staged into the copy: rsync -a copies
# dotfiles, so a naive staged copy carries the repo-root .dockerignore along
# to $ctx/.dockerignore, and Docker auto-discovers a context-root
# .dockerignore from a directory the same way regardless of which Dockerfile
# is building it — silently re-excluding test/ from the staged copy exactly as
# it would from the repo root directly. The recipe below excludes every
# .dockerignore from the rsync and then places
# test/integration/isolation/.dockerignore at the staged root explicitly, so
# Docker's own (real, no-trick) context-root ignore-file discovery sees only
# this image's small, correct exclusion list.
#
# --init runs a real init (tini) as container PID 1: without it, the `go
# test` process itself is PID 1, which never reaps re-parented grandchildren
# after a process-group kill — a container-hygiene artifact of the TEST
# HARNESS, not of isolation.Apply, but one that produces a false failure in
# TestIsolation_ProcessGroupKillReapsPrivilegeDroppedGrandchild without it.
.PHONY: test-isolation
test-isolation: ## Run run-as-user isolation tests as root against real OS accounts in a container (needs Docker)
@if ! docker info >/dev/null 2>&1; then \
echo "docker unavailable — skipping isolation integration tests"; exit 0; fi
@ctx=$$(mktemp -d) && trap 'rm -rf "$$ctx"' EXIT && \
rsync -a --exclude-from=test/integration/isolation/.dockerignore --exclude='.dockerignore' "$(CURDIR)/" "$$ctx/" && \
cp test/integration/isolation/.dockerignore "$$ctx/.dockerignore" && \
docker build -q -t sqi-isolation-test -f test/integration/isolation/Dockerfile "$$ctx" && \
docker run --rm --init sqi-isolation-test \
go test $(TEST_FLAGS) -tags integration -run 'TestIsolation_' -v -timeout 15m ./test/integration/
.PHONY: test-discovery
test-discovery: ## Run the mDNS discovery tests over REAL multicast (fails rather than skips if multicast is unavailable)
@echo "note: advertisements are restricted to loopback, so nothing is announced"
@echo " on your network. One test (RealBinary...) binds the test broker to"
@echo " all interfaces for ~10s; make test-integration skips that one."
@echo " On Linux, loopback needs both: sudo ip link set lo multicast on"
@echo " and: sudo ip -6 addr add fe80::1/64 dev lo (zeroconf discards"
@echo " loopback addresses, so lo has nothing to advertise without it)"
SQI_TEST_REQUIRE_MULTICAST=1 go test $(TEST_FLAGS) -tags integration \
-run 'TestDiscovery_' -v -timeout 10m ./test/integration/
.PHONY: test-isolation-windows
test-isolation-windows: ## Run windows run-as-user isolation tests as SYSTEM against real local accounts (needs an elevated shell)
@powershell -NoProfile -ExecutionPolicy Bypass -File scripts/test-isolation-windows.ps1
.PHONY: bench
bench: ## Run benchmarks
go test -bench=. -benchmem $(GO_PKGS)
.PHONY: smoke
smoke: build-server build-worker ## Run the end-to-end smoke test against the built binaries
SQI_SERVER_BIN=$(BUILD_DIR)/$(BINARY) SQI_WORKER_BIN=$(BUILD_DIR)/$(WORKER_BINARY) \
bash scripts/smoke.sh
.PHONY: auth-demo
auth-demo: build-server build-worker ## Run the auth surface demo on a live local farm (KEEP=1 to leave it running)
SQI_SERVER_BIN=$(BUILD_DIR)/$(BINARY) SQI_WORKER_BIN=$(BUILD_DIR)/$(WORKER_BINARY) \
SQI_AUTH_DEMO_KEEP=$(if $(KEEP),$(KEEP),0) \
bash scripts/auth-demo.sh
# ── Lint and Vet ─────────────────────────────────────────────────────────────
.PHONY: vet
vet: ## Run go vet
go vet $(GO_PKGS)
# Lint targets use explicit path patterns rather than ./... so that third-party
# Go code in web/node_modules/ (installed by npm) is not linted.
# ./web (no trailing /...) targets only the web Go package (embed.go).
LINT_PKGS := ./cmd/... ./internal/... ./pkg/... ./test/... ./web
.PHONY: lint
lint: ## Run golangci-lint (install: https://golangci-lint.run/usage/install/)
golangci-lint run $(LINT_PKGS)
.PHONY: lint-fix
lint-fix: ## Run golangci-lint with --fix for auto-correctable issues
golangci-lint run --fix $(LINT_PKGS)
# actionlint version, run via `go run` so no global install is required.
# Files are passed explicitly (rather than relying on actionlint's directory
# auto-discovery) to skip macOS AppleDouble sidecar files (._*.yml) that appear
# when the repo lives on a non-APFS volume; CI never sees those.
ACTIONLINT_VERSION := v1.7.12
.PHONY: lint-actions
lint-actions: ## Lint GitHub Actions workflows with actionlint (via go run; no install)
go run github.com/rhysd/actionlint/cmd/actionlint@$(ACTIONLINT_VERSION) \
$$(find .github/workflows -maxdepth 1 -type f \( -name '*.yml' -o -name '*.yaml' \) ! -name '._*')
# ── Formatting ────────────────────────────────────────────────────────────────
.PHONY: fmt
fmt: ## Format code with gofumpt and goimports
gofumpt -l -w $(FMT_DIRS)
goimports -l -w $(FMT_DIRS)
.PHONY: fmt-check
fmt-check: ## Check formatting without modifying files (used in CI)
@unformatted=$$(gofumpt -l $(FMT_DIRS)); \
if [ -n "$$unformatted" ]; then \
echo "Unformatted files:"; echo "$$unformatted"; exit 1; \
fi
# ── Generate ──────────────────────────────────────────────────────────────────
.PHONY: generate
generate: ## Run go generate across the module
go generate ./...
# ── Docs ─────────────────────────────────────────────────────────────────────
.PHONY: docs
docs: ## Serve Go package docs locally via pkgsite
@which pkgsite > /dev/null 2>&1 || go install golang.org/x/pkgsite/cmd/pkgsite@latest
pkgsite -open .
# ── Docs site (MkDocs) ────────────────────────────────────────────────────────
# The public documentation site (docs/ + mkdocs.yml), published to GitHub Pages
# on release. Uses a local virtualenv at .venv-docs so it never touches system
# Python. Run docs-site-install once, then docs-site / docs-site-serve.
DOCS_VENV := .venv-docs
# Strip macOS AppleDouble sidecars (._*) that some network/exFAT mounts create
# on every write; jinja2 chokes on them when loading theme templates. No-op on
# clean filesystems (Linux CI, local APFS) where the find matches nothing.
DOCS_CLEAN := find $(DOCS_VENV) -name '._*' -delete 2>/dev/null || true
.PHONY: docs-site-install
docs-site-install: ## Create .venv-docs and install pinned MkDocs dependencies
@test -d $(DOCS_VENV) || python3 -m venv $(DOCS_VENV)
$(DOCS_VENV)/bin/python -m pip install -r requirements-docs.txt
.PHONY: docs-site
docs-site: ## Build the docs site with strict checks (the CI gate)
@$(DOCS_CLEAN)
$(DOCS_VENV)/bin/mkdocs build --strict
.PHONY: docs-site-serve
docs-site-serve: ## Serve the docs site locally with live reload
@$(DOCS_CLEAN)
$(DOCS_VENV)/bin/mkdocs serve
# ── Release ───────────────────────────────────────────────────────────────────
.PHONY: changelog
changelog: ## Regenerate CHANGELOG.md from Conventional Commits (VERSION=x.y.z tags unreleased commits)
@which git-cliff > /dev/null 2>&1 || { echo "git-cliff not found — install: https://git-cliff.org/docs/installation"; exit 1; }
git-cliff $(if $(VERSION),--tag v$(VERSION),) --output CHANGELOG.md
@echo "Wrote CHANGELOG.md$(if $(VERSION), (unreleased commits tagged v$(VERSION)),)"
.PHONY: release
release: ## Build a release with goreleaser (install: https://goreleaser.com)
GOVERSION=$(GO_VERSION) goreleaser release --clean
.PHONY: release-snapshot
release-snapshot: ## Build a local snapshot release (no publish, no git tag required)
GOVERSION=$(GO_VERSION) goreleaser release --snapshot --clean
# ── Docker ────────────────────────────────────────────────────────────────────
.PHONY: docker-build
docker-build: ## Build the sqi-server Docker image
docker build \
--build-arg VERSION=$(VERSION) \
--build-arg COMMIT=$(COMMIT) \
--build-arg BUILD_DATE=$(BUILD_DATE) \
-t sqi-server:$(VERSION) \
-f deploy/Dockerfile .
.PHONY: docker-run
docker-run: ## Run the sqi-server Docker image with default config
docker run --rm -p 8080:8080 sqi-server:$(VERSION) serve
# ── Clean ─────────────────────────────────────────────────────────────────────
.PHONY: clean
clean: ## Remove build artifacts and coverage output
rm -rf $(BUILD_DIR) $(COVERAGE_OUT)
# web/dist is intentionally not cleaned: web/dist/index.html is git-tracked so
# //go:embed in web/embed.go always has a file (see .gitignore), and build-web
# regenerates the bundle on every build anyway.
# ── Dependency helpers ────────────────────────────────────────────────────────
.PHONY: deps
deps: ## Download and tidy Go module dependencies
go mod download
go mod tidy
.PHONY: deps-upgrade
deps-upgrade: ## Upgrade all Go dependencies to latest minor/patch
go get -u ./...
go mod tidy
.PHONY: hooks
hooks: ## Install git hooks via lefthook (install: go install github.com/evilmartians/lefthook@latest)
lefthook install
# ── Python client (clients/python) ────────────────────────────────────────────
# The sqi-sdk library lives in clients/python with its own toolchain (ruff,
# mypy, pytest). It is developed in an isolated virtualenv at clients/python/.venv
# so its dependencies never leak into the system interpreter. Targets cd into the
# project so ruff/mypy/pytest discover the pyproject.toml config there.
PY_DIR := clients/python
PY_VENV := $(PY_DIR)/.venv
.PHONY: py-install
py-install: ## Create clients/python/.venv and install sqi-sdk editable with all extras
python3 -m venv $(PY_VENV)
$(PY_VENV)/bin/python -m pip install --upgrade pip
$(PY_VENV)/bin/python -m pip install -e '$(PY_DIR)[yaml,ws,dev]'
.PHONY: py-fmt
py-fmt: ## Format the Python client with ruff
cd $(PY_DIR) && .venv/bin/ruff format .
.PHONY: py-lint
py-lint: ## Lint the Python client with ruff
cd $(PY_DIR) && .venv/bin/ruff check .
.PHONY: py-typecheck
py-typecheck: ## Type-check the Python client with mypy (strict; src at 3.9, tests at 3.13)
cd $(PY_DIR) && .venv/bin/mypy src \
&& .venv/bin/mypy --python-version=3.13 tests
.PHONY: py-test
py-test: ## Run the Python client unit tests with coverage
cd $(PY_DIR) && .venv/bin/pytest
.PHONY: py-test-integration
py-test-integration: build-server build-worker ## Run the Python client integration tests against freshly-built binaries
cd $(PY_DIR) && .venv/bin/pytest -m integration --no-cov
.PHONY: py-check
py-check: ## Full Python client gate (check-only): ruff format, ruff check, mypy, pytest
cd $(PY_DIR) && .venv/bin/ruff format --check . \
&& .venv/bin/ruff check . \
&& .venv/bin/mypy src \
&& .venv/bin/mypy --python-version=3.13 tests \
&& .venv/bin/pytest
.PHONY: py-build
py-build: ## Build the Python client sdist + wheel into clients/python/dist/
$(PY_VENV)/bin/python -m pip install --quiet --upgrade build
cd $(PY_DIR) && .venv/bin/python -m build
# ── CI convenience target ─────────────────────────────────────────────────────
.PHONY: ci
ci: fmt-check vet lint test-cover ## Run the full CI check suite locally