-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathMakefile
More file actions
835 lines (750 loc) · 46.6 KB
/
Copy pathMakefile
File metadata and controls
835 lines (750 loc) · 46.6 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
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
# 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.yaml
# 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
NILAWAY = $(gobin-or-die)/nilaway
DEADCODE = $(gobin-or-die)/deadcode
GREMLINS = $(gobin-or-die)/gremlins
# yq is the one tool consulted at PARSE time (EXEMPT and BINARIES below expand
# during Makefile read), where gobin-or-die would abort even `make help` on a
# GOBIN-less machine — so it resolves to $(GOBIN)/yq when GOBIN is set and only
# then falls back to a bare `yq`. Its parse-time consumers fail CLOSED on their
# own: EXEMPT captures a sentinel the ratchet turns into a hard error (see the
# standards section) rather than silently running with an empty ledger.
YQ = $(if $(GOBIN),$(GOBIN)/yq,yq)
# Maximum source line length enforced by golines (it shortens longer lines).
GOLINES_MAX ?= 120
# golines shells out to a BASE FORMATTER once per file, and with none named it
# uses goimports — which resolves every import against the module cache on each
# spawn. The cost therefore scales with the dependency graph rather than with the
# source: 186 files in modern-go-application took 37 seconds, one goimports
# process per file, and the same files take under a second with gofmt.
#
# Naming gofmt loses nothing. Import grouping and ordering are enforced by
# golangci-lint's gci/goimports formatters in `make lint` — see fmt-check's own
# comment below — so golines re-resolving them is work the gate already does,
# paid again by every repository on every `make fmt` and every `make check`.
GOLINES_FLAGS ?= -m $(GOLINES_MAX) --base-formatter=gofmt
# 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
$(YQ) --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
# named exactly once (see GORELEASER_CONFIG_NAME); 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.
# ONE spelling, named once. goreleaser accepts several — .goreleaser.yaml,
# .goreleaser.yml, and the dotless forms — and this line used to accept two of
# them with $(wildcard .goreleaser.yaml .goreleaser.yml). That is what let the
# fleet carry both: 101 repositories on .yml and 54 on .yaml, split cleanly by
# org and by whenever each org was set up, with the gate passing either way so
# nothing could ever detect the drift and every new repository inherited
# whichever spelling its template happened to hold. A build system that accepts
# two spellings guarantees it will always have two.
#
# .goreleaser.yaml is the survivor: it is what goreleaser's own documentation and
# `goreleaser init` produce. Any other spelling is a hard failure of the gate
# rather than a silently accepted alternative — see the goreleaser-name target.
GORELEASER_CONFIG_NAME = .goreleaser.yaml
GORELEASER_CONFIG ?= $(wildcard $(GORELEASER_CONFIG_NAME))
# GORELEASER_MISNAMED is every spelling goreleaser would also answer to, which
# is exactly what must not be present.
GORELEASER_MISNAMED = $(wildcard .goreleaser.yml 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
# GO_TEST_SHUFFLE randomizes test execution order. A suite that passes in
# declaration order but fails shuffled is order-DEPENDENT: some test is quietly
# relying on another having run first (a package-level var left set, a shared
# fixture, a temp dir). That is a defect in the tests, not a flake — a result
# that depends on execution order proves nothing about any single unit. Go
# prints the seed on failure, so a shuffled failure reproduces with
# GO_TEST_SHUFFLE=<seed>.
GO_TEST_SHUFFLE ?= on
# GO_TEST_COUNT applies to the CI race run (`test-all`). `-count=2` runs each
# test twice in the SAME process, so a test that mutates package-level state and
# does not restore it fails on the second pass. That is precisely the failure
# mode a test-installed package var ("test seam") creates, and no other step in
# this gate detects it.
GO_TEST_COUNT ?= 2
# 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)))
# VET_PKGS is the package set `go vet` runs over — default ./..., narrowed in a
# repo's Makefile.local ONLY to drop COMMITTED GENERATED trees whose
# machine-authored lines trip vet (e.g. an ANTLR parser's deliberate
# `goto errorExit` unreachable trick), matching the COVER_PKGS narrowing:
# VET_PKGS = $(shell go list ./... | grep -v /src/grammar)
VET_PKGS ?= ./...
# STATICCHECK_PKGS is the package set staticcheck runs over — default ./...,
# narrowed in a repo's Makefile.local for the same reason and to the same set as
# VET_PKGS: staticcheck still reports some checks (e.g. SA4006) inside COMMITTED
# GENERATED trees despite their `Code generated` markers.
# STATICCHECK_PKGS = $(shell go list ./... | grep -v /src/grammar)
STATICCHECK_PKGS ?= ./...
# NILAWAY_PKGS / DEADCODE_PKGS are the package sets for the two whole-program
# analyses below. They narrow in a Makefile.local for the same reason and to the
# same set as VET_PKGS/STATICCHECK_PKGS: COMMITTED GENERATED trees, whose
# machine-authored code is not ours to fix.
NILAWAY_PKGS ?= ./...
DEADCODE_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 deadcode tidy-check 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.
#
# The ledger read fails CLOSED: when the file exists but $(YQ) cannot read it
# (tool missing, parse error), EXEMPT captures the __EXEMPT_UNREADABLE__ sentinel
# instead of coming up empty, and standards-run refuses to run any gate. A
# silently-empty EXEMPT would un-ratchet every declared gap — exempt gates fail
# hard and stale exemptions go undetected. (This shipped once: the CI image
# lacked yq, `2>/dev/null` ate the error, and every .standards.yaml repo's
# declared backlog failed the gate in CI while passing locally.)
STANDARDS_FILE ?= .standards.yaml
EXEMPT := $(if $(wildcard $(STANDARDS_FILE)),$(shell $(YQ) -r '.exempt // {} | keys | .[]' $(STANDARDS_FILE) 2>/dev/null || echo __EXEMPT_UNREADABLE__))
# $(call standards-run,<capability>,<command>) — see the ratchet table above.
define standards-run
$(if $(filter __EXEMPT_UNREADABLE__,$(EXEMPT)),echo "STANDARDS: cannot read $(STANDARDS_FILE) with $(YQ) — install the pinned toolset ('make tools'); refusing to run with the exemption ledger unread" >&2; exit 1,$(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)) || { echo "STANDARDS: cannot read $(STANDARDS_FILE) with $(YQ) — install the pinned toolset ('make tools')" >&2; exit 1; }; \
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 goreleaser-name fmt-check lint staticcheck deadcode tidy-check 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_PKGS)
$(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
.PHONY: goreleaser-name
goreleaser-name: ## Fail if the goreleaser config is spelled any way but the one
$(if $(GORELEASER_MISNAMED),$(error goreleaser config is misnamed: $(GORELEASER_MISNAMED) — rename it to $(GORELEASER_CONFIG_NAME), which is the one spelling this fleet uses and the one `goreleaser init` produces))
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) $(STATICCHECK_PKGS)
.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)
# nilaway traces a nil value from where it is produced to where it is
# dereferenced, ACROSS function and package boundaries — strictly beyond what
# vet/staticcheck do (they reason within one function).
#
# ADVISORY, NOT A GATE. Deliberately absent from `check` and `ci`. Measured
# across six repos it reported four findings and all four were FALSE POSITIVES
# on nil slices: `spans[i]` inside a `range members` that cannot execute when
# empty, `parts[len(parts)-1]` guarded by an earlier `len(data) == 0` return,
# `old[i]` inside a loop bounded by `len(old)`. One was raised only because a
# test correctly passes nil to exercise the empty case.
#
# A blocking gate must be zero-false-positive, or every consumer learns to
# silence it — and an exemption written for a non-problem is indistinguishable
# in the ledger from one written for a real gap, which is how a ratchet rots.
# So this runs on demand, during a quality audit, where a human or agent
# adjudicates each finding. Promote it to `check` only if its slice precision
# improves. -exclude-test-files drops the test-origin noise.
.PHONY: nilaway
nilaway: ## ADVISORY (not in check/ci): nil-flow analysis; findings need adjudication
$(NILAWAY) -exclude-test-files $(NILAWAY_PKGS)
# Mutation testing measures test ADEQUACY — the one thing the 100%-`cover` gate
# cannot. Coverage proves every statement EXECUTED; it cannot tell an assertion
# from a bare call. gremlins changes the code on purpose (negates a conditional,
# shifts a boundary, alters an operator) and reruns the suite: a mutant the tests
# still PASS against — reported LIVED — is a deliberate behaviour change nothing
# detected. It mutates only COVERED code, so it is precisely the complement to
# `cover`, not a second opinion on it.
#
# The gap is measured, not theoretical. Weakening ONE assertion in gomatic/go-error
# (dropping `want.Equal(wantMessage, err.Error())` while still CALLING err.Error(),
# so every statement stays executed) held statement coverage at 100.0% while
# efficacy fell 100% -> 33.33%. In gomatic/go-hx — 100% covered, and the repo whose
# shipped defects motivated this target — two mutants survived: the empty-version
# message in store/format.go:61 and the error propagation out of the segment loop
# in store/verify.go:157. Both are contracts those functions' own doc comments
# state and no test asserts.
#
# ADVISORY, NOT A GATE. Deliberately absent from `check` and `ci`, for nilaway's
# reason (findings need adjudication) plus two of its own:
#
# • EQUIVALENT MUTANTS are an irreducible false positive — a mutant that changes
# the code but CANNOT change behaviour. gomatic/go-flagged's lone survivor
# mutates `make([]rune, 0, len(meta.Name)*2)` to `/2`: a capacity HINT, so
# append() covers the difference and the result is identical (verified by
# applying it and running the suite — green). No legitimate test can kill it,
# so that repo's honest ceiling is 95%, not 100% — and the ceiling differs per
# repo, so there is no fleet-wide threshold to gate on and no stable baseline
# to ratchet against.
# • The score is TIMING-DEPENDENT. gremlins derives each mutant's test timeout
# from how long the coverage run took, so on a fast suite every mutant's fresh
# `go test` — which must recompile the mutated package — blows the budget and
# lands in TIMED OUT, a status excluded from the efficacy denominator outright.
# At the default coefficient gomatic/go-archive reported 21 of 27 mutants
# TIMED OUT; at 60 all 27 were KILLED. Worse, it HIDES survivors: go-flagged
# read 100.00% efficacy under the default and 95.00% — one real LIVED mutant —
# once the timeout was large enough to let the mutants actually run.
#
# A verdict that moves with machine speed is not a gate; that is the same reasoning
# the `cover` recipe records about concurrent runs. So this runs on demand, during a
# quality audit, where a human or agent adjudicates each survivor.
#
# It is also advisory by CONSTRUCTION: with no threshold configured gremlins exits
# 0 whatever it finds. Do NOT "promote" this by adding --threshold-efficacy — that
# flag is silently non-functional in v0.6.0 (measured: 33.33% efficacy against
# --threshold-efficacy 90 still exits 0), so wiring it into a gate would install a
# check that can never fail. Only a .gremlins.yaml `unleash.threshold.efficacy`
# enforces (exit 10), and per the equivalent-mutant point above there is no
# defensible fleet-wide number to put there.
#
# MUTATE_TIMEOUT_COEFFICIENT multiplies the coverage-run duration to get each
# mutant's timeout. gremlins defaults to 2, far too small for a fast suite; 60
# classified every mutant in the repos measured above. Raise it in a Makefile.local
# for a repo whose suite is slow enough that real mutants still land in TIMED OUT —
# a TIMED OUT tally that is not ~0 means the run has not measured anything yet.
MUTATE_TIMEOUT_COEFFICIENT ?= 60
# MUTATE_EXCLUDE are filepath regexps gremlins must not mutate. testdata/ is
# excluded by default: an analyzer's testdata/src fixtures are INPUTS to a test,
# never code the suite executes, so every mutant in them lands in NOT COVERED and
# drags mutant coverage down over nothing (gomatic/yze-go-gotostmt read 14.29%
# with them in, 100.00% with them out).
MUTATE_EXCLUDE ?= testdata/
# MUTATE_PATH is the module path gremlins analyses (it takes a path, not a package
# pattern). A repo with committed GENERATED trees excludes them via MUTATE_EXCLUDE,
# matching the COVER_PKGS/VET_PKGS narrowing — machine-authored code is not ours to
# write tests for.
MUTATE_PATH ?= .
.PHONY: mutate
mutate: ## ADVISORY (not in check/ci): mutation testing; LIVED mutants need adjudication
$(GREMLINS) unleash \
--timeout-coefficient $(MUTATE_TIMEOUT_COEFFICIENT) \
$(addprefix --exclude-files ,$(MUTATE_EXCLUDE)) \
$(MUTATE_PATH)
# deadcode reports functions unreachable from any entry point. `-test` adds the
# test binaries as roots, which does two things: a library (no main package)
# becomes analyzable at all, and a helper reachable only from tests is correctly
# NOT reported. What remains is genuinely unreachable — delete it.
#
# deadcode EXITS 0 even when it reports findings, so a bare `$(DEADCODE) ...`
# recipe would be a gate that can never fail. The non-empty-output test below is
# what makes it a gate; do not "simplify" it away. A tool error (non-zero exit)
# still fails on its own.
# DEADCODE_FILTER drops the three finding classes that are not this repo's dead
# code to delete:
# ^/ an ABSOLUTE path is a dependency in the module cache. A repo
# cannot delete a function inside a module it merely imports.
# node_modules|vendor|third_party
# vendored third-party trees, which are not ours to edit.
# Example Go example functions are documentation. One without an
# `// Output:` comment is compiled but never run, so the tool
# calls it unreachable; it is doing its job as written.
# Measured across the fleet these three accounted for every deadcode finding
# but one, which is why the gate filters rather than reports them.
# DEADCODE_TAGS lists the build tags whose files must be visible for the
# reachability analysis to be honest. deadcode analyses ONE tag configuration,
# so a helper used only by `//go:build integration` tests looks unreachable
# without them — the tool reporting exactly what it was asked. A repo with
# tag-gated tests sets this in its Makefile.local, e.g.
# DEADCODE_TAGS = integration
DEADCODE_TAGS ?=
DEADCODE_TAGFLAG = $(if $(DEADCODE_TAGS),-tags $(DEADCODE_TAGS),)
DEADCODE_FILTER ?= grep -vE '^/|/node_modules/|/vendor/|/third_party/|unreachable func: Example'
.PHONY: deadcode-raw
deadcode-raw:
@out=$$($(DEADCODE) -test $(DEADCODE_TAGFLAG) $(DEADCODE_PKGS) | $(DEADCODE_FILTER) || true); \
test -z "$${out}" || { echo "unreachable code — delete it, or make it reachable from a test:" >&2; echo "$${out}" >&2; exit 1; }
.PHONY: deadcode
deadcode: ## Fail on code unreachable from any entry point or test (ratchet-aware)
@$(call standards-run,gate:deadcode,$(MAKE) deadcode-raw)
# tidy-check asserts `go mod tidy` would be a no-op: go.mod/go.sum describe
# exactly what the source imports, nothing more. `-diff` (Go 1.23+) reports the
# change a tidy WOULD make and exits non-zero instead of writing it, so the gate
# never mutates a consumer's tree. Untidiness is not cosmetic here — a require
# nothing imports is a dependency (and its CVE surface) sitting in go.sum where
# govulncheck's source mode cannot see it but Dependabot can.
TIDY_SUBMODULES := $(addprefix tidy-check@,$(SUBMODULES))
.PHONY: tidy-check-raw $(TIDY_SUBMODULES)
tidy-check-raw: $(TIDY_SUBMODULES)
go mod tidy -diff
$(TIDY_SUBMODULES): tidy-check@%:
go mod tidy -C $* -diff
.PHONY: tidy-check
tidy-check: ## Assert go.mod/go.sum are tidy, without rewriting them (ratchet-aware)
@$(call standards-run,gate:tidy,$(MAKE) tidy-check-raw)
##@ Test
TEST_SUBMODULES := $(addprefix test@,$(SUBMODULES))
.PHONY: test $(TEST_SUBMODULES)
test: $(TEST_SUBMODULES) ## Run tests (root module + submodules)
$(GOTESTSUM) --format $(GO_TEST_FORMAT) -- -shuffle=$(GO_TEST_SHUFFLE) ./...
$(TEST_SUBMODULES): test@%:
go test -C $* -shuffle=$(GO_TEST_SHUFFLE) ./...
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 -shuffle=$(GO_TEST_SHUFFLE) -count=$(GO_TEST_COUNT) -coverprofile=$(COVERAGE_FOLDER)/coverage.out ./...
$(TESTALL_SUBMODULES): test-all@%:
cd $* && CGO_ENABLED=1 go test -race -short -shuffle=$(GO_TEST_SHUFFLE) -count=$(GO_TEST_COUNT) ./...
.PHONY: coverage
coverage: $(COVERAGE_FOLDER) ## Run tests with coverage
$(GOTESTSUM) --format $(GO_TEST_FORMAT) -- -shuffle=$(GO_TEST_SHUFFLE) -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.
#
# The profile is written to a per-invocation path and only moved onto the
# canonical coverage.out at the end. Writing the shared name directly let two
# concurrent runs in one repository interleave into it, and the verdict was then
# read back out of the wreckage: a mangled profile makes `go tool cover` fail,
# the total parses empty, and the gate reports a coverage miss that the code
# does not have. A gate whose answer depends on what else happens to be running
# is not a gate — and a red that goes away on a re-run is worse than a noisy
# one, because it teaches everyone to re-run until green, which is exactly how
# a real failure gets waved through.
.PHONY: cover
cover: $(COVERAGE_FOLDER) ## Run tests and assert COVER_THRESHOLD coverage of COVER_PKGS
@profile=$(COVERAGE_FOLDER)/coverage.$$$$.out; \
$(GOTESTSUM) --format $(GO_TEST_FORMAT) -- -shuffle=$(GO_TEST_SHUFFLE) -covermode=$(COVER_MODE) -coverpkg=$(COVERPKG) -coverprofile=$${profile} $(COVER_PKGS) \
|| { mv -f $${profile} $(COVERAGE_FOLDER)/coverage.out 2>/dev/null; exit 1; }; \
total=$$(go tool cover -func=$${profile} | awk '/^total:/{print $$3}'); \
echo "total coverage: $${total}"; \
[ "$${total}" = "$(COVER_THRESHOLD)" ] \
|| { echo "coverage $${total} below $(COVER_THRESHOLD):"; go tool cover -func=$${profile} | awk '$$3 != "100.0%"'; mv -f $${profile} $(COVERAGE_FOLDER)/coverage.out; exit 1; }; \
mv -f $${profile} $(COVERAGE_FOLDER)/coverage.out
# 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 (a library gets the portability matrix)
$(if $(BINARIES),$(GORELEASER) build --snapshot --clean,@$(MAKE) --no-print-directory portability)
# PORTABILITY_TARGETS is the platform matrix a library is compiled for when it
# has no binaries of its own to build. A library previously skipped build-all
# entirely, so it could pass its gate while being unbuildable for the very
# targets its consumers ship to: gomatic/go-sql called pg_query's cgo-only
# functions directly, stayed green here, and broke the release build of every
# CLI that imported it. CGO_ENABLED=0 is the point — it is the condition under
# which a cgo-gated dependency vanishes.
PORTABILITY_TARGETS ?= linux/amd64 linux/arm64 windows/amd64 darwin/arm64
PORTABILITY_CHECKS := $(addprefix portability@,$(PORTABILITY_TARGETS))
# `go build ./...`, deliberately, after two alternatives were tried and rejected.
# `go vet` also typechecks _test.go, and a cgo-gated library's tests legitimately
# call the cgo-only API — consumers never build tests, so vet reports a failure
# that is not one. `go build -o <dir>/ ./...` rejects a module with no main
# packages ("no main packages to build"), which is precisely the library case
# this target exists for. Plain build writes nothing for a library and is the
# only one of the three that answers the actual question.
.PHONY: portability $(PORTABILITY_CHECKS)
portability: $(PORTABILITY_CHECKS) ## Compile the package set for each release target without cgo
$(PORTABILITY_CHECKS): portability@%:
CGO_ENABLED=0 GOOS=$(firstword $(subst /, ,$*)) GOARCH=$(lastword $(subst /, ,$*)) go build ./...
.PHONY: release
release: pre-build ## Create a release with goreleaser
$(if $(GORELEASER_CONFIG),$(GORELEASER) release --clean,@echo "no .goreleaser.yaml — 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.yaml — 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.yaml
# 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
# FMT_FILES is the Go source the formatter sees: the whole tree MINUS the
# directories and files the Go toolchain itself ignores — those whose path has
# a segment beginning with `_` or `.`, and those under a `testdata/` directory
# (go build/vet/list/mod-tidy skip all three). Formatting must match the
# compiler: a `_legacy/` port-reference tree, kept verbatim and never built (per
# its own README), otherwise fails fmt-check over code that is not part of the
# module. A repo with no such directory sees exactly the whole tree, so its
# behaviour is unchanged.
#
# `testdata/` is not merely unbuilt — for an analyzer repo it is the SPECIMEN,
# and formatting it destroys what it tests. yze-go-cliv3's fixture writes an
# import path as a RAW string literal so the analyzer is proven to flag that
# form too; golines rewrote it to a quoted string, which silently turns the
# edge-case fixture into a duplicate of the ordinary one. The gate was failing
# on a file whose non-conformance is its entire purpose.
# GENERATED files are excluded by their own marker, not by path. A generated
# file says DO NOT EDIT in its header and the formatters were editing it anyway:
# `make fmt` in modern-go-application rewrote gqlgen's and protoc's output, so a
# regeneration and a format fought over the same lines forever and every `make
# fmt` produced a diff nobody wrote. One marker covers every generated tree
# without naming any of them.
#
# The pattern is Go's OWN definition of the marker — a whole line, at the head of
# the file, ending in a period — and not merely the words appearing somewhere.
# Matching the words anywhere excluded three hand-written files in go-yze, which
# is the analyzer family that DETECTS the marker and therefore quotes it: a file
# SHOWING the convention was read as a file MAKING the claim, and stopped being
# formatted. `head -5` bounds the search to the header a generated file puts it
# in, so a quotation further down cannot exempt anything.
FMT_FILES = $(shell find . -name '*.go' -not -path '*/_*' -not -path '*/.*' -not -path '*/testdata/*' \
-exec sh -c 'head -5 "$$1" | grep -qE "^// Code generated .* DO NOT EDIT\.$$" || echo "$$1"' _ {} \;)
.PHONY: fmt
fmt: ## Format code (golines then gofumpt)
@[ -z "$(FMT_FILES)" ] || $(GOLINES) $(GOLINES_FLAGS) -w $(FMT_FILES)
@[ -z "$(FMT_FILES)" ] || $(GOFUMPT) -l -w $(FMT_FILES)
.PHONY: fmt-check
fmt-check: ## Fail if any line exceeds GOLINES_MAX (gofumpt/imports are enforced by lint)
@out="$$([ -z "$(FMT_FILES)" ] || $(GOLINES) $(GOLINES_FLAGS) -l $(FMT_FILES))"; \
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