-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathMakefile
More file actions
523 lines (461 loc) · 27.4 KB
/
Copy pathMakefile
File metadata and controls
523 lines (461 loc) · 27.4 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
# Managed by nicerobot/tools.repository (distribute-build)
# build/go/Makefile — the canonical shared Go toolchain Makefile.
#
# (Formerly "go-make". It now lives at nicerobot/tools.build/build/go/Makefile —
# moved under build/<language>/ so each language/use-case has its own canonical
# Makefile, e.g. build/python/Makefile.)
#
# ┌───────────────────────────────────────────────────────────────────────────┐
# │ DO NOT EDIT THIS FILE IN A CONSUMER REPO. │
# │ │
# │ This Makefile is DISTRIBUTED into every registered org's Go repos by the │
# │ push-tooling in nicerobot/tools.repository (sourced from │
# │ nicerobot/tools.build/build/go/Makefile). It is owned upstream — an in-tree │
# │ edit is OVERWRITTEN by the next push. It does NOT point outward (no self- │
# │ update, no network): the canonical copy is pushed TO repos, never pulled. │
# │ │
# │ • Change shared behavior -> edit nicerobot/tools.build/build/go/Makefile,│
# │ then re-run the push from tools.repository. │
# │ • Customize ONE repo -> add a Makefile.local (see the tail of this │
# │ file): set the documented hook variables (COVER_GATE, INTEGRATION_PKG, │
# │ COVER_PKGS, …) and ADD new targets there — never redefine a target │
# │ this file already declares. │
# └───────────────────────────────────────────────────────────────────────────┘
#
# Single source of truth for how every Go library and service runs
# vet / lint / staticcheck / tests / formatting and builds binaries. A repo runs
# its own in-tree copy directly:
#
# make lint test build # from inside any repo
#
# CI checks out only that repo and runs `make ci` inside the nicerobot/tools.build
# go gate, whose image bakes the pinned tool set into ${GOBIN} — so the repo needs
# no tool stanza and no vendored tooling:
# - uses: actions/checkout@v5
# - uses: nicerobot/tools.build/ci/go@v2 # FROM the go-tooling image; runs `make ci`
# (Run the gate locally with `make tools` once to populate ${GOBIN}, then `make ci`.)
#
# Everything else is derived from the repo's own source of truth:
# BINARIES <- the `id:` values under `builds:` in ./.goreleaser.yml
# SUBMODULES <- nested go.mod dirs (excluding vendor/testdata/fixtures)
# Override either on the command line for the rare repo that needs to.
#
# The canonical tool set lives in ONE place — the pinned manifest
# nicerobot/tools.build/go-tooling/tools.txt — and is installed as real binaries
# into ${GOBIN} via `go install path@version` (see the tools section below).
# Consumer repos carry NO tool stanza and vendor NO tool dependencies; this
# Makefile resolves every tool from $(GOBIN) only and fails loudly if it is
# missing.
# A Self-Documenting Makefile: http://marmelab.com/blog/2016/02/29/auto-documented-makefile.html
.DEFAULT_GOAL := test
.PHONY: help
help: ## This help
@awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m[ target... ]\033[0m\n"} /^[a-zA-Z_0-9@\/ -]+:.*?##/ { printf " \033[36m%-30s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(sort $(MAKEFILE_LIST))
# --------------------------------------------------------------------------- #
# tools
# --------------------------------------------------------------------------- #
# Tools are resolved from ${GOBIN} and ONLY ${GOBIN} — never PATH, never
# $(go env GOPATH)/bin. They are provisioned there OUT OF BAND, not by this
# Makefile, which never builds or discovers a tool:
#
# • Locally — `make tools` (delegates to the tools.build installer script,
# which persists GOBIN to ${HOME}/go/bin when unset, then installs).
# • In CI — the nicerobot/tools.build go gate runs inside the go-tooling image,
# which bakes the same pinned binaries and sets GOBIN to where they live.
#
# Why ${GOBIN} only: a tool the gate silently picks up from PATH (e.g. a `brew
# install`ed copy) would be an unpinned, drifting version masquerading as the
# canonical one. Resolving every tool by its absolute $(GOBIN)/<name> path makes
# a brew/PATH copy irrelevant to the gate. (`make doctor` warns when a brew copy
# shadows the pinned one for bare-name runs — agents/humans invoking by name.)
#
# GOBIN must be a SINGLE directory. We deliberately do NOT fall back to
# `$(go env GOPATH)/bin`: GOPATH may be a list of paths, so that fallback is
# ambiguous and breaks in multi-entry-GOPATH environments. gobin-or-die expands
# to GOBIN, or aborts make with a clear message the moment a tool is first used —
# so `make help`/`clean` work without GOBIN, but any gate target requires it.
GO ?= go
GOBIN := $(strip $(shell $(GO) env GOBIN))
gobin-or-die = $(or $(GOBIN),$(error GOBIN is not set — run 'make tools' (it persists GOBIN and installs the pinned tools); the gate resolves tools from $${GOBIN} only, no PATH/GOPATH fallback))
GOLANGCI_LINT = $(gobin-or-die)/golangci-lint
STICKLER = $(gobin-or-die)/stickler
STATICCHECK = $(gobin-or-die)/staticcheck
GOFUMPT = $(gobin-or-die)/gofumpt
GOTESTSUM = $(gobin-or-die)/gotestsum
GOVULNCHECK = $(gobin-or-die)/govulncheck
GORELEASER = $(gobin-or-die)/goreleaser
GOLINES = $(gobin-or-die)/golines
# Maximum source line length enforced by golines (it shortens longer lines).
GOLINES_MAX ?= 120
# TOOLS_BUILD points at a local nicerobot/tools.build checkout so `make tools` /
# `make doctor` can run its scripts. Defaults to the home-ecosystem clone path;
# override on the command line elsewhere. CI never runs these (the image bakes the
# tools), so they are developer conveniences only.
TOOLS_BUILD ?= $(HOME)/src/github.com/nicerobot/tools.build
.PHONY: tools
tools: ## Install the canonical tool set into ${GOBIN} (bootstraps GOBIN if unset)
$(TOOLS_BUILD)/scripts/go-install-tools.sh
.PHONY: doctor
doctor: ## Warn if a Homebrew copy shadows a pinned ${GOBIN} tool for bare-name runs
$(TOOLS_BUILD)/scripts/go-doctor.sh
.PHONY: tools-version
tools-version: ## Print the version of every pinned tool (from ${GOBIN})
$(GOLANGCI_LINT) version
$(STATICCHECK) -version
$(GOFUMPT) --version
$(GOTESTSUM) --version
$(GORELEASER) --version
# --------------------------------------------------------------------------- #
# Consumer configuration (derived; override on the command line if ever needed)
# --------------------------------------------------------------------------- #
# BINARIES: the `id:` values under `builds:` in the consumer's goreleaser config.
# Derived with yq (not awk over raw YAML, which mis-parsed a templated id like
# `id: "{{ .ProjectName }}"` into `--id "{{` and broke the build). The config is
# either .goreleaser.yaml or .goreleaser.yml; an id that is itself a
# `{{ .ProjectName }}` template — or absent — resolves to the project_name value,
# while literal ids pass through unchanged.
# The yq pipeline: bind project_name as ${pn}, take each build id (falling back to
# ${pn} when a build sets none), and replace a `{{ ... }}` template id with ${pn} —
# so a literal id passes through and a `{{ .ProjectName }}` id resolves to the
# project name. One yq call, no shell loop.
GORELEASER_CONFIG ?= $(firstword $(wildcard .goreleaser.yaml .goreleaser.yml))
BINARIES ?= $(shell test -n '$(GORELEASER_CONFIG)' && yq '.project_name as $$pn | .builds[] | select(.skip != true) | .id // $$pn | sub("\{\{.*\}\}", $$pn)' '$(GORELEASER_CONFIG)' 2>/dev/null)
# SUBMODULES: nested modules (own go.mod), excluding vendored/test-fixture mods,
# Terraform-downloaded module sources under .terraform/, and anything beneath a
# `_`-prefixed directory (e.g. _legacy/). The go tool itself ignores `_`-prefixed
# (and `.`-prefixed) directories, so a stale, package-less, or otherwise
# unbuildable module parked under one must NOT enter the vet/test/cover fan-out —
# it would fail `go vet -C <dir> ./...` with "matched no packages" or stale deps.
SUBMODULES ?= $(patsubst ./%/,%,$(dir $(shell find . -mindepth 2 -name go.mod -not -path '*/vendor/*' -not -path '*/testdata/*' -not -path '*/fixtures/*' -not -path '*/.terraform/*' -not -path '*/tools/*' -not -path '*/_*/*')))
BUILD_DIR ?= bin
GOOS ?= $(shell go env GOOS)
GOARCH ?= $(shell go env GOARCH)
COVERAGE_FOLDER ?= var
GO_TEST_FORMAT ?= standard-verbose
# Coverage gate: COVER_PKGS is the set of packages whose AGGREGATE statement
# coverage must reach COVER_THRESHOLD — EVERY package at 100%, default ./...,
# and that includes cmd/. cmd/* is NOT exempt: keep main() a thin shim that calls
# a testable run(...) with injected collaborators (the urfave/cli v3 pattern), so
# the composition root is fully covered like everything else.
# The ONLY legitimate narrowing is to drop COMMITTED GENERATED trees whose lines
# are machine-authored, e.g.:
# COVER_PKGS = $(shell go list ./... | grep -v /src/proto | grep -v /src/grammar)
# COVERPKG is the comma-joined form `go test -coverpkg` needs (so coverage is
# attributed across the same set, not just the package under test).
comma := ,
empty :=
space := $(empty) $(empty)
COVER_PKGS ?= ./...
COVER_MODE ?= atomic
COVER_THRESHOLD ?= 100.0%
COVERPKG ?= $(subst $(space),$(comma),$(strip $(COVER_PKGS)))
# COVER_GATE names the target that `check`/`ci` run to enforce coverage. The
# default is the flat aggregate `cover` gate above. A repo with a different
# policy (e.g. a per-package ratchet with auditable per-function exceptions)
# sets COVER_GATE in its Makefile.local to its own NEW target — swapping the
# gate WITHOUT redefining `check`, `ci`, or `cover`. Routed via `cover-gate`.
COVER_GATE ?= cover
export BUILD_NUMBER ?= 9999999
export GORELEASER_BUILD_FLAGS ?=
$(BUILD_DIR) $(COVERAGE_FOLDER):
mkdir -p $@
##@ CI
# `ci` is the HARD CI gate — the single source of truth for what every push must
# pass. It is a SUPERSET of `check` (the developer gate): the same static,
# vulnerability and 100%-`cover` gates, plus the race detector (`test-all`) and
# cross-platform compilation (`build-all`) that `check` skips for local speed.
# `vulncheck` and `cover` were once held out of CI during a soft rollout;
# consumers are green, so they are enforced on every push now — coverage and
# vulnerabilities can no longer silently regress in CI.
.PHONY: ci
ci: standards-validate fmt-check lint staticcheck vulncheck cover-gate test-all build-all ## Aggregate target for CI builds
# True CI parity: run the real `ci` recipe INSIDE the baked toolchain image,
# so it uses the pinned tools and the exact base environment CI runs in — not the
# host's Go. The consumer checkout is bind-mounted and its own Makefile drives
# `ci` (BUILD_HOME is baked into the image, so the include resolves, and any
# consumer ci extensions run too). Everything is vendored, so no module cache
# mount is needed. -it only when attached to a TTY, so it works in scripts.
BUILD_IMAGE ?= $(DOCKER_REGISTRY)/build:$(DOCKER_IMAGE_TAG)
DOCKER_TTY := $(shell test -t 0 && echo -it)
.PHONY: ci-local
ci-local: ## Run the CI aggregate inside the baked image, exactly as CI does
docker run --rm $(DOCKER_TTY) \
--volume $(CURDIR):$(CURDIR) \
--workdir $(CURDIR) \
$(BUILD_IMAGE) \
make ci
##@ Code Quality
# --------------------------------------------------------------------------- #
# Standards exemptions (anti-rot ratchet)
# --------------------------------------------------------------------------- #
# A repo may carry a hand-authored .standards.yaml declaring capabilities it does
# NOT yet satisfy (registry: nicerobot/tools.build/standards/capabilities.yaml),
# each with a reason:
#
# exempt:
# gate:coverage: "legacy paths uncovered; backfill tracked in <issue>"
#
# Each gate step routes through `standards-run`, which applies the ratchet:
# - capability NOT exempt -> run the step, pass its exit through (default).
# - exempt AND step FAILS -> the declared gap holds; warn and succeed.
# - exempt AND step PASSES -> the exemption is STALE; fail so it gets removed.
# Exemptions can only shrink. With no .standards.yaml, EXEMPT is empty and every
# step behaves exactly as before — zero change for the common case.
STANDARDS_FILE ?= .standards.yaml
EXEMPT := $(if $(wildcard $(STANDARDS_FILE)),$(shell yq -r '.exempt // {} | keys | .[]' $(STANDARDS_FILE) 2>/dev/null))
# $(call standards-run,<capability>,<command>) — see the ratchet table above.
define standards-run
$(if $(filter $(1),$(EXEMPT)),if $(2); then echo "STANDARDS: stale exemption '$(1)' now PASSES — remove it from $(STANDARDS_FILE)" >&2; exit 1; else echo "STANDARDS: '$(1)' exempt (declared gap, not enforced)" >&2; fi,$(2))
endef
# standards-validate: every exemption must carry a non-empty reason string. (Full
# registry-membership validation lives in `git repo standards verify`, which has
# the registry; the gate stays self-contained so it runs unchanged in the CI image.)
.PHONY: standards-validate
standards-validate: ## Validate .standards.yaml exemptions carry reasons
@test -f $(STANDARDS_FILE) || exit 0; \
bad=$$(yq -r '.exempt // {} | to_entries | map(select(.value == null or .value == "")) | .[].key' $(STANDARDS_FILE) 2>/dev/null); \
test -z "$${bad}" || { echo "STANDARDS: exemptions missing a reason: $${bad}" >&2; exit 1; }
# `check` is the comprehensive DEVELOPER gate: run it locally before pushing. It
# is the static + `vulncheck` + 100%-`cover` core that `ci` ALSO enforces, so a
# local `check` pass predicts a green CI; `ci` is a superset that additionally
# runs `test-all` (race) and `build-all` (cross-compile). The complexity linters
# are part of `lint` now (folded into .golangci.yaml).
.PHONY: check
check: standards-validate fmt-check lint staticcheck vulncheck cover-gate ## Full developer gate (CI runs this + race & cross-compile)
# cover-gate routes the coverage step through $(COVER_GATE) (default `cover`) so
# a repo can swap the coverage policy by setting COVER_GATE in Makefile.local —
# without redefining `check`, `ci`, or `cover`. Deferred to a sub-make so the
# Makefile.local override (read at the tail) is in effect when it runs.
.PHONY: cover-gate
cover-gate: ## Run the active coverage gate (COVER_GATE, default `cover`), ratchet-aware
@$(call standards-run,gate:coverage,$(MAKE) $(COVER_GATE))
# Per-submodule vet targets via a static pattern rule (NOT `vet-%:` — GNU make
# skips implicit/pattern rules for phony targets; a static pattern with an
# explicit target list fires correctly).
VET_SUBMODULES := $(addprefix vet@,$(SUBMODULES))
.PHONY: vet $(VET_SUBMODULES)
vet: $(VET_SUBMODULES) ## Run go vet (root module + submodules)
go vet ./...
$(VET_SUBMODULES): vet@%:
go vet -C $* ./...
# Lint runs through stickler (the gomatic lint runner): it executes the configured
# tools — golangci-lint (the complexity gate folded into .golangci.yaml) plus the
# yze analyzer suite — to completion, normalizes their findings, and fails on any
# finding or tool error. Per-repo lint deltas live in a hand-authored .stickler.yaml
# `config:` overlay deep-merged onto the managed (clobberable) .golangci.yaml at run
# time — so the centralized config stays uniform and distribute-owned while a repo
# EXTENDS it without an in-tree edit the next distribute would erase. This replaces
# the former yq-merged .golangci.override.yml mechanism (no yq dependency).
# stickler shells out to golangci-lint/yze by name, so $(GOBIN) is prepended to PATH.
.PHONY: lint-raw
lint-raw: vet
PATH="$(GOBIN):$${PATH}" $(STICKLER)
.PHONY: lint
lint: ## Run the stickler lint suite (golangci-lint + yze; ratchet-aware; .stickler.yaml-merged config)
@$(call standards-run,gate:lint,$(MAKE) lint-raw)
.PHONY: staticcheck-raw
staticcheck-raw:
$(STATICCHECK) ./...
.PHONY: staticcheck
staticcheck: ## Run staticcheck (ratchet-aware)
@$(call standards-run,gate:staticcheck,$(MAKE) staticcheck-raw)
# VULNCHECK_SCAN is the govulncheck precision knob. The default `symbol` builds
# the call graph and reports only vulnerabilities actually reachable from a
# called symbol. A repo whose generics trip the known x/vuln source-mode panic
# (`ForEachElement called on type containing *types.TypeParam`, govulncheck
# <=v1.4.0) sets `VULNCHECK_SCAN = package`: that skips call-graph construction
# (so no panic) and flags any vulnerable *package* that is imported at all —
# strictly more conservative than symbol scanning, never less, so it can't hide
# a finding. Drop back to `symbol` once upstream fixes the panic.
VULNCHECK_SCAN ?= symbol
.PHONY: vulncheck-raw
vulncheck-raw:
$(GOVULNCHECK) -mode=source -scan=$(VULNCHECK_SCAN) ./...
.PHONY: vulncheck
vulncheck: ## Run govulncheck (ratchet-aware)
@$(call standards-run,gate:vulncheck,$(MAKE) vulncheck-raw)
##@ Test
TEST_SUBMODULES := $(addprefix test@,$(SUBMODULES))
.PHONY: test $(TEST_SUBMODULES)
test: $(TEST_SUBMODULES) ## Run tests (root module + submodules)
$(GOTESTSUM) --format $(GO_TEST_FORMAT) -- ./...
$(TEST_SUBMODULES): test@%:
go test -C $* ./...
TESTALL_SUBMODULES := $(addprefix test-all@,$(SUBMODULES))
.PHONY: test-all $(TESTALL_SUBMODULES)
test-all: $(COVERAGE_FOLDER) $(TESTALL_SUBMODULES) ## Run all tests with race detection + coverage
CGO_ENABLED=1 $(GOTESTSUM) --format $(GO_TEST_FORMAT) -- -race -short -coverprofile=$(COVERAGE_FOLDER)/coverage.out ./...
$(TESTALL_SUBMODULES): test-all@%:
cd $* && CGO_ENABLED=1 go test -race -short ./...
.PHONY: coverage
coverage: $(COVERAGE_FOLDER) ## Run tests with coverage
$(GOTESTSUM) --format $(GO_TEST_FORMAT) -- -coverprofile=$(COVERAGE_FOLDER)/coverage.out ./...
# The coverage GATE: run COVER_PKGS and fail unless aggregate statement coverage
# is exactly COVER_THRESHOLD. This is the 100%-coverage enforcement every
# consumer shares (folded into `check`); a consumer scopes COVER_PKGS to its own
# tested set. Lists the sub-100% functions on failure so the miss is actionable.
.PHONY: cover
cover: $(COVERAGE_FOLDER) ## Run tests and assert COVER_THRESHOLD coverage of COVER_PKGS
$(GOTESTSUM) --format $(GO_TEST_FORMAT) -- -covermode=$(COVER_MODE) -coverpkg=$(COVERPKG) -coverprofile=$(COVERAGE_FOLDER)/coverage.out $(COVER_PKGS)
@total=$$(go tool cover -func=$(COVERAGE_FOLDER)/coverage.out | awk '/^total:/{print $$3}'); \
echo "total coverage: $${total}"; \
[ "$${total}" = "$(COVER_THRESHOLD)" ] || { echo "coverage $${total} below $(COVER_THRESHOLD):"; go tool cover -func=$(COVERAGE_FOLDER)/coverage.out | awk '$$3 != "100.0%"'; exit 1; }
# Build-tag-gated tests (integration, e2e, ...). The shared `test`/`test-all`
# targets run only UNTAGGED unit tests; anything behind a `//go:build <tag>`
# constraint is invisible to them and runs here instead. this shared Makefile owns
# the invocation (gotestsum + the pinned toolchain); the CONSUMER owns whatever
# environment those tests need — a live Postgres, env vars, fixtures — by
# exporting it before calling this (or wrapping it in a consumer target).
#
# make test-tag TAG=integration
# make test-tag TAG=integration TEST_TAG_ARGS='-run TestFoo -timeout 10m'
# make test-tag TAG=e2e TEST_TAG_PKG=./e2e/...
#
# TEST_TAG_PKG defaults to ./... ; TEST_TAG_ARGS appends any extra `go test`
# flags (-run, -timeout, -parallel, -bench, ...).
TEST_TAG_PKG ?= ./...
TEST_TAG_ARGS ?=
.PHONY: test-tag
test-tag: ## Run build-tag-gated tests: make test-tag TAG=integration [TEST_TAG_ARGS='-run X']
@test -n "$(TAG)" || { echo "ERROR: TAG is required, e.g. 'make test-tag TAG=integration'"; exit 1; }
$(GOTESTSUM) --format $(GO_TEST_FORMAT) -- -tags $(TAG) $(TEST_TAG_ARGS) $(TEST_TAG_PKG)
# Tag-as-target sugar: `make test-tag-e2e` == `make test-tag TAG=e2e`. The stem
# $* is the tag. It delegates to test-tag so the actual invocation stays single-
# sourced; TEST_TAG_ARGS / TEST_TAG_PKG given on the command line propagate to
# the sub-make. Deliberately NOT phony: .PHONY takes no patterns, and GNU make
# skips IMPLICIT pattern rules for phony targets (see the vet note above). The
# recipe creates no file named after the target, so it re-runs like a phony
# target would. (Pattern targets don't appear in `make help`.)
test-tag-%:
@$(MAKE) test-tag TAG=$*
# `integration` is the near-universal tag, so it gets a named, help-listed
# shortcut rather than every consumer wrapping `test-tag TAG=integration`. The
# consumer still owns the ENVIRONMENT these tests need (live Postgres, env
# vars) — export it before invoking, e.g. PGHOST/PGPORT/... — this shared
# Makefile owns only the run.
#
# Scope: run from ./integration/... when that folder exists (the common layout —
# tagged tests collected in their own dir), else the whole module (./...) for
# repos that scatter `//go:build integration` files alongside their packages.
INTEGRATION_PKG := $(if $(wildcard integration),./integration/...,./...)
.PHONY: test-integration
test-integration: ## Run `integration`-tagged tests (needs the consumer's env, e.g. Postgres)
@$(MAKE) test-tag TAG=integration TEST_TAG_PKG=$(INTEGRATION_PKG)
##@ Build
.PHONY: build
build: $(BINARIES) ## Build all binaries (no-op in a library — no .goreleaser builds)
$(if $(BINARIES),,@echo "no binaries (library) — nothing to build")
# Build hook. Deliberately a no-op: generated code is committed and formatting
# is enforced by the gate, so `make build` must NOT regenerate or rewrite the
# tree (regeneration is fragile across tool versions and would dirty the working
# tree mid-build). Run `make fmt` / `make generate` explicitly when you want them.
.PHONY: pre-build
pre-build:
# Build one binary via goreleaser single-target snapshot, then drop a stable
# unversioned symlink beside the arch-suffixed artifact.
.PHONY: $(BINARIES)
$(BINARIES): pre-build | $(BUILD_DIR)
$(GORELEASER) build --single-target --snapshot --clean --id $@
cp dist/$@-$(GOOS)-$(GOARCH) $(BUILD_DIR)/$@-$(GOOS)-$(GOARCH)
@rm -f $(BUILD_DIR)/$@
@ln -sf $@-$(GOOS)-$(GOARCH) $(BUILD_DIR)/$@
.PHONY: build-all
build-all: pre-build ## Build binaries for all platforms
$(if $(BINARIES),$(GORELEASER) build --snapshot --clean,@echo "no BINARIES (no builds: in .goreleaser.yml) — nothing to build")
.PHONY: release
release: pre-build ## Create a release with goreleaser
$(if $(GORELEASER_CONFIG),$(GORELEASER) release --clean,@echo "no .goreleaser.yml — library released via its git tag; nothing to do")
.PHONY: release-snapshot
release-snapshot: pre-build ## Create a snapshot release (no git tag required)
$(if $(GORELEASER_CONFIG),$(GORELEASER) release --snapshot --clean,@echo "no .goreleaser.yml — nothing to release")
##@ Docker
# Centralized image build. Everything is derived from the consumer repo itself,
# so it sets nothing in the common case:
# DOCKER_IMAGE <- ghcr.io/<owner>/<repo-dir-name> (owner from origin remote)
# DOCKER_ENTRYPOINT <- the first id under builds: in .goreleaser.yml
# The consumer's Dockerfile is expected to `FROM` the shared distroless runtime
# base (nicerobot/tools.build/runtime), which bakes the unprivileged user, certs
# and the distroless base — so the consumer Dockerfile is just COPY + ENTRYPOINT.
# DOCKER_REGISTRY derives its owner from the repo's own origin remote, so this
# distributed Makefile is correct in whatever org/repo it lands in.
DOCKER_REGISTRY ?= ghcr.io/$(shell git config --get remote.origin.url 2>/dev/null | sed -E 's#\.git$$##;s#.*[:/]([^/]+)/[^/]+$$#\1#')
DOCKER_IMAGE ?= $(DOCKER_REGISTRY)/$(notdir $(CURDIR))
DOCKER_IMAGE_TAG ?= latest
DOCKER_ENTRYPOINT ?= $(firstword $(BINARIES))
DOCKER_PLATFORM ?= linux/$(GOARCH)
DOCKERFILE ?= Dockerfile
# OCI provenance labels stamped onto every image identically. Revision is the CI
# build number; source is the consumer's origin remote. --build-arg
# ENTRYPOINT_BIN lets the shared runtime.dockerfile / consumer Dockerfile pick
# the binary without hardcoding it.
DOCKER_LABELS := \
--label org.opencontainers.image.revision=$(BUILD_NUMBER) \
--label org.opencontainers.image.source=$(shell git config --get remote.origin.url 2>/dev/null) \
--label org.opencontainers.image.title=$(notdir $(CURDIR))
.PHONY: docker
docker: build-all ## Build the docker image (single-arch, for the current platform)
docker build \
--platform $(DOCKER_PLATFORM) \
--build-arg ENTRYPOINT_BIN=$(DOCKER_ENTRYPOINT) \
$(DOCKER_LABELS) \
--file $(DOCKERFILE) \
--tag $(DOCKER_IMAGE):$(DOCKER_IMAGE_TAG) .
# Multi-arch manifest list from both arches goreleaser already produced under
# dist/. build-all emits both amd64 and arm64, but a per-consumer single-arch
# `docker build` threw one away; buildx assembles the manifest so the published
# tag serves both.
DOCKER_BUILDX_PLATFORMS ?= linux/amd64,linux/arm64
.PHONY: docker-buildx
docker-buildx: build-all ## Build + push a multi-arch image manifest (needs buildx)
docker buildx build \
--platform $(DOCKER_BUILDX_PLATFORMS) \
--build-arg ENTRYPOINT_BIN=$(DOCKER_ENTRYPOINT) \
$(DOCKER_LABELS) \
--file $(DOCKERFILE) \
--tag $(DOCKER_IMAGE):$(DOCKER_IMAGE_TAG) \
--push .
##@ Utilities
.PHONY: fmt
fmt: ## Format code (golines then gofumpt)
$(GOLINES) -m $(GOLINES_MAX) -w .
$(GOFUMPT) -l -w .
.PHONY: fmt-check
fmt-check: ## Fail if any line exceeds GOLINES_MAX (gofumpt/imports are enforced by lint)
@out="$$($(GOLINES) -m $(GOLINES_MAX) -l .)"; \
if [ -n "$${out}" ]; then echo "lines exceed $(GOLINES_MAX) cols (run 'make fmt'):"; echo "$${out}"; exit 1; fi
.PHONY: generate
generate: ## Generate code
go generate ./...
# Tidy/vendor/verify the root module AND every nested module. Mirrors the
# test@% / vet@% fan-out: a static pattern rule over an explicit target list
# (phony pattern rules don't fire as implicit rules — see the vet note above).
DEPS_SUBMODULES := $(addprefix deps@,$(SUBMODULES))
.PHONY: deps tidy $(DEPS_SUBMODULES)
deps tidy: $(DEPS_SUBMODULES) ## Tidy and verify dependencies (root module + submodules)
go mod tidy -go=$(shell go mod edit -json | jq -r .Go)
go mod vendor
go mod verify
$(DEPS_SUBMODULES): deps@%:
cd $* && go mod tidy -go=$$(go mod edit -json | jq -r .Go) && go mod vendor && go mod verify
# Fail if the working tree is dirty. Run after fmt/generate/tidy to prove a
# consumer committed everything those targets produce — the typical CI failure
# is a checked-in repo that drifted from `go generate` / `gofumpt` / `go mod
# vendor` output. Any repo can hang this off its `ci` target.
.PHONY: verify
verify: ## Fail if the working tree has uncommitted changes
@git diff --exit-code || { echo "ERROR: working tree is dirty (run 'make fmt generate tidy' and commit)"; exit 1; }
.PHONY: clean
clean: ## Clean build + test artifacts
rm -rf dist/
rm -rf $(foreach b,$(BINARIES),$(BUILD_DIR)/$(b)*)
rm -rf *.test *.out coverage* $(COVERAGE_FOLDER)/coverage*
# --------------------------------------------------------------------------- #
# Per-repo customization (optional, repo-owned — the ONLY place a repo diverges)
# --------------------------------------------------------------------------- #
# A repo customizes the toolchain WITHOUT editing this shared file by dropping a
# Makefile.local beside it. It is `-include`d here (the leading `-` makes it a
# silent no-op when absent), so it is read LAST: set documented hook variables
# (COVER_GATE, INTEGRATION_PKG, COVER_PKGS, …) and ADD new targets there. Never
# redefine a target this file already declares — that forks the toolchain and
# trips make's "overriding recipe" warning. See the banner at the top.
-include Makefile.local