Go Makefile Writer
Design a practical root Makefile that is readable, reproducible, and aligned with repository layout.
Quick Reference
| If you need to… |
Go to |
| Create a Makefile from scratch for a new Go project |
§Execution Modes → Create + §Workflow |
| Refactor or update an existing Makefile (minimal-diff) |
§Execution Modes → Refactor |
Decide which targets to include (build, test, lint, ci…) |
§Workflow (Plan targets) |
| Get a complete working Makefile example to start from |
Load references/golden/simple-project.mk or complex-project.mk |
Check quality rules, variable conventions, .PHONY requirements |
Load references/makefile-quality-guide.md |
| Review a Makefile PR quickly |
Load references/pr-checklist.md |
| Handle a monorepo or multi-module Go repo |
§Monorepo Support |
Execution Modes
Select a mode before starting and state it in the output report.
Create (new Makefile from scratch)
- Full target set generated from project inspection.
- Use golden templates (simple-project.mk / complex-project.mk) as starting points.
- No backward-compatibility concerns.
Refactor (modify existing Makefile)
- Minimal-diff edits — change only what is needed; do not rewrite the entire file.
- Backward compatibility: if target names change, keep aliases for at least one transition period and document them in the output report.
- Preserve existing useful targets unless user explicitly asks to remove them.
- Before editing, snapshot the current target list via
make -qp | awk -F: '/^[a-zA-Z0-9_-]+:/ {print $1}' | sort -u for comparison.
- Validation must include verifying that previously used critical targets still work (or their aliases do).
Workflow
Select mode (Create or Refactor) and record rationale.
Inspect project structure:
- discover entrypoints (
package main, wherever it lives) by running this skill's discovery script against the target repo: bash <skill-dir>/scripts/discover_go_entrypoints.sh <project-root> (the script lives in the skill directory, not in the target repo — pass the repo root as its argument)
- read the script's exit status before its output.
0 means the query was complete and what you got is the whole answer — including an empty answer, which then genuinely means "library, no programs". 4 means the query was incomplete: empty output is unknown, not library. 2 is a bad root, 3 (with --modules) is zero modules. The stderr diagnostic names the toolchain error to fix
- the script emits 5 tab-separated fields —
kind name target_name dir confidence. confidence is not decoration. confirmed means go list reported the package's name as main, and can become a build target directly. candidate means the toolchain could not be asked about that subtree and the row is a filename guess — list those to the user for confirmation instead of generating targets from them, and say in your output that they are unconfirmed
- if the script cannot run at all, fall back to
find cmd -name main.go -type f, but treat every result as a candidate: a file named main.go may declare any package, and a program's file may be named anything
- detect quality tools and conventions (
go test, golangci-lint, swag)
- detect code generation usage (
go generate, protobuf, wire, mockgen, etc.)
- detect containerization (
Dockerfile, docker-compose.yml)
- read
go.mod for Go version (go directive) and module path
- inspect existing
Makefile if present (Refactor mode)
- detect workspace / multi-module layout via the toolchain first:
go env GOWORK — a real path means a go.work workspace (its modules are go list -m), while the literal string off means workspace mode was disabled and must be treated as "no workspace", not as a path. Only when there is no go.work fall back to a scoped go.mod search (bash <skill-dir>/scripts/discover_go_entrypoints.sh --modules <project-root>, which excludes vendor/, testdata/, examples/). See §Monorepo Support.
Plan target set:
- core targets:
help, fmt, tidy, test, cover, lint, clean
- version targets (programs only):
version (print embedded version info)
- CI target:
ci (fmt-check + lint + test + cover-check in one pass)
- optional targets:
swagger, generate, install-tools, test-integration, bench
- build targets (programs only):
build-all plus per-binary targets, with -ldflags version injection unless the project already has a version mechanism
- run targets: per-binary
run-* targets
- container targets (when Dockerfile present):
docker-build, docker-push
- cross-compile targets (when needed):
build-linux, build-all-platforms
- Go version-aware decisions (see Go Version Awareness)
Compose and write root Makefile:
- keep targets explicit and predictable
- use variables (
GO, BIN_DIR, VERSION, COMMIT, BUILD_TIME) for repeated paths and build metadata
- inject version info via
-ldflags in all build targets
- include
.PHONY
- fail early with clear tool checks for optional dependencies
- use target templates from makefile-quality-guide.md
- Refactor mode: apply minimal-diff strategy and backward-compatibility rules from the mode definition above
Validate:
- run
make help
- run
make test
- run one representative
build-* target
- when the project has a binary and version injection was added: build, then run the binary's version surface (
--version, a version subcommand — whatever it actually has) to verify injection reached the artifact. make version only prints the Make variables, not what the binary embeds. A library-only repo has nothing to run here; validate with make test instead.
- if possible, run one representative
run-* target in a safe environment
- Refactor mode: verify previously used critical targets still work (or provide aliases); compare target list before vs after
Rules
Each rule carries its force, because "we always add a version target" and "a
target must not exit 0 when its work failed" are not the same kind of claim and
should not be argued about on the same terms:
| Tag |
Force |
What to do when a project disagrees |
| [C] |
Correctness. Breaking it produces a Makefile that lies about success, or that does not work. |
Do not deviate. If the user insists, say plainly what will break. |
| [D] |
Recommended default. Right for most Go repos; a project may have a real reason not to. |
Follow it unless the repo shows otherwise; if you deviate, say why in the Assumptions block. |
| [T] |
Template convention. This template's house style. A repo doing it differently is not wrong. |
Match the repo's existing convention over this one. |
Error Propagation — every rule here is [C]
A build script whose targets exit 0 on internal failure is worse than no script:
CI goes green on a broken tree. These are the shapes that leak, all of which
shipped in this skill's own templates until 2026-09-09:
- [C] Branch on a tool's exit status, not only on its output.
gofmt -l on a
file that does not parse prints the error to stderr, prints nothing on
stdout, and exits 2 — so test -z "$(gofmt -l .)" passes a tree that does
not compile. Capture both and check the status first.
- [C] A pipeline reports only its last command's status.
curl … | sh succeeds
when the download fails, because sh given no input exits 0. Either set
SHELL := bash with .SHELLFLAGS := -euo pipefail -c, or download to a temp
file and check before executing. Prefer both: the recipe stays correct if the
template is copied somewhere without bash.
- [C] A
for loop's status is its last iteration's. Put || exit 1 in the
body, or a broken first binary is masked by a working second one.
- [C] A staleness check must compare content, not status strings.
git status --porcelain prints the same ?? path line however the file's
contents change, and git diff skips untracked files — so hash them
(git hash-object) rather than trusting the status line.
- [C] An aggregate target must fail when it has nothing to aggregate. A
module or entrypoint list that comes back empty means discovery broke; looping
over nothing and exiting 0 is the failure this whole layer exists to prevent.
- [C] A loop body must not END on a test.
while read f; do [ -f "$f" ] && echo …; done takes the loop's status from the last iteration's [ -f ], so
one skipped entry fails the whole target under set -e. Write if … then … fi, which ends on the fi and is always true. This is the mirror image of
the loop rule above: there a real failure was swallowed, here a non-failure
was manufactured — both come from not knowing what the loop's status is.
- [C] "Nothing found" and "could not look" are different answers. A check
that cannot run has not passed. Give the two outcomes different exit codes
and let the caller branch on them; collapsing them into "exit 0, no output"
is how a program with an unreadable build cache gets classified as a library
and loses every build target it should have had.
Project shape decides the target set
Ask what the repo is before applying the default target list. Getting this
wrong is how a generated Makefile ends up demanding things the project cannot
supply:
| Repo shape |
How to tell |
What changes |
| Library only (no programs) |
discover_go_entrypoints.sh exits 0 having printed nothing. Exit 0 is the part that matters: it means every module answered, so "no programs" is an answer rather than a silence |
No build-*, run-*, version target, -ldflags injection, or --version validation — there is no binary to inject into. Keep fmt/tidy/test/cover/lint/ci. Validate with make test, not with a binary. |
| Program with its own version mechanism |
an existing version package, debug.ReadBuildInfo(), a generated version.go, or a release tool that stamps binaries |
Do not add a second, competing mechanism. Wire the existing one, or leave version handling alone and say so. -ldflags -X is [D], not [C]. |
| cgo project |
import "C", mattn/go-sqlite3, … |
CGO_ENABLED=1; drop the static-build and CGO_ENABLED=0 cross-compile defaults. |
No --version flag |
the binary uses a version subcommand, or has none |
Validate injection however the program actually exposes it. Do not add a flag to someone's CLI to satisfy a Makefile check. |
| Different test strategy |
build-tagged integration suites, a test script, gotestsum, no -race on this platform |
Mirror what CI runs. The ci target's job is to match the pipeline, not to impose this template's. |
| Shape not yet known |
discover_go_entrypoints.sh exits 4 — the toolchain could not be asked about some or all of the tree (bad GOFLAGS, unreadable build cache, no go.mod, a module that will not load) |
Do not classify. Empty output here means unknown, not library: a perfectly ordinary program can produce it. Fix the reported toolchain error and re-run, or tell the user what could not be checked and what you assumed. Guessing "library" here silently drops every build-* target the repo should have. |
When a project's shape contradicts a [D] rule, follow the project and record the
deviation in the Assumptions block of your output. When it contradicts a [C]
rule, that is a defect in the project, and worth saying so.
Target Design
- [D] Prefer explicit targets over complex metaprogramming unless the user asks for DRY-heavy style.
- [C] Keep artifact output paths deterministic — a target whose output location varies per invocation cannot be cached or cleaned reliably.
- [T] Put them under
bin/. Match an existing repo's choice (build/, dist/, out/) over this one.
- [T] Keep
help output self-documenting via ## comments with .DEFAULT_GOAL := help. A repo with its own help format is not defective — fixture 009_custom_help_format_fp.json exists to stop that being reported as one. What is [C]: make help must work and must list the targets.
- [T] Map target names to
cmd/ path semantics: cmd/<name> → build-<name>, cmd/<kind>/<name> → build-<kind>-<name>. Match an existing repo's naming first.
- [C] Declare all non-file targets in
.PHONY — otherwise a same-named file silently disables the target.
- [C] Output executable bare
Makefile (tabs for recipes, not spaces) — spaces produce "missing separator" and the file simply does not run.
Build Quality
Safety
- [C] Fail early with clear tool-presence checks (
command -v <tool>) for optional dependencies — a missing tool must stop the target, not be skipped.
- [C]
ci must mirror the actual CI pipeline. A local ci that runs a different set gives false confidence; mirror what the pipeline runs, not what this template prefers.
- [D] Check for code generation staleness (
generate-check) when go generate is used.
Go Version Awareness
Read go.mod for the go directive before composing the Makefile. Record as Go version: X.Y in the output report.
| Go Version |
Makefile Impact |
| < 1.16 |
go install does not support pkg@version; use go get for tool installation |
| ≥ 1.18 |
Go workspaces (go.work) and fuzzing (go test -fuzz) available |
| ≥ 1.20 |
go build -cover + GOCOVERDIR enable whole-program / integration coverage; consider a cover-integration target |
| ≥ 1.21 |
go.mod toolchain directive (automatic toolchain selection); built-in min/max/clear |
| ≥ 1.22 |
Per-iteration loop variable semantics; no Makefile impact but note in output |
If go.mod is not found or not readable, record Go version: unknown and use conservative defaults (no version-specific features).
Monorepo Support
When the repo is a Go workspace or multi-module layout (step 1 Inspect), adapt for monorepo:
- Detect via the toolchain, not a bare file search: prefer
go.work (go env GOWORK); when it exists, the modules are its use directives (go list -m run inside the workspace). Only fall back to searching for go.mod files when there is no go.work, and then exclude vendor/, testdata/, examples/, and tool-only modules.
- Per-module targets: generate
test-<module>, lint-<module>, build-<module> for each module that has entrypoints
- Aggregate targets:
test-all, lint-all, build-all that iterate over the workspace modules
- Per-module
go mod tidy: tidy operates on a single main module — run it inside each module (for m in $(MODULES); do (cd $$m && go mod tidy); done), never once at the workspace root
- Root Makefile pattern:
# `go env GOWORK` has THREE answers, not two: a path (workspace active), the
# empty string (no go.work), and the literal string `off` (workspace mode
# disabled via GOWORK=off or -workfile=off). `off` is the trap — it is
# non-empty, so testing emptiness alone reads it as a workspace path and asks
# `go list -m`, which then answers for whichever single module the cwd is in,
# or for NOTHING AT ALL in a monorepo with no root go.mod. Filtering `off` out
# collapses it into the "no workspace" case, where the file search finds every
# module regardless.
GOWORK := $(shell go env GOWORK 2>/dev/null)
WORKSPACE := $(filter-out off,$(strip $(GOWORK)))
ifeq ($(WORKSPACE),)
# SCOPED go.mod search — a bare search sweeps in vendor/testdata/examples.
MODULES := $(shell find . -name go.mod -type f 2>/dev/null | \
grep -Ev '(^|/)(vendor|testdata|examples?)(/|$$)' | \
sed 's|/go\.mod$$||; s|^\./||' | LC_ALL=C sort -u)
else
MODULES := $(shell go list -m -f '{{.Dir}}' 2>/dev/null)
endif
# Zero modules is never a legitimate answer — every Go repo has at least one
# go.mod — so each aggregate target below opens with the same guard. Without
# it the loop iterates zero times and exits 0, and a broken module list reads
# as "all modules pass". The guard is repeated rather than factored into a
# prerequisite target on purpose: these recipes get copied one at a time, and a
# fragment whose safety lives in a sibling target arrives without it. It is
# also not `$(error)`, which would fire at parse time and break `make help`.
test-all: ## Run tests for all modules
@test -n "$(strip $(MODULES))" || { echo "no Go modules found — check the go.work / go.mod layout"; exit 1; }
@for mod in $(MODULES); do \
echo "=== testing $$mod ==="; \
(cd $$mod && go test -race ./...) || exit 1; \
done
lint-all: ## Lint all modules
@test -n "$(strip $(MODULES))" || { echo "no Go modules found — check the go.work / go.mod layout"; exit 1; }
@for mod in $(MODULES); do \
echo "=== linting $$mod ==="; \
(cd $$mod && golangci-lint run) || exit 1; \
done
- When the project is a single-module repo, this section does not apply — use the standard single-module workflow.
- Record
Layout: monorepo (N modules) or Layout: single-module in the output report.
Anti-Patterns (DO NOT generate these)
Before writing or reviewing a Makefile, check against these common mistakes. If your output matches any of these patterns, fix it before delivering.
Missing fundamentals:
- No
help target or missing ## self-documenting comments
- No
.PHONY declaration for non-file targets
- No race testing at all — the default
test should use -race; provide test-norace (full suite, no race) as the cgo-off / unsupported-platform equivalent. test-short runs a smaller quick set and is not a substitute.
- No
-ldflags version injection in build-* targets of a program that has no version mechanism of its own (a library, or a repo that already stamps versions another way, is not a defect here)
Naming and layout:
- Target names not matching
cmd/ path semantics (e.g., cmd/consumer/sync but target is build-sync instead of build-consumer-sync)
run-* targets leaving ad-hoc binaries in source directories instead of bin/
Reproducibility:
install-tools using @latest for all tools in CI (pin specific versions for reproducibility; @latest is acceptable only for local dev convenience)
- Hardcoding a tool version without discovering the repo's existing pin — check CI workflows,
.golangci.version / .tool-versions (asdf/mise), and any existing install-tools first; use a current compatible version (golangci-lint is now v2, module path .../v2/cmd/golangci-lint) and prefer the tool's official installer where it documents one (go install from source is explicitly not guaranteed for golangci-lint)
ci target that diverges from the actual CI pipeline — make ci should mirror CI exactly
- Hidden assumptions about local paths or OS-specific tools (e.g.,
sed -i without considering macOS vs GNU differences)
Cross-compilation:
- Cross-compiling a pure-Go binary without
CGO_ENABLED=0 — produces dynamically linked binaries that fail on target machines (cgo projects instead need CGO_ENABLED=1 and a cross C toolchain)
- Hardcoded
GOOS/GOARCH without variable override
Code generation:
- Generated code not checked for staleness before build (missing
generate-check target)
Over-engineering:
- Overly dynamic Make metaprogramming (eval/call/define) that reduces readability when explicit targets would be clearer
- Tab-vs-space issues in Makefile recipes (recipes MUST use tabs, not spaces)
Quality Improvements to Offer
- Add
.DEFAULT_GOAL := help.
- Add
cover-check target with a configurable threshold.
- Add
tidy target for go mod tidy + go mod verify.
- Keep
run-* from polluting source directories; prefer go run ./cmd/... or run from bin/.
- Pin tool versions in
install-tools for CI reproducibility (see quality-guide §11).
Load References Selectively
When starting any Makefile creation or refactor task:
→ Run this skill's discovery script against the target repo first — bash <skill-dir>/scripts/discover_go_entrypoints.sh <project-root> — to locate every package main and infer project shape (library-only vs single-binary vs multi-binary). Check the confidence column before turning a row into a target. Add --modules to list workspace/multi-module directories; it exits 3 rather than printing an empty list, because zero modules means discovery broke, not that there is nothing to build.
When writing or reviewing specific targets (build, test, lint, run, install-tools), or checking quality rules:
→ Load references/makefile-quality-guide.md for canonical target templates, variable conventions, .PHONY rules, self-documenting help output, and the 15-item review checklist.
When reviewing a PR that touches a Makefile:
→ Load references/pr-checklist.md for the fast Makefile-specific PR review checklist (target naming, portability, idempotency, CI compatibility).
When you need a complete working Makefile as a starting point or reference:
→ Load references/golden/simple-project.mk for a single-binary project with minimal tooling.
→ Load references/golden/complex-project.mk for a multi-binary project with Docker, code generation, and cross-compilation targets.
Output Contract
When generating or refactoring a Makefile, always return:
- Mode:
Create or Refactor with rationale
- Project info: Go version (from
go.mod), layout (single-module or monorepo (N modules)), entrypoints discovered
- Changed files
- New/updated targets
- Deprecated/aliased targets (Refactor mode — list old name → new name mappings)
- Assumptions or missing tools
- Validation commands executed with pass/fail status
Example Output (Create mode, single-binary)
### Mode
Create — no existing Makefile found
### Project info
- Go version: 1.23 (from go.mod)
- Layout: single-module
- Entrypoints: cmd/api
### Changed files
- `Makefile` (created)
### New targets
help, build-api, build-all, run-api, fmt, fmt-check, tidy,
test, cover, cover-check, lint, ci, version, install-tools,
check-tools, clean
### Deprecated/aliased targets
(none — new Makefile)
### Assumptions
- golangci-lint will be installed via `make install-tools`
- Version info injected into `main.version`, `main.commit`, `main.buildTime`
### Validation
✓ make help — 16 targets listed
✓ make test — all tests pass with -race
✓ make build-api — binary at bin/api
✓ ./bin/api --version — version=v0.1.0-dirty commit=abc1234 buildTime=… (injection reached the artifact)
Self-Validation
Run scripts/run_regression.sh to verify skill integrity:
- Contract tests: structure of SKILL.md, quality guide, golden examples, discovery script
- Golden review tests: all defect/FP fixtures' rules covered in docs
- Coverage matrix: see
scripts/tests/COVERAGE.md
1---2name: go-makefile-writer3description: Canonical skill for Go Makefiles. Create/refactor root Makefiles for Go repositories with standardized build/test/lint/run targets, self-documenting help output, predictable artifacts, and maintainable target naming.4---56# Go Makefile Writer78Design a practical root `Makefile` that is readable, reproducible, and aligned with repository layout.910## Quick Reference1112| If you need to… | Go to |13|---|---|14| Create a Makefile from scratch for a new Go project | §Execution Modes → Create + §Workflow |15| Refactor or update an existing Makefile (minimal-diff) | §Execution Modes → Refactor |16| Decide which targets to include (`build`, `test`, `lint`, `ci`…) | §Workflow (Plan targets) |17| Get a complete working Makefile example to start from | Load `references/golden/simple-project.mk` or `complex-project.mk` |18| Check quality rules, variable conventions, `.PHONY` requirements | Load `references/makefile-quality-guide.md` |19| Review a Makefile PR quickly | Load `references/pr-checklist.md` |20| Handle a monorepo or multi-module Go repo | §Monorepo Support |2122## Execution Modes2324Select a mode before starting and state it in the output report.2526### Create (new Makefile from scratch)2728- Full target set generated from project inspection.29- Use golden templates ([simple-project.mk](references/golden/simple-project.mk) / [complex-project.mk](references/golden/complex-project.mk)) as starting points.30- No backward-compatibility concerns.3132### Refactor (modify existing Makefile)3334- **Minimal-diff edits** — change only what is needed; do not rewrite the entire file.35- **Backward compatibility**: if target names change, keep aliases for at least one transition period and document them in the output report.36- **Preserve existing useful targets** unless user explicitly asks to remove them.37- Before editing, snapshot the current target list via `make -qp | awk -F: '/^[a-zA-Z0-9_-]+:/ {print $1}' | sort -u` for comparison.38- Validation must include verifying that previously used critical targets still work (or their aliases do).3940## Workflow41420. **Select mode** (`Create` or `Refactor`) and record rationale.43441. **Inspect** project structure:45 - discover entrypoints (`package main`, wherever it lives) by running this skill's discovery script against the target repo: `bash <skill-dir>/scripts/discover_go_entrypoints.sh <project-root>` (the script lives in the skill directory, not in the target repo — pass the repo root as its argument)46 - **read the script's exit status before its output.** `0` means the query was complete and what you got is the whole answer — including an empty answer, which then genuinely means "library, no programs". `4` means the query was incomplete: empty output is *unknown*, not *library*. `2` is a bad root, `3` (with `--modules`) is zero modules. The stderr diagnostic names the toolchain error to fix47 - the script emits 5 tab-separated fields — `kind name target_name dir confidence`. **`confidence` is not decoration.** `confirmed` means `go list` reported the package's name as `main`, and can become a build target directly. `candidate` means the toolchain could not be asked about that subtree and the row is a filename guess — list those to the user for confirmation instead of generating targets from them, and say in your output that they are unconfirmed48 - if the script cannot run at all, fall back to `find cmd -name main.go -type f`, but treat every result as a candidate: a file named `main.go` may declare any package, and a program's file may be named anything49 - detect quality tools and conventions (`go test`, `golangci-lint`, `swag`)50 - detect code generation usage (`go generate`, protobuf, wire, mockgen, etc.)51 - detect containerization (`Dockerfile`, `docker-compose.yml`)52 - **read `go.mod`** for Go version (`go` directive) and module path53 - inspect existing `Makefile` if present (Refactor mode)54 - **detect workspace / multi-module layout via the toolchain first**: `go env GOWORK` — a real path means a `go.work` workspace (its modules are `go list -m`), while the literal string `off` means workspace mode was **disabled** and must be treated as "no workspace", not as a path. Only when there is no `go.work` fall back to a scoped `go.mod` search (`bash <skill-dir>/scripts/discover_go_entrypoints.sh --modules <project-root>`, which excludes `vendor/`, `testdata/`, `examples/`). See §Monorepo Support.55562. **Plan** target set:57 - core targets: `help`, `fmt`, `tidy`, `test`, `cover`, `lint`, `clean`58 - version targets (**programs only**): `version` (print embedded version info)59 - CI target: `ci` (fmt-check + lint + test + cover-check in one pass)60 - optional targets: `swagger`, `generate`, `install-tools`, `test-integration`, `bench`61 - build targets (**programs only**): `build-all` plus per-binary targets, with `-ldflags` version injection unless the project already has a version mechanism62 - run targets: per-binary `run-*` targets63 - container targets (when Dockerfile present): `docker-build`, `docker-push`64 - cross-compile targets (when needed): `build-linux`, `build-all-platforms`65 - **Go version-aware decisions** (see [Go Version Awareness](#go-version-awareness))66673. **Compose** and write root Makefile:68 - keep targets explicit and predictable69 - use variables (`GO`, `BIN_DIR`, `VERSION`, `COMMIT`, `BUILD_TIME`) for repeated paths and build metadata70 - inject version info via `-ldflags` in all build targets71 - include `.PHONY`72 - fail early with clear tool checks for optional dependencies73 - use target templates from [makefile-quality-guide.md](references/makefile-quality-guide.md)74 - **Refactor mode**: apply minimal-diff strategy and backward-compatibility rules from the mode definition above75764. **Validate**:77 - run `make help`78 - run `make test`79 - run one representative `build-*` target80 - **when the project has a binary and version injection was added**: build, then run the binary's version surface (`--version`, a `version` subcommand — whatever it actually has) to verify injection reached the artifact. `make version` only prints the Make variables, not what the binary embeds. A library-only repo has nothing to run here; validate with `make test` instead.81 - if possible, run one representative `run-*` target in a safe environment82 - **Refactor mode**: verify previously used critical targets still work (or provide aliases); compare target list before vs after8384## Rules8586Each rule carries its force, because "we always add a `version` target" and "a87target must not exit 0 when its work failed" are not the same kind of claim and88should not be argued about on the same terms:8990| Tag | Force | What to do when a project disagrees |91|-----|-------|-------------------------------------|92| **[C]** | **Correctness.** Breaking it produces a Makefile that lies about success, or that does not work. | Do not deviate. If the user insists, say plainly what will break. |93| **[D]** | **Recommended default.** Right for most Go repos; a project may have a real reason not to. | Follow it unless the repo shows otherwise; if you deviate, say why in the Assumptions block. |94| **[T]** | **Template convention.** This template's house style. A repo doing it differently is not wrong. | Match the repo's existing convention over this one. |9596### Error Propagation — every rule here is [C]9798A build script whose targets exit 0 on internal failure is worse than no script:99CI goes green on a broken tree. These are the shapes that leak, all of which100shipped in this skill's own templates until 2026-09-09:101102- **[C] Branch on a tool's exit status, not only on its output.** `gofmt -l` on a103 file that does not parse prints the error to **stderr**, prints **nothing** on104 stdout, and exits **2** — so `test -z "$(gofmt -l .)"` passes a tree that does105 not compile. Capture both and check the status first.106- **[C] A pipeline reports only its last command's status.** `curl … | sh` succeeds107 when the download fails, because `sh` given no input exits 0. Either set108 `SHELL := bash` with `.SHELLFLAGS := -euo pipefail -c`, or download to a temp109 file and check before executing. Prefer both: the recipe stays correct if the110 template is copied somewhere without bash.111- **[C] A `for` loop's status is its last iteration's.** Put `|| exit 1` in the112 body, or a broken first binary is masked by a working second one.113- **[C] A staleness check must compare content, not status strings.**114 `git status --porcelain` prints the same `?? path` line however the file's115 contents change, and `git diff` skips untracked files — so hash them116 (`git hash-object`) rather than trusting the status line.117- **[C] An aggregate target must fail when it has nothing to aggregate.** A118 module or entrypoint list that comes back empty means discovery broke; looping119 over nothing and exiting 0 is the failure this whole layer exists to prevent.120- **[C] A loop body must not END on a test.** `while read f; do [ -f "$f" ] &&121 echo …; done` takes the loop's status from the last iteration's `[ -f ]`, so122 one skipped entry fails the whole target under `set -e`. Write `if … then …123 fi`, which ends on the `fi` and is always true. This is the mirror image of124 the loop rule above: there a real failure was swallowed, here a non-failure125 was manufactured — both come from not knowing what the loop's status is.126- **[C] "Nothing found" and "could not look" are different answers.** A check127 that cannot run has not passed. Give the two outcomes different exit codes128 and let the caller branch on them; collapsing them into "exit 0, no output"129 is how a program with an unreadable build cache gets classified as a library130 and loses every build target it should have had.131132### Project shape decides the target set133134Ask what the repo *is* before applying the default target list. Getting this135wrong is how a generated Makefile ends up demanding things the project cannot136supply:137138| Repo shape | How to tell | What changes |139|---|---|---|140| **Library only** (no programs) | `discover_go_entrypoints.sh` **exits 0** having printed nothing. Exit 0 is the part that matters: it means every module answered, so "no programs" is an answer rather than a silence | **No** `build-*`, `run-*`, `version` target, `-ldflags` injection, or `--version` validation — there is no binary to inject into. Keep `fmt`/`tidy`/`test`/`cover`/`lint`/`ci`. Validate with `make test`, not with a binary. |141| **Program with its own version mechanism** | an existing `version` package, `debug.ReadBuildInfo()`, a generated `version.go`, or a release tool that stamps binaries | Do **not** add a second, competing mechanism. Wire the existing one, or leave version handling alone and say so. `-ldflags -X` is [D], not [C]. |142| **cgo project** | `import "C"`, `mattn/go-sqlite3`, … | `CGO_ENABLED=1`; drop the static-build and `CGO_ENABLED=0` cross-compile defaults. |143| **No `--version` flag** | the binary uses a `version` subcommand, or has none | Validate injection however the program actually exposes it. Do not add a flag to someone's CLI to satisfy a Makefile check. |144| **Different test strategy** | build-tagged integration suites, a test script, `gotestsum`, no `-race` on this platform | Mirror what CI runs. The `ci` target's job is to match the pipeline, not to impose this template's. |145| **Shape not yet known** | `discover_go_entrypoints.sh` **exits 4** — the toolchain could not be asked about some or all of the tree (bad `GOFLAGS`, unreadable build cache, no `go.mod`, a module that will not load) | **Do not classify.** Empty output here means *unknown*, not *library*: a perfectly ordinary program can produce it. Fix the reported toolchain error and re-run, or tell the user what could not be checked and what you assumed. Guessing "library" here silently drops every `build-*` target the repo should have. |146147When a project's shape contradicts a [D] rule, follow the project and record the148deviation in the Assumptions block of your output. When it contradicts a [C]149rule, that is a defect in the project, and worth saying so.150151### Target Design152- **[D]** Prefer explicit targets over complex metaprogramming unless the user asks for DRY-heavy style.153- **[C]** Keep artifact output paths deterministic — a target whose output location varies per invocation cannot be cached or cleaned reliably.154- **[T]** Put them under `bin/`. Match an existing repo's choice (`build/`, `dist/`, `out/`) over this one.155- **[T]** Keep `help` output self-documenting via `##` comments with `.DEFAULT_GOAL := help`. A repo with its own help format is not defective — fixture `009_custom_help_format_fp.json` exists to stop that being reported as one. What *is* [C]: `make help` must work and must list the targets.156- **[T]** Map target names to `cmd/` path semantics: `cmd/<name>` → `build-<name>`, `cmd/<kind>/<name>` → `build-<kind>-<name>`. Match an existing repo's naming first.157- **[C]** Declare all non-file targets in `.PHONY` — otherwise a same-named file silently disables the target.158- **[C]** Output executable bare `Makefile` (tabs for recipes, not spaces) — spaces produce "missing separator" and the file simply does not run.159160### Build Quality161- **[D]** Inject version metadata via `-ldflags` in build targets — **only when the project has a binary and no version mechanism of its own** (see Project shape):162 ```163 LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildTime=$(BUILD_TIME)164 ```165 `-X` only sets an **existing** package-level string var — `-X main.version` assumes `var version string` in package `main`; discover the real package/name first and use its import path if it lives elsewhere (a wrong path silently no-ops). `-s -w` strips the symbol table and DWARF (release builds only; keep a debug build without it).166- **[D]** Default the `test` target to `-race` — it catches real data races. But `-race` requires `CGO_ENABLED=1`, a supported OS/arch, and adds ~5–10× memory / 2–20× time. Offer a genuine race-free equivalent, `test-norace` (`go test ./...` — the **full** suite, no race), for cgo-disabled builds and platforms without race support. Do not conflate this with `test-short` (`go test -short ./...`): `-short` skips `testing.Short()`-gated cases, so it runs a *smaller* set — it turns off race only as a side effect and is not a substitute for the full suite.167- **[D]** `CGO_ENABLED=0` is the default for **pure-Go** static builds and containers. For cgo projects (`import "C"`, `mattn/go-sqlite3`, …) keep `CGO_ENABLED=1` — and note the two collide: a `-race` test target cannot run under `CGO_ENABLED=0`.168- **[D]** For reproducible release builds add `-trimpath` (strips local paths so the binary is checkout-location-independent) and drive `buildTime` from `SOURCE_DATE_EPOCH`. This raises reproducibility but is not absolute: `git describe --dirty` makes `VERSION` depend on tree state, so only a clean checkout with a fixed toolchain is bit-for-bit reproducible.169- **[C]** Pin tool versions in `install-tools` — an unpinned tool makes CI non-reproducible and can change verdicts between runs.170171### Safety172- **[C]** Fail early with clear tool-presence checks (`command -v <tool>`) for optional dependencies — a missing tool must stop the target, not be skipped.173- **[C]** `ci` must mirror the actual CI pipeline. A local `ci` that runs a *different* set gives false confidence; mirror what the pipeline runs, not what this template prefers.174- **[D]** Check for code generation staleness (`generate-check`) when `go generate` is used.175176## Go Version Awareness177178Read `go.mod` for the `go` directive before composing the Makefile. Record as `Go version: X.Y` in the output report.179180| Go Version | Makefile Impact |181|-----------|-----------------|182| < 1.16 | `go install` does not support `pkg@version`; use `go get` for tool installation |183| ≥ 1.18 | Go workspaces (`go.work`) and fuzzing (`go test -fuzz`) available |184| ≥ 1.20 | `go build -cover` + `GOCOVERDIR` enable whole-program / integration coverage; consider a `cover-integration` target |185| ≥ 1.21 | `go.mod` `toolchain` directive (automatic toolchain selection); built-in `min`/`max`/`clear` |186| ≥ 1.22 | Per-iteration loop variable semantics; no Makefile impact but note in output |187188If `go.mod` is not found or not readable, record `Go version: unknown` and use conservative defaults (no version-specific features).189190## Monorepo Support191192When the repo is a Go workspace or multi-module layout (step 1 Inspect), adapt for monorepo:193194- **Detect via the toolchain, not a bare file search**: prefer `go.work` (`go env GOWORK`); when it exists, the modules are its `use` directives (`go list -m` run inside the workspace). Only fall back to searching for `go.mod` files when there is no `go.work`, and then exclude `vendor/`, `testdata/`, `examples/`, and tool-only modules.195- **Per-module targets**: generate `test-<module>`, `lint-<module>`, `build-<module>` for each module that has entrypoints196- **Aggregate targets**: `test-all`, `lint-all`, `build-all` that iterate over the workspace modules197- **Per-module `go mod tidy`**: `tidy` operates on a single main module — run it inside each module (`for m in $(MODULES); do (cd $$m && go mod tidy); done`), never once at the workspace root198- **Root Makefile pattern**:199200```make201# `go env GOWORK` has THREE answers, not two: a path (workspace active), the202# empty string (no go.work), and the literal string `off` (workspace mode203# disabled via GOWORK=off or -workfile=off). `off` is the trap — it is204# non-empty, so testing emptiness alone reads it as a workspace path and asks205# `go list -m`, which then answers for whichever single module the cwd is in,206# or for NOTHING AT ALL in a monorepo with no root go.mod. Filtering `off` out207# collapses it into the "no workspace" case, where the file search finds every208# module regardless.209GOWORK := $(shell go env GOWORK 2>/dev/null)210WORKSPACE := $(filter-out off,$(strip $(GOWORK)))211212ifeq ($(WORKSPACE),)213# SCOPED go.mod search — a bare search sweeps in vendor/testdata/examples.214MODULES := $(shell find . -name go.mod -type f 2>/dev/null | \215 grep -Ev '(^|/)(vendor|testdata|examples?)(/|$$)' | \216 sed 's|/go\.mod$$||; s|^\./||' | LC_ALL=C sort -u)217else218MODULES := $(shell go list -m -f '{{.Dir}}' 2>/dev/null)219endif220221# Zero modules is never a legitimate answer — every Go repo has at least one222# go.mod — so each aggregate target below opens with the same guard. Without223# it the loop iterates zero times and exits 0, and a broken module list reads224# as "all modules pass". The guard is repeated rather than factored into a225# prerequisite target on purpose: these recipes get copied one at a time, and a226# fragment whose safety lives in a sibling target arrives without it. It is227# also not `$(error)`, which would fire at parse time and break `make help`.228229test-all: ## Run tests for all modules230 @test -n "$(strip $(MODULES))" || { echo "no Go modules found — check the go.work / go.mod layout"; exit 1; }231 @for mod in $(MODULES); do \232 echo "=== testing $$mod ==="; \233 (cd $$mod && go test -race ./...) || exit 1; \234 done235236lint-all: ## Lint all modules237 @test -n "$(strip $(MODULES))" || { echo "no Go modules found — check the go.work / go.mod layout"; exit 1; }238 @for mod in $(MODULES); do \239 echo "=== linting $$mod ==="; \240 (cd $$mod && golangci-lint run) || exit 1; \241 done242```243244- When the project is a single-module repo, this section does not apply — use the standard single-module workflow.245- Record `Layout: monorepo (N modules)` or `Layout: single-module` in the output report.246247## Anti-Patterns (DO NOT generate these)248249Before writing or reviewing a Makefile, check against these common mistakes. If your output matches any of these patterns, fix it before delivering.250251**Missing fundamentals:**252- No `help` target or missing `##` self-documenting comments253- No `.PHONY` declaration for non-file targets254- No race testing at all — the default `test` should use `-race`; provide `test-norace` (full suite, no race) as the cgo-off / unsupported-platform equivalent. `test-short` runs a smaller quick set and is not a substitute.255- No `-ldflags` version injection in `build-*` targets **of a program that has no version mechanism of its own** (a library, or a repo that already stamps versions another way, is not a defect here)256257**Naming and layout:**258- Target names not matching `cmd/` path semantics (e.g., `cmd/consumer/sync` but target is `build-sync` instead of `build-consumer-sync`)259- `run-*` targets leaving ad-hoc binaries in source directories instead of `bin/`260261**Reproducibility:**262- `install-tools` using `@latest` for all tools in CI (pin specific versions for reproducibility; `@latest` is acceptable only for local dev convenience)263- Hardcoding a tool version without discovering the repo's existing pin — check CI workflows, `.golangci.version` / `.tool-versions` (asdf/mise), and any existing `install-tools` first; use a current compatible version (golangci-lint is now v2, module path `.../v2/cmd/golangci-lint`) and prefer the tool's official installer where it documents one (`go install` from source is explicitly not guaranteed for golangci-lint)264- `ci` target that diverges from the actual CI pipeline — `make ci` should mirror CI exactly265- Hidden assumptions about local paths or OS-specific tools (e.g., `sed -i` without considering macOS vs GNU differences)266267**Cross-compilation:**268- Cross-compiling a **pure-Go** binary without `CGO_ENABLED=0` — produces dynamically linked binaries that fail on target machines (cgo projects instead need `CGO_ENABLED=1` and a cross C toolchain)269- Hardcoded `GOOS/GOARCH` without variable override270271**Code generation:**272- Generated code not checked for staleness before build (missing `generate-check` target)273274**Over-engineering:**275- Overly dynamic Make metaprogramming (eval/call/define) that reduces readability when explicit targets would be clearer276- Tab-vs-space issues in Makefile recipes (recipes MUST use tabs, not spaces)277278## Quality Improvements to Offer279280- Add `.DEFAULT_GOAL := help`.281- Add `cover-check` target with a configurable threshold.282- Add `tidy` target for `go mod tidy` + `go mod verify`.283- Keep `run-*` from polluting source directories; prefer `go run ./cmd/...` or run from `bin/`.284- Pin tool versions in `install-tools` for CI reproducibility (see [quality-guide §11](references/makefile-quality-guide.md#11-tool-installation)).285286## Load References Selectively287288When starting any Makefile creation or refactor task:289→ Run this skill's discovery script against the target repo first — `bash <skill-dir>/scripts/discover_go_entrypoints.sh <project-root>` — to locate every `package main` and infer project shape (library-only vs single-binary vs multi-binary). Check the `confidence` column before turning a row into a target. Add `--modules` to list workspace/multi-module directories; it exits 3 rather than printing an empty list, because zero modules means discovery broke, not that there is nothing to build.290291When writing or reviewing specific targets (`build`, `test`, `lint`, `run`, `install-tools`), or checking quality rules:292→ Load `references/makefile-quality-guide.md` for canonical target templates, variable conventions, `.PHONY` rules, self-documenting `help` output, and the 15-item review checklist.293294When reviewing a PR that touches a Makefile:295→ Load `references/pr-checklist.md` for the fast Makefile-specific PR review checklist (target naming, portability, idempotency, CI compatibility).296297When you need a complete working Makefile as a starting point or reference:298→ Load `references/golden/simple-project.mk` for a single-binary project with minimal tooling.299→ Load `references/golden/complex-project.mk` for a multi-binary project with Docker, code generation, and cross-compilation targets.300301## Output Contract302303When generating or refactoring a Makefile, always return:3043051. **Mode**: `Create` or `Refactor` with rationale3062. **Project info**: Go version (from `go.mod`), layout (`single-module` or `monorepo (N modules)`), entrypoints discovered3073. **Changed files**3084. **New/updated targets**3095. **Deprecated/aliased targets** (Refactor mode — list old name → new name mappings)3106. **Assumptions or missing tools**3117. **Validation commands executed** with pass/fail status312313### Example Output (Create mode, single-binary)314315```316### Mode317Create — no existing Makefile found318319### Project info320- Go version: 1.23 (from go.mod)321- Layout: single-module322- Entrypoints: cmd/api323324### Changed files325- `Makefile` (created)326327### New targets328help, build-api, build-all, run-api, fmt, fmt-check, tidy,329test, cover, cover-check, lint, ci, version, install-tools,330check-tools, clean331332### Deprecated/aliased targets333(none — new Makefile)334335### Assumptions336- golangci-lint will be installed via `make install-tools`337- Version info injected into `main.version`, `main.commit`, `main.buildTime`338339### Validation340✓ make help — 16 targets listed341✓ make test — all tests pass with -race342✓ make build-api — binary at bin/api343✓ ./bin/api --version — version=v0.1.0-dirty commit=abc1234 buildTime=… (injection reached the artifact)344```345346## Self-Validation347348Run `scripts/run_regression.sh` to verify skill integrity:349- **Contract tests**: structure of SKILL.md, quality guide, golden examples, discovery script350- **Golden review tests**: all defect/FP fixtures' rules covered in docs351- **Coverage matrix**: see `scripts/tests/COVERAGE.md`