| name | go-makefile |
| description | The Makefile for a Go project - pinned asset downloads, build targets with ldflags version injection, docker targets, and the semver version calculation. Use when creating or changing a Makefile, adding an asset to download, wiring a build for another platform, or working out how the release version is computed. Triggers on Makefile, make assets, make build-all, GOOS, GOARCH, CGO_ENABLED, ldflags, AppVersion, and the version target. |
| user-invocable | false |
Go Makefile
One Makefile that downloads pinned assets, builds every platform binary, and calculates the next version from the last commit.
A Web Only or CLI + Web project uses the whole file. A CLI Only project deletes the two blocks marked below, since it has no frontend and no container. A Headless API Service keeps the docker block and deletes the assets block.
Assets Are Never Committed
make assets downloads everything under css/, js/, fonts/, and fontawesome/, and the release workflow calls that same target rather than reimplementing the downloads. One definition means CI and a laptop produce the same tree.
Those directories are listed in .gitignore. A downloaded asset in a commit is a binary nobody reviews, a version nobody can trace, and a merge conflict nobody can resolve.
Every version is pinned to an exact release. A floating @latest makes two builds of one commit differ, and a new major arriving overnight breaks rendering with no diff to point at. Moving a pin is an edit to this file, which is the intended maintenance cost.
The whole target is guarded by a stamp file whose only prerequisite is the Makefile. make build on an unchanged checkout then downloads nothing, and moving any pin re-downloads everything, because every pin lives in the file the stamp depends on.
.gitignore needs its own entry for internal/server/static/.assets-stamp, which sits beside the asset directories rather than inside one. Its leading dot also keeps it out of //go:embed static, which skips names starting with . or _.
Fonts
Three families are downloaded by default and all three come from Google Fonts as woff2. Nothing is converted, since the css2 endpoint already serves woff2 to a browser-shaped User-Agent.
| Family | Role |
|---|
| Inter | body and UI text |
| Google Sans | display headings and branding |
| JetBrains Mono | code and monospace |
Only the latin and latin-ext blocks of each stylesheet are kept. Google Fonts declares every subset it has, which for Google Sans is twenty-five files covering scripts the page never renders, and go:embed compiles all of them into the binary whether a browser asks for them or not. Filtering leaves six woff2 files across the three families.
The Nerd Font variant is off by default. It exists only for a page that renders Nerd Font glyphs, and it costs two megabytes and roughly ten seconds of woff2 compression against sixty kilobytes and half a second for the plain family. Set NERDFONT := 1 in the Makefile when a page needs the glyphs; the target then fills the same css/jetbrains-mono.css under the same JetBrains Mono family name, so no page changes either way.
Template
.PHONY: help assets verify-assets font nerdfont fontawesome clean build build-for build-all docker-build docker-push version
APP_NAME := [APP_NAME]
DOCKER_USER := [GITHUB_USER]
MODULE := github.com/[GITHUB_USER]/[APP_NAME]
VERSION ?= dev-build
GOOS ?= $(shell go env GOOS)
GOARCH ?= $(shell go env GOARCH)
TAILWIND_VERSION := 4.3.3
LUCIDE_VERSION := 1.34.0
FONTAWESOME_VERSION := 7.3.1
DEVICON_VERSION := 2.17.0
MARKED_VERSION := 18.0.11
HIGHLIGHTJS_VERSION := 11.12.0
MERMAID_VERSION := 11.17.2
CHARTJS_VERSION := 4.5.1
NERDFONT_VERSION := 3.5.1
NERDFONT := 0
STATIC_DIR := internal/server/static
JS_DIR := $(STATIC_DIR)/js
CSS_DIR := $(STATIC_DIR)/css
FONTS_DIR := $(STATIC_DIR)/fonts
FA_DIR := $(STATIC_DIR)/fontawesome
STAMP := $(STATIC_DIR)/.assets-stamp
MONO := $(if $(filter 1,$(NERDFONT)),nerdfont,font FAMILY="JetBrains+Mono" SLUG=jetbrains-mono WEIGHTS="400;700")
UA := Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36
UVX := uv tool run
CYAN := \033[0;36m
GREEN := \033[0;32m
NC := \033[0m
help: ## Show this help
@echo
@grep -E '^[a-zA-Z_-]+:.*?
.DEFAULT_GOAL := help
@:
:
@mkdir -p /css /webfonts
@curl -sfL -o
@curl -sfL -o
@curl -sfL -o
@curl -sfL -o
@curl -sfL -o
@curl -sfL -o
@curl -sfL -o
@curl -sfL -o
@ --no-print-directory fontawesome
@ --no-print-directory font FAMILY= SLUG=inter WEIGHTS=
@ --no-print-directory font FAMILY= SLUG=google-sans WEIGHTS=
@ --no-print-directory
@touch
@echo
@curl -sfL -H \
\
-o
@awk '/^\/\* /{keep = ($$0 ~ /^\/\* latin(-ext)? \*\/$$/)} keep' \
>
@rm -f
@grep -o 'https://fonts.gstatic.com/[^)]*' | sort -u \
| xargs -P 8 -I{} sh -c 'curl -sfL -o $$1' _ {}
@sed -i.bak -E 's|https://fonts\.gstatic\.com/[^)]*/([^/)]+)|/static/fonts/\1|g'
@rm -f
@set -e; tmp=; trap 'rm -rf ' EXIT; \
curl -sfL -o \
; \
tar -xJf -C \
JetBrainsMonoNerdFontMono-Regular.ttf JetBrainsMonoNerdFontMono-Bold.ttf; \
for w in Regular Bold; do \
-q --from fonttools ttLib.woff2 compress \
-o \
>/dev/null 2>&1; \
done
@{ \
for pair in 400:Regular 700:Bold; do \
printf '@font-face{font-family:;font-style:normal;font-weight:%s;font-display:swap;src:url() format();}\n' \
; \
done; \
} >
@curl -sfL -o
@for f in fa-brands-400 fa-regular-400 fa-solid-900; do \
curl -sfL -o ; \
done
@sed -i.bak 's|../webfonts/|/static/fontawesome/webfonts/|g'
@rm -f
@test -s /tailwind.js || (echo && exit 1)
@test -s /inter.css || (echo && exit 1)
@test -s /google-sans.css || (echo && exit 1)
@test -s /jetbrains-mono.css || (echo && exit 1)
@rm -f -*
@rm -rf
@echo
@go build -ldflags= -o .
@echo
@CGO_ENABLED=0 GOOS= GOARCH= go build \
-ldflags= \
-o -- .
@echo
@ build-for GOOS=linux GOARCH=amd64
@ build-for GOOS=linux GOARCH=arm64
@ build-for GOOS=darwin GOARCH=amd64
@ build-for GOOS=darwin GOARCH=arm64
@docker build --build-arg VERSION= -t /: .
@docker tag /: /:latest
@docker buildx build --platform linux/amd64,linux/arm64 \
--build-arg VERSION= \
-t /: \
-t /:latest \
--push .
@LATEST_TAG=$; \
LATEST_TAG=$${LATEST_TAG
MAJOR=$; \
MINOR=$; \
PATCH=$; \
MAJOR=$${MAJOR:-0}; MINOR=$${MINOR:-0}; PATCH=$${PATCH:-0}; \
COMMIT_MSG=; \
if echo | grep -q ; then \
MAJOR=$$((MAJOR + 1)); MINOR=0; PATCH=0; \
elif echo | grep -q ; then \
MINOR=$$((MINOR + 1)); PATCH=0; \
\
PATCH=$$((PATCH + 1)); \
fi; \
echo
Notes
make build depends on assets so a fresh clone compiles. //go:embed static fails at compile time when the directory it names is empty, and an untracked asset tree means it always is on a first checkout.
build-for depends on verify-assets rather than assets, so a matrix of platform builds downloads once and then checks, instead of re-downloading per architecture.
Every target is silent on success apart from the one line that says what it produced. A tool that narrates its own progress buries the one line a failed build needs, which is why fonttools has both its streams redirected and uv runs under -q.
docker-push builds both architectures with buildx rather than tagging whatever the local daemon produced, so the pushed manifest serves an arm64 host an arm64 image.
CGO_ENABLED=0 produces a static binary with no libc dependency, which is what lets one Linux build run on any distribution and lets the container's final stage be almost empty.
-ldflags "-s -w" strips the symbol table and DWARF data. The version is injected into the same flag, so AppVersion is a build input rather than a constant somebody has to remember to edit.
The -X path matches the package holding AppVersion. It is $(MODULE)/cmd.AppVersion when the variable lives in cmd/root.go, and main.Version when it lives in main.go.
version is the single definition of how a release number is derived, and the release workflow calls make -s version rather than repeating the logic. Two implementations of a version calculation drift, and the one in CI is the one nobody runs locally.
| Last commit contains | Bump | Example |
|---|
| nothing special | patch | v1.0.0 to v1.0.1 |
[minor-release] | minor | v1.0.1 to v1.1.0 |
[major-release] | major | v1.1.0 to v2.0.0 |
Extension Variant
A Chrome extension replaces the build block with a zip target and reads its version from manifest.json, since the manifest is what the browser installs against.
EXT_NAME := [EXTENSION_NAME]
VERSION ?= $(shell grep '"version"' manifest.json | head -1 | sed 's/.*"version": "\(.*\)".*/\1/')
DIST_DIR := dist
SRC_FILES := manifest.json popup/ content/ background/ icons/ lib/
build: clean ## Build the distributable zip
@mkdir -p $(DIST_DIR)
@zip -r $(DIST_DIR)/$(EXT_NAME)-$(VERSION).zip \
$(shell for f in $(SRC_FILES); do [ -e "$$f" ] && echo "$$f"; done) \
-x "*.DS_Store" -x "*/.git/*"
dev: ## Print how to load the unpacked extension
@echo "chrome://extensions -> Developer mode -> Load unpacked -> $(PWD)"
Only directories that exist are zipped, so an extension with no content script produces a valid archive rather than a shell error.