# Define variables for the application APP_NAME = quartermaster BUILD_DIR = build # Get the current Git hash GIT_HASH := $(shell git rev-parse --short HEAD) ifneq ($(shell git status --porcelain),) # There are untracked changes GIT_HASH := $(GIT_HASH)+ endif # Capture the current build date in RFC3339 format BUILD_DATE := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") # Default target: Builds binaries for both OSX and Linux all: mac linux simple-responder # Clean build directory clean: rm -rf $(BUILD_DIR) # use cached test results while developing test-dev: go test -short ./... staticcheck ./... || true test: go test -short -count=1 ./internal/... # for CI - full test (takes longer) test-all: go test -race -count=1 ./internal/... ui-svelte/node_modules: cd ui-svelte && npm install # build the Svelte UI (embedded into every binary) # vite empties the out dir, taking the tracked placeholder with it. `touch` used # to put it back as a ZERO-BYTE file, which compiles (the embed only needs a file # to exist) but silently destroys the note explaining why the file may not be # deleted - and left every UI build with a dirty working tree. Copy the real # content back instead, from a source outside the directory vite wipes. ui: ui-svelte/node_modules cd ui-svelte && npm run build cp internal/server/ui_dist_placeholder.txt internal/server/ui_dist/placeholder.txt # Build OSX binary mac: ui @echo "Building Mac binary..." GOOS=darwin GOARCH=arm64 go build -ldflags="-X main.commit=${GIT_HASH} -X main.version=local_${GIT_HASH} -X main.date=${BUILD_DATE}" -o $(BUILD_DIR)/$(APP_NAME)-darwin-arm64 ./cmd/quartermaster # Build Linux binary linux: linux-arm64 linux-amd64 linux-amd64: ui @echo "Building Linux AMD64 binary..." GOOS=linux GOARCH=amd64 go build -ldflags="-X main.commit=${GIT_HASH} -X main.version=local_${GIT_HASH} -X main.date=${BUILD_DATE}" -o $(BUILD_DIR)/$(APP_NAME)-linux-amd64 ./cmd/quartermaster linux-arm64: ui @echo "Building Linux ARM64 binary..." GOOS=linux GOARCH=arm64 go build -ldflags="-X main.commit=${GIT_HASH} -X main.version=local_${GIT_HASH} -X main.date=${BUILD_DATE}" -o $(BUILD_DIR)/$(APP_NAME)-linux-arm64 ./cmd/quartermaster # Windows VERSIONINFO resource. cmd/quartermaster/resource_windows_amd64.syso is # committed (Go links any .syso in the main package automatically, which is why it # and favicon.ico live beside the main package rather than at the repo root), and # `go build` links whatever copy is on disk. A RELEASE does not use the committed # one: packaging/windows/build-release.ps1 regenerates both resources from the tag # it is releasing and puts the committed bytes back afterwards. This rule is what # dev and packaging builds get, so it stamps the newest existing tag instead. # Commit the .syso after it regenerates. # # These are FILE targets, not phony ones, so make rebuilds them exactly when # favicon.ico or versioninfo.json is newer and skips the tool otherwise. The # windows/setup-windows targets depend on them, because an icon change that only # touches favicon.ico used to leave the exe carrying the previous icon with no # warning. FileDescription is what the # Windows Startup apps list / Task Manager shows for the autostart entry, so it # must read "Quartermaster" — keep it in sync with the Run value name in # internal/server/autostart.go. NOTE: a 0.0.0.0 FixedFileInfo makes Windows drop # the whole string table; keep the version non-zero. # # -icon embeds favicon.ico as the exe's application icon (resource ID 1). Without # it the exe has no icon at all, so Explorer, the taskbar, and the Startup apps # list all fall back to the blank generic-executable glyph. # The version stamped into both Windows VERSIONINFO resources. The two # versioninfo.json files carry 1.0.0.0 as a placeholder and nothing bumps them by # hand: the number comes from the newest release tag, via goversioninfo's # -ver-*/-product-ver-* overrides, which beat the JSON without touching it. # packaging/windows/build-release.ps1 does the same thing from the tag it is # releasing, so a release exe is stamped even when its tag does not exist yet. # # Prerelease tags (v1.0.4-rc1) are filtered out: FixedFileInfo is four integers # with nowhere to put a suffix, and a dev build should not claim to BE the rc. # The filter cannot be a make pattern: only the FIRST '%' in a pattern is a # wildcard and the rest are literal, so `filter-out %-%` matches nothing and the # prerelease tag reaches -ver-patch, which goversioninfo rejects outright (a # build that worked while the newest tag was a release, and stops working the # moment an rc is cut). findstring over the list says what it means instead. # With no tags at all VI_FLAGS is empty and the JSON's own values are used -- # note a 0.0.0.0 FixedFileInfo makes Windows drop the whole string table, which # is why the placeholder in the JSON is not zero. VI_TAGS := $(shell git tag --list "v[0-9]*.[0-9]*.[0-9]*" --sort=-v:refname) VI_TAG := $(firstword $(foreach t,$(VI_TAGS),$(if $(findstring -,$(t)),,$(t)))) VI_VER := $(patsubst v%,%,$(VI_TAG)) VI_PARTS := $(subst ., ,$(VI_VER)) ifneq ($(VI_VER),) VI_FLAGS := -ver-major $(word 1,$(VI_PARTS)) -ver-minor $(word 2,$(VI_PARTS)) -ver-patch $(word 3,$(VI_PARTS)) -ver-build 0 -product-ver-major $(word 1,$(VI_PARTS)) -product-ver-minor $(word 2,$(VI_PARTS)) -product-ver-patch $(word 3,$(VI_PARTS)) -product-ver-build 0 -file-version $(VI_VER).0 -product-version $(VI_VER) endif # A file target cannot see that `git tag` moved, so the version is carried in a # stamp FILENAME: a new tag names a file that does not exist, the rule runs, and # its fresh mtime pulls both .syso files with it. Without this the resources # would keep whatever version they were generated at until the JSON or the icon # happened to change. VI_STAMP = $(BUILD_DIR)/versioninfo-$(VI_VER).stamp $(VI_STAMP): @mkdir -p $(BUILD_DIR) && rm -f $(BUILD_DIR)/versioninfo-*.stamp && touch $@ QM_SYSO = cmd/quartermaster/resource_windows_amd64.syso SETUP_SYSO = cmd/quartermaster-setup/resource_windows_amd64.syso $(QM_SYSO): cmd/quartermaster/favicon.ico cmd/quartermaster/versioninfo.json $(VI_STAMP) go run github.com/josephspurrier/goversioninfo/cmd/goversioninfo@v1.7.0 -icon cmd/quartermaster/favicon.ico $(VI_FLAGS) -o $@ cmd/quartermaster/versioninfo.json versioninfo: $(QM_SYSO) # Same, for the setup program. Its own .syso, in its own package directory: a # .syso is linked by the main package that sits beside it, so the cmd/quartermaster # one reaches the server binary and nothing else. Without this the wizard's exe has # the blank generic-executable glyph in Explorer AND in the taskbar, which is # the first thing a user sees of the app. $(SETUP_SYSO): cmd/quartermaster/favicon.ico cmd/quartermaster-setup/versioninfo.json $(VI_STAMP) go run github.com/josephspurrier/goversioninfo/cmd/goversioninfo@v1.7.0 -icon cmd/quartermaster/favicon.ico $(VI_FLAGS) -o $@ cmd/quartermaster-setup/versioninfo.json versioninfo-setup: $(SETUP_SYSO) # Build the first-run wizard's UI bundle (embedded into cmd/quartermaster-setup, # NOT into the server binary — see internal/setup/api.go). ui-setup: ui-svelte/node_modules cd ui-svelte && npm run build:setup # Build the setup program: a native window (WebView2) that asks the first-run # questions and drives the Inno installer silently. # # A dev build embeds only the zero-byte placeholder at cmd/quartermaster-setup/ # inno/setup.exe, so it falls back to copying whatever quartermaster binaries sit # next to it — which makes this a runnable end-to-end test of the wizard (probe, # scan, backend download, config write, launch) with no Inno toolchain in the # loop. Copy the exe into build/quartermaster-windows/ first if you want it to # pick up the config examples too. The release build # (packaging/windows/build-release.ps1) is what embeds the real installer. setup-windows: ui-setup $(SETUP_SYSO) @echo "Building Windows setup program..." GOOS=windows GOARCH=amd64 go build -ldflags="-H=windowsgui -X main.commit=${GIT_HASH} -X main.version=local_${GIT_HASH} -X main.date=${BUILD_DATE}" -o $(BUILD_DIR)/$(APP_NAME)-setup.exe ./cmd/quartermaster-setup # Build Windows binary windows: ui $(QM_SYSO) @echo "Building Windows binary..." GOOS=windows GOARCH=amd64 go build -ldflags="-H=windowsgui -X main.commit=${GIT_HASH} -X main.version=local_${GIT_HASH} -X main.date=${BUILD_DATE}" -o $(BUILD_DIR)/$(APP_NAME)-windows-amd64.exe ./cmd/quartermaster # Assemble a runnable Windows folder + zip (binary, configs, launcher, service files). # NOTE: llama-server.exe (llama.cpp) and GGUF models are NOT bundled — separate # projects/licensing. Copy quartermaster-generate.example.yaml to # quartermaster-generate.yaml and edit modelsRoot / serverExe before use. PKG_WIN_DIR = $(BUILD_DIR)/quartermaster-windows package-windows: windows @echo "Packaging Windows bundle..." # Refresh the bundle IN PLACE — overwrite only the shipped artifacts and never # touch user data: playground-data/ (chats/prefs/logins), runtime config.yaml, # the user-edited quartermaster-generate.yaml, and the UI-owned overrides # sidecar (quartermaster-overrides.yaml, holds API keys) all survive untouched. # Only the regenerated packaging/ subtree is removed first, to drop files that # were renamed/removed across versions. (No `rm -rf` of the bundle dir itself: # on Windows a file-watcher often holds a handle and it fails "resource busy".) mkdir -p $(PKG_WIN_DIR)/config # templates/ is no longer shipped; the rm stays so an existing install that # still has the folder from an older package loses it on the next re-package. rm -rf $(PKG_WIN_DIR)/packaging $(PKG_WIN_DIR)/templates # Installed as Quartermaster.exe; the old lowercase build-artifact name is # removed so a re-packaged bundle does not keep two copies, one of which # would never be updated again. cp $(BUILD_DIR)/$(APP_NAME)-windows-amd64.exe $(PKG_WIN_DIR)/Quartermaster.exe rm -f $(PKG_WIN_DIR)/$(APP_NAME)-windows-amd64.exe cp quartermaster-generate.example.yaml $(PKG_WIN_DIR)/config/quartermaster-generate.example.yaml # Seed the runtime generate file from the example only when none exists yet, # so a re-package never clobbers the user's edited quartermaster-generate.yaml. @if [ -f $(PKG_WIN_DIR)/config/quartermaster-generate.yaml ]; then \ echo " kept existing config/quartermaster-generate.yaml"; \ else \ cp quartermaster-generate.example.yaml $(PKG_WIN_DIR)/config/quartermaster-generate.yaml; \ echo " seeded config/quartermaster-generate.yaml from example"; fi cp config.example.yaml $(PKG_WIN_DIR)/config/config.example.yaml cp -r packaging $(PKG_WIN_DIR)/packaging @echo "$(APP_NAME) $(GIT_HASH) built $(BUILD_DATE)" > $(PKG_WIN_DIR)/VERSION.txt # Zip a CLEAN distributable, OPT-IN via ZIP=1. The bundle doubles as a live # install, so it accumulates ~1.3 GB of FETCHED artifacts the archive must not # carry: bin/ (backend exes the installer wizard downloads on first run) and # config/titlegen/ (the 79 MB title model, fetched on demand). Deflating those # turned a seconds-long re-package into a minutes-long one — and nothing # consumes this zip today (installer.iss and build-release.ps1 both build from # the folder), so it is no longer made by default. # Excluded beyond that: user data living in the bundle — private chats, the # regenerated-on-launch config.yaml, and the overrides sidecar, which holds # the user's API keys and must never ship. @if [ "$(ZIP)" != "1" ]; then \ echo " skipped quartermaster-windows.zip (use ZIP=1 to build it)"; \ else \ cd $(BUILD_DIR) && rm -f quartermaster-windows.zip && \ if command -v zip >/dev/null 2>&1; then \ zip -qr quartermaster-windows.zip quartermaster-windows \ -x 'quartermaster-windows/playground-data/*' \ 'quartermaster-windows/.cache/*' \ 'quartermaster-windows/logs/*' \ 'quartermaster-windows/bin/*' \ 'quartermaster-windows/config/titlegen/*' \ 'quartermaster-windows/config/config.yaml' \ 'quartermaster-windows/config/quartermaster-overrides.yaml'; \ elif command -v tar >/dev/null 2>&1; then \ tar -a -c -f quartermaster-windows.zip \ --exclude='quartermaster-windows/playground-data' \ --exclude='quartermaster-windows/.cache' \ --exclude='quartermaster-windows/logs' \ --exclude='quartermaster-windows/bin' \ --exclude='quartermaster-windows/config/titlegen' \ --exclude='quartermaster-windows/config/config.yaml' \ --exclude='quartermaster-windows/config/quartermaster-overrides.yaml' \ quartermaster-windows; \ else \ echo "WARN: no zip/tar found — folder left unarchived at $(PKG_WIN_DIR)"; \ fi; \ fi @echo "Done: $(PKG_WIN_DIR)" # Assemble a runnable Linux/Mac folder + tar.gz. Same in-place, user-data-preserving # refresh as package-windows; no launcher .cmd (the binary runs directly), but the # systemd unit ships under packaging/. ponytail: amd64 linux / arm64 mac only — add # more arches here if a release ever needs them. PKG_NIX_BIN_linux = $(APP_NAME)-linux-amd64 PKG_NIX_BIN_mac = $(APP_NAME)-darwin-arm64 package-linux: linux-amd64 @$(MAKE) --no-print-directory _package-nix NIX_OS=linux NIX_BIN=$(PKG_NIX_BIN_linux) package-mac: mac @$(MAKE) --no-print-directory _package-nix NIX_OS=mac NIX_BIN=$(PKG_NIX_BIN_mac) # NIX_OS = linux|mac, NIX_BIN = built binary filename in $(BUILD_DIR) _package-nix: @echo "Packaging $(NIX_OS) bundle..." $(eval PKG_NIX_DIR := $(BUILD_DIR)/$(APP_NAME)-$(NIX_OS)) mkdir -p $(PKG_NIX_DIR)/config rm -rf $(PKG_NIX_DIR)/packaging $(PKG_NIX_DIR)/templates cp $(BUILD_DIR)/$(NIX_BIN) $(PKG_NIX_DIR)/ cp quartermaster-generate.example.yaml $(PKG_NIX_DIR)/config/quartermaster-generate.example.yaml @if [ -f $(PKG_NIX_DIR)/config/quartermaster-generate.yaml ]; then \ echo " kept existing config/quartermaster-generate.yaml"; \ else \ cp quartermaster-generate.example.yaml $(PKG_NIX_DIR)/config/quartermaster-generate.yaml; \ echo " seeded config/quartermaster-generate.yaml from example"; fi cp config.example.yaml $(PKG_NIX_DIR)/config/config.example.yaml cp -r packaging $(PKG_NIX_DIR)/packaging @echo "$(APP_NAME) $(GIT_HASH) built $(BUILD_DATE)" > $(PKG_NIX_DIR)/VERSION.txt # Same exclusions as the Windows bundle: user data (chats, the regenerated # config.yaml, and the API-key-bearing overrides sidecar) plus the fetched # artifacts a live install accumulates (bin/, the slot-KV cache, the title # model). The tarball IS the linux/mac deliverable, so it is not opt-in. cd $(BUILD_DIR) && rm -f $(APP_NAME)-$(NIX_OS).tar.gz && \ tar --exclude='$(APP_NAME)-$(NIX_OS)/playground-data' \ --exclude='$(APP_NAME)-$(NIX_OS)/logs' \ --exclude='$(APP_NAME)-$(NIX_OS)/.cache' \ --exclude='$(APP_NAME)-$(NIX_OS)/bin' \ --exclude='$(APP_NAME)-$(NIX_OS)/config/titlegen' \ --exclude='$(APP_NAME)-$(NIX_OS)/config/config.yaml' \ --exclude='$(APP_NAME)-$(NIX_OS)/config/quartermaster-overrides.yaml' \ -czf $(APP_NAME)-$(NIX_OS).tar.gz $(APP_NAME)-$(NIX_OS) @echo "Done: $(PKG_NIX_DIR) (+ $(BUILD_DIR)/$(APP_NAME)-$(NIX_OS).tar.gz)" # for testing with real external processes simple-responder: @echo "Building simple responder" GOOS=darwin GOARCH=arm64 go build -o $(BUILD_DIR)/simple-responder_darwin_arm64 cmd/simple-responder/simple-responder.go GOOS=linux GOARCH=amd64 go build -o $(BUILD_DIR)/simple-responder_linux_amd64 cmd/simple-responder/simple-responder.go simple-responder-windows: @echo "Building simple responder for windows" GOOS=windows GOARCH=amd64 go build -o $(BUILD_DIR)/simple-responder.exe cmd/simple-responder/simple-responder.go # Ensure build directory exists $(BUILD_DIR): mkdir -p $(BUILD_DIR) # Build the four published binaries + SHA256SUMS into $(BUILD_DIR)/dist. # # This is the UPDATE deliverable: the app is one self-contained binary, so the # in-app updater (internal/update) downloads exactly one of these files and # renames it over the running exe. The names MUST match # internal/update.assetName() — a name that drifts means that platform silently # stops seeing updates. # # VERSION=vX.Y.Z stamps the release version; without it the binaries are marked # local_ and, by design, never offer themselves an update. DIST_DIR = $(BUILD_DIR)/dist DIST_VERSION = $(if $(VERSION),$(VERSION),local_$(GIT_HASH)) DIST_LDFLAGS = -X main.commit=$(GIT_HASH) -X main.version=$(DIST_VERSION) -X main.date=$(BUILD_DATE) dist: ui @echo "Building release binaries ($(DIST_VERSION))..." @mkdir -p $(DIST_DIR) # CGO is off across the project, so all four targets cross-compile from any # one host with nothing but the Go toolchain. # -H=windowsgui: no console window on launch, including the relaunch that # follows a self-update. CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="-H=windowsgui $(DIST_LDFLAGS)" -o $(DIST_DIR)/$(APP_NAME)-windows-amd64.exe ./cmd/quartermaster CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$(DIST_LDFLAGS)" -o $(DIST_DIR)/$(APP_NAME)-linux-amd64 ./cmd/quartermaster CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$(DIST_LDFLAGS)" -o $(DIST_DIR)/$(APP_NAME)-linux-arm64 ./cmd/quartermaster CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="$(DIST_LDFLAGS)" -o $(DIST_DIR)/$(APP_NAME)-darwin-arm64 ./cmd/quartermaster # sha256sum's own format, which is what the updater's SHA256SUMS fallback # parses. Generated from inside the dir so the entries are bare names. @cd $(DIST_DIR) && rm -f SHA256SUMS && \ if command -v sha256sum >/dev/null 2>&1; then \ sha256sum $(APP_NAME)-* > SHA256SUMS; \ else \ shasum -a 256 $(APP_NAME)-* > SHA256SUMS; \ fi @cat $(DIST_DIR)/SHA256SUMS @echo "Done: $(DIST_DIR)" # GitHub repo for `gh` (avoids "no default remote" when multiple remotes exist). RELEASE_REPO ?= Quartermaster-Labs/Quartermaster # Build every release artifact locally and upload it to a GitHub release. The # Release workflow runs this very script on a runner, so the two paths cannot # drift; this one stays for debugging a release without pushing a tag. What ships is # the setup programs (one per platform), the four bare server binaries and # SHA256SUMS. The binaries are the in-app updater's payload and the headless # install README documents, so a release without them both breaks updates for # every existing install (silently) and points README at files that are not # there. -SkipBinaries opts out; the release notes keep them from competing with # the setup programs by naming those first and collapsing the rest. # make release VERSION=v0.5.1 -> DRAFT release of that tag # make release-public VERSION=v0.5.1 -> same, but PUBLIC # make release-binaries VERSION=v0.5.1 -> binaries + sums only, no installer # # VERSION is MANDATORY. It used to default to the highest existing tag, which # built whatever HEAD was and published it under a version that tag need not # point at. build-release.ps1 also refuses a dirty tree, and refuses a tag that # already exists anywhere but HEAD. # Needs go, npm, gh (authed), and — unless -SkipInstaller — Inno Setup 6 (ISCC). release: @$(MAKE) --no-print-directory _build-release DRAFT=true release-public: @$(MAKE) --no-print-directory _build-release DRAFT=false # Skips ISCC entirely: a patch release that every existing install will pick up # through the updater does not need a new first-install wizard, and this is also # the fast way to test the update path itself. release-binaries: @$(MAKE) --no-print-directory _build-release DRAFT=true RELEASE_EXTRA="-SkipInstaller -PublishBinaries" _build-release: @if [ -z "$(VERSION)" ]; then \ echo "Error: VERSION=vMAJOR.MINOR.PATCH is required (e.g. make release VERSION=v0.5.1)" >&2; exit 1; fi # Kept in step with build-release.ps1's own check: a -SUFFIX is allowed and # makes the GitHub release a prerelease. Diverging here would reject locally # what the CI path accepts. @echo "$(VERSION)" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z][0-9A-Za-z.-]*)?$$' || { \ echo "Error: VERSION must be vMAJOR.MINOR.PATCH, optionally -SUFFIX (e.g. v0.5.1, v0.5.1-rc1)" >&2; exit 1; } # Created on HEAD when it does not exist yet. build-release.ps1 then verifies # the tag IS HEAD, so a tag that already lives on another commit stops the # release instead of quietly shipping that commit under this version. @git rev-parse "$(VERSION)" >/dev/null 2>&1 || git tag "$(VERSION)" powershell -NoProfile -ExecutionPolicy Bypass \ -File packaging/windows/build-release.ps1 -Tag $(VERSION) -Draft $(DRAFT) -Repo $(RELEASE_REPO) $(RELEASE_EXTRA) GOOS ?= $(shell go env GOOS 2>/dev/null || echo linux) GOARCH ?= $(shell go env GOARCH 2>/dev/null || echo amd64) test-ui: cd ui-svelte && npm ci && npm run check && npm test # Phony targets .PHONY: all clean ui dist mac windows versioninfo versioninfo-setup ui-setup setup-windows package-windows package-linux package-mac _package-nix simple-responder simple-responder-windows test test-all test-dev test-ui release release-public release-binaries _build-release .PHONY: linux linux-arm64 linux-amd64