# Caprock — build, test, docs, and release targets. # # Go module lives at the repo root; the dashboard lives in ui/ and is embedded into # the binary from internal/api/dist (built by `make ui`). That directory is # committed so `go install` embeds a real UI, but only master's dist workflow # commits it: a pull request never carries it (ADR-068). DOCS := $(shell find . -name '*.md' \ -not -path './node_modules/*' -not -path './.git/*' \ -not -path './vendor/*' -not -path './ui/node_modules/*' \ -not -path './ui/dist/*' -not -path './.claude/*' \ -not -path './app/node_modules/*' -not -path './app/src-tauri/target/*' \ -not -path './relay/node_modules/*' -not -path './relay/.wrangler/*' \ -not -path './ios-app/node_modules/*' -not -path './ios-app/ios/App/App/public/*' | sort) # Every recipe below is POSIX shell: pipelines, `[ -d ]`, single-quoted # -ldflags, `2>/dev/null`. On Windows, GNU make with no `sh` on PATH falls back # to cmd.exe, which understands none of it — the first Windows contributor to # clone the repo hit `mkdir -p` on the very first command in CONTRIBUTING.md # (FB-025). # # CI never caught it because CI does not use make on Windows: it runs `go build` # and `go test` directly, so the Windows job is green while `make build` is # broken. A green matrix is not the same as a working repository. # # Rather than rewrite ~20 recipes in a dialect no one here can test, point make # at the Bash that is already on every Windows dev machine: Git for Windows # ships one, and Go and Node are prerequisites anyway. If it is on PATH under # any of its usual names, use it; otherwise fall back and let the recipe fail # with a clear message rather than a mangled one. ifeq ($(OS),Windows_NT) SHELL := $(shell where bash 2>NUL | findstr /R "." || echo bash) .SHELLFLAGS := -c # `go build -o bin/caprock` writes exactly that name, with no extension — # `go build` only appends .exe when it is choosing the name itself. The # result is a file Windows will not run by name, which is a broken build that # exits 0. The CI job added with this fix is what caught it. EXE := .exe endif BIN := bin VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) LDFLAGS := -s -w -X github.com/dspv/caprock/internal/version.Version=$(VERSION) # Mail's sign-in client IDs (docs/mail-oauth.md), from the environment when # set (release.yml passes the GitHub secrets of the same names); empty, Mail # offers app passwords instead of the sign-in buttons. MAIL_OAUTH := github.com/dspv/caprock/internal/mail/oauth LDFLAGS += -X $(MAIL_OAUTH).GoogleClientID=$(CAPROCK_MAIL_GOOGLE_CLIENT_ID) -X $(MAIL_OAUTH).GoogleClientSecret=$(CAPROCK_MAIL_GOOGLE_CLIENT_SECRET) -X $(MAIL_OAUTH).MicrosoftClientID=$(CAPROCK_MAIL_MICROSOFT_CLIENT_ID) GOFLAGS := -trimpath export CGO_ENABLED = 0 .DEFAULT_GOAL := help # --- help ----------------------------------------------------------------- .PHONY: help help: ## Show this help @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \ | sort | awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}' # --- build ---------------------------------------------------------------- .PHONY: build build: ui ## Build ./bin/caprock, ./bin/caprock-hook and ./bin/caprock-pty-host (with the embedded UI) @mkdir -p $(BIN) go build $(GOFLAGS) -ldflags '$(LDFLAGS)' -o $(BIN)/caprock$(EXE) ./cmd/caprock go build $(GOFLAGS) -ldflags '$(LDFLAGS)' -o $(BIN)/caprock-hook$(EXE) ./cmd/caprock-hook go build $(GOFLAGS) -ldflags '$(LDFLAGS)' -o $(BIN)/caprock-pty-host$(EXE) ./cmd/caprock-pty-host .PHONY: build-go build-go: ## Build the Go binaries only (uses whatever UI is already embedded) @mkdir -p $(BIN) go build $(GOFLAGS) -ldflags '$(LDFLAGS)' -o $(BIN)/caprock$(EXE) ./cmd/caprock go build $(GOFLAGS) -ldflags '$(LDFLAGS)' -o $(BIN)/caprock-hook$(EXE) ./cmd/caprock-hook go build $(GOFLAGS) -ldflags '$(LDFLAGS)' -o $(BIN)/caprock-pty-host$(EXE) ./cmd/caprock-pty-host .PHONY: ui ui: ## Build the dashboard into internal/api/dist (installs deps on a fresh clone; never commit the result) cd ui && { [ -d node_modules ] || npm ci; } cd ui && npm run build # internal/api/dist is committed for `go install`, and rebuilt on master by # .github/workflows/dist.yml after every merge that touches ui/ (ADR-068). A # branch never changes it: two branches that both rebuilt it always conflicted # there, whatever else they touched. A local `make ui` leaves the directory # modified; that is expected, and nothing here asks you to commit it. .PHONY: dist-untouched dist-untouched: ## Fail if this branch's commits change internal/api/dist (master rebuilds it; ADR-068) @base=$$(git merge-base HEAD origin/master 2>/dev/null) || { \ echo "dist-untouched: no origin/master here, skipped"; exit 0; }; \ if ! git diff --quiet "$$base" HEAD -- internal/api/dist; then \ echo "This branch changes internal/api/dist. dist is rebuilt on master after merge; drop your dist changes:"; \ echo " make dist-reset && git commit -m 'chore: drop the dist changes'"; \ git --no-pager diff --stat "$$base" HEAD -- internal/api/dist | tail -3; exit 1; fi @echo "dist is untouched by this branch" .PHONY: dist-reset dist-reset: ## Put internal/api/dist back as this branch's base on master had it (staged; commit to drop a branch's dist changes) @base=$$(git merge-base HEAD origin/master) && \ git rm -rq --cached --ignore-unmatch -- internal/api/dist && \ git checkout "$$base" -- internal/api/dist && \ git clean -qfd -- internal/api/dist && \ echo "internal/api/dist is as $$(git rev-parse --short $$base) has it (staged)" .PHONY: dist-check dist-check: ui ## Fail if the committed internal/api/dist/ is not this commit's ui/ built (the release gate for `go install`) @git status --porcelain --untracked-files=all -- internal/api/dist \ | grep -vE '^[MARD] ' | grep -q . && { \ echo "internal/api/dist/ is not ui/ built. Releases tag the commit after master's dist rebuild:"; \ echo " wait for the dist workflow (gh run list -w dist -L 3), git pull, then tag; never commit dist by hand"; \ git --no-pager status --short -- internal/api/dist | tail -5; exit 1; } || true @echo "dist is in sync with ui/" .PHONY: ui-install ui-install: ## npm ci for the dashboard cd ui && npm ci .PHONY: dev dev: ## Run daemon (go run) + vite dev server; UI on :5173 proxies API to :22776 @./scripts/dev.sh # --- test / lint ---------------------------------------------------------- .PHONY: test test: test-go test-ui test-scripts ## All tests .PHONY: test-go test-go: ## Go tests go test ./... # Every statement the Go tests run, explained against the latest schema # (internal/store/plans_test.go). The OpenCode database on this machine is # left out, as on CI, where the check runs on the go job's own test run. PLANS_DIR := bin/plans .PHONY: plans plans: ## Rewrite internal/store/testdata/query-plans.txt from a run of the Go tests; review its diff rm -rf $(PLANS_DIR) CAPROCK_PLAN_LOG=$(CURDIR)/$(PLANS_DIR) OPENCODE_DB=:memory: go test -count=1 ./... CAPROCK_PLANS_FROM=$(CURDIR)/$(PLANS_DIR) CAPROCK_PLANS_UPDATE=1 go test -count=1 -run '^TestQueryPlans$$' ./internal/store .PHONY: test-scripts test-scripts: ## Release script tests (latest.json for the app updater, changelog fragments) python3 -m unittest discover -s scripts -p 'test_*.py' .PHONY: test-ui test-ui: ## Dashboard tests cd ui && npm test -- --run .PHONY: lint lint: lint-go lint-ui ## All linters .PHONY: lint-go lint-go: ## go vet + golangci-lint go vet ./... golangci-lint run .PHONY: lint-ui lint-ui: ## tsc --noEmit cd ui && npm run typecheck .PHONY: smoke smoke: build-go ## Phase 0 DoD scenario + the Phase 2 e2e (what CI's smoke step runs) go test -tags smoke -count=1 ./internal/smoke/... ./internal/board/... # The app's golden paths in a headless browser (ui/e2e, .ai/10-infrastructure.md # § E2E): this checkout's dashboard, built into a temp directory and served by # `vite preview`, in front of a daemon under `env -i` with a temp HOME and data # dir, a free port and testdata/fakeclaude as its claude. The browser is # Playwright's own headless shell, never the system Chrome. About 20 s. .PHONY: e2e e2e: build-go ## The app's golden paths in Playwright's headless shell against a sandboxed daemon (ui/e2e) cd ui && { [ -d node_modules ] || npm ci; } cd ui && node e2e/has-browser.mjs cd ui && npx playwright test -c e2e $(E2E_ARGS) # --- desktop app (app/, Tauri v2; ADR-038) ---------------------------------- # The app bundles the daemon as a Tauri sidecar, which Tauri looks for as # app/src-tauri/binaries/caprock-[.exe]; app-sidecar builds # it from this checkout so the app always carries the daemon it was built with, # and caprock-pty-host beside it, the binary the daemon holds terminals in. APP_SRC := app/src-tauri APP_TRIPLE = $(shell rustc -vV 2>/dev/null | sed -n 's/^host: //p') # Bundle formats for app-bundle; empty means every format tauri.conf.json lists # for this OS. `make app-bundle APP_BUNDLES=app` skips Tauri's macOS .dmg, whose # layout step drives Finder (releases and CI make theirs with scripts/app-dmg.sh). APP_BUNDLES ?= # The app's version for app-bundle (release jobs pass the tag without the v); # empty keeps tauri.conf.json's. APP_VERSION ?= # With TAURI_SIGNING_PRIVATE_KEY (and _PASSWORD) in the environment, # app-bundle also writes the signed updater bundles (F20, ADR-042): # tauri.updater.conf.json turns createUpdaterArtifacts on. Without the key # it builds exactly as before. .PHONY: app-sidecar app-sidecar: ## Build the daemon the app bundles (app/src-tauri/binaries/caprock[-pty-host]-) @[ -n "$(APP_TRIPLE)" ] || { echo "rustc not found: install Rust with rustup (see app/README.md)"; exit 1; } @mkdir -p $(APP_SRC)/binaries go build $(GOFLAGS) -ldflags '$(LDFLAGS)' -o $(APP_SRC)/binaries/caprock-$(APP_TRIPLE)$(EXE) ./cmd/caprock go build $(GOFLAGS) -ldflags '$(LDFLAGS)' -o $(APP_SRC)/binaries/caprock-pty-host-$(APP_TRIPLE)$(EXE) ./cmd/caprock-pty-host .PHONY: app app: app-sidecar ## Run the desktop app in development (debug build; finds or starts a daemon) cargo run --manifest-path $(APP_SRC)/Cargo.toml .PHONY: app-test app-test: app-sidecar ## Desktop app: cargo fmt --check, clippy -D warnings, cargo test cargo fmt --manifest-path $(APP_SRC)/Cargo.toml --check cargo clippy --manifest-path $(APP_SRC)/Cargo.toml --all-targets -- -D warnings cargo test --manifest-path $(APP_SRC)/Cargo.toml .PHONY: app-bundle app-bundle: app-sidecar ## Build the release app bundle(s) under app/src-tauri/target/release/bundle cd app && { [ -d node_modules ] || npm ci; } cd app && npx tauri build $(if $(APP_BUNDLES),--bundles $(APP_BUNDLES),) $(if $(APP_VERSION),--config '{"version":"$(APP_VERSION)"}',) $(if $(TAURI_SIGNING_PRIVATE_KEY),--config src-tauri/tauri.updater.conf.json,) .PHONY: app-local app-local: ## macOS: build this checkout's app (host arch, release) and install it over /Applications/Caprock.app @bash scripts/app-local.sh .PHONY: app-local-revert app-local-revert: ## macOS: back from a local build to the released app (brew reinstall --cask dspv/tap/caprock-app) @bash scripts/app-local.sh --revert .PHONY: app-release app-release: ## macOS: build the universal .dmg for TAG=vX.Y.Z, check it, attach it to the release, render the cask @[ -n "$(TAG)" ] || { echo "usage: make app-release TAG=vX.Y.Z [ARGS='--no-upload --clobber --cask-pr']"; exit 1; } @bash scripts/app-release.sh $(TAG) $(ARGS) .PHONY: app-latest app-latest: ## Attach version-less copies of TAG=vX.Y.Z's app files, add them to checksums.txt, mark it Latest @[ -n "$(TAG)" ] || { echo "usage: make app-latest TAG=vX.Y.Z [ARGS=--allow-missing]"; exit 1; } @bash scripts/app-latest.sh $(TAG) $(ARGS) # --- docs ----------------------------------------------------------------- .PHONY: docs-fmt docs-fmt: ## Tight-align all markdown tables (run after editing any table) @python3 scripts/align-tables.py $(DOCS) .PHONY: docs-check docs-check: ## Fail if any markdown table is unaligned or a changelog fragment is malformed (CI gate) @python3 scripts/align-tables.py --check $(DOCS) @python3 scripts/changelog.py check .PHONY: docs-links docs-links: ## Fail if any relative markdown link does not resolve @python3 scripts/check-links.py $(DOCS) .PHONY: lang-check lang-check: ## Fail if any tracked text file holds Cyrillic (generated dist and binaries skipped) @python3 scripts/check-no-cyrillic.py .PHONY: pr-check pr-check: ## Before opening a PR: changed Go packages vet + test -race -count=3, Windows vet + test compile, the whole UI suite when ui/ changed, make e2e when ui/ or internal/api changed @./scripts/pr-check.sh .PHONY: check check: check-go check-ui ## Docs gates + lint + test + dist untouched + smoke (what CI runs, minus the OS matrix) # The two halves of check, which share nothing, so release.yml's verify runs # them as two jobs side by side. Together they are exactly check. .PHONY: check-go check-go: docs-check docs-links lang-check lint-go test-go smoke ## check's Go half: docs gates, vet + golangci-lint, go test, smoke .PHONY: check-ui check-ui: lint-ui test-ui test-scripts dist-untouched ## check's dashboard half: typecheck, vitest, release script tests, dist untouched .PHONY: release-ready release-ready: check dist-check ## Everything `check` does, plus the things only a release forgets @# dist-check: the tag must hold its own ui/ built, or `go install @vX.Y.Z` @# embeds an older dashboard; release.yml's verify fails the same way, but @# only after the tag is pushed, and a tag is never moved. @echo "" @echo "→ the site's changelog" @# The site keeps its own copy of CHANGELOG.md, regenerated by a script in @# that repository. Nothing enforces running it, so a release cycle that @# skips the step leaves caprock.dev showing a version that no longer @# exists — which is what happened on 0.49.1 through 0.49.3, and was only @# noticed because a stray background job was still watching for a string. @# @# A warning rather than a failure: the site is a sibling checkout that not @# everyone building this repo has, and a gate that cannot run everywhere is @# a gate people learn to skip. @if [ -d ../caprock-web ]; then \ if ! (cd ../caprock-web && node scripts/sync-changelog.mjs --check >/dev/null 2>&1); then \ echo " ✗ caprock.dev is behind CHANGELOG.md."; \ echo " cd ../caprock-web && npm run sync:changelog && git commit -am 'docs(changelog): sync' && git push"; \ exit 1; \ fi; \ echo " ✓ the site's changelog matches"; \ else \ echo " – no ../caprock-web checkout; sync it wherever you keep it"; \ fi @echo "" @echo "Ready. Tag with: git tag -a vX.Y.Z -m '...' && git push origin vX.Y.Z" .PHONY: shots shots: ## Re-take the documented screenshots and open a PR (needs a real database) @bash scripts/refresh-shots.sh .PHONY: record record: ## Serve a scrubbed dashboard on :4291 to record video from (Ctrl-C to stop) @bash scripts/record-stand.sh .PHONY: gif gif: ## Re-record a README GIF from the stand (make record first). SCENE=context-tax @python3 scripts/record-gif.py $(or $(SCENE),context-tax) .PHONY: reload reload: build ## Build the dashboard + binaries and restart the running daemon on this machine @# Runs ./bin/caprock rather than installing over the copy on PATH. @# @# It used to `install` over whatever `command -v caprock` found, which on a @# Homebrew machine replaces brew's symlink with a plain file: `brew upgrade` @# then reports success while PATH still resolves to the dev build, and the @# next `brew doctor` complains. Exactly the breakage the update dialog warns @# about, caused by our own dev loop. Recovering meant `brew link --overwrite`. @$(BIN)/caprock down >/dev/null 2>&1 || true @$(BIN)/caprock up --no-open >/dev/null 2>&1 || true @echo "reloaded → $$($(BIN)/caprock --version) (from ./bin; the copy on PATH is untouched)"