Best Practices for Go Makefile and Build Scripts: golang-standards/project-layout Guide
The golang-standards/project-layout repository recommends keeping a minimal root Makefile that delegates all build logic to version-controlled shell scripts in a scripts/ directory, ensuring reproducible builds across local development and CI pipelines.
The golang-standards/project-layout repository establishes a clean separation between a tiny top-level Makefile and executable shell scripts in scripts/. This approach prevents an ever-growing Makefile while maintaining a simple, idiomatic interface for developers. By following these best practices for Go Makefile and build scripts, projects achieve consistent behavior between local make commands and automated CI workflows.
Separate Concerns with a Thin Makefile and Script Directory
According to the source code in the golang-standards/project-layout repository, the root Makefile should act solely as a thin wrapper that exposes high-level targets while delegating implementation details to the scripts/ directory. This structure keeps the project root uncluttered and provides a single source of truth for build operations.
The repository defines three primary locations for build-related files:
Makefile(root): Contains only high-level target aliases andincludestatements, typically under 20 linesscripts/: Houses concrete Bash scripts for building, testing, linting, and releasing (documented inscripts/README.md)build/: Holds CI configuration files, packaging scripts, and Dockerfiles (documented inbuild/README.md)
Recommended Makefile Pattern
The root Makefile should define .PHONY targets that invoke corresponding scripts in the scripts/ directory. This pattern ensures that adding a new build step requires only creating a new script file and adding a single line to the Makefile.
.PHONY: all build test lint fmt clean ci docker
all: build
# High-level targets merely invoke the corresponding script
build: ## Build the binary
@./scripts/build.sh
test: ## Run unit tests
@./scripts/test.sh
lint: ## Run linters (golangci-lint, staticcheck, …)
@./scripts/lint.sh
fmt: ## Format source files
@./scripts/fmt.sh
clean: ## Remove generated artifacts
@./scripts/clean.sh
ci: ## Run the CI pipeline locally
@./scripts/ci.sh
docker: ## Build Docker image
@./scripts/docker.sh
Key implementation details from the repository's Makefile:
@silences command echo, keeping output tidy during execution##comments enable automatic help text generation via tools likemake help.PHONYdeclarations prevent conflicts when files namedbuildortestexist- Each target invokes scripts using relative paths (
./scripts/) to ensure portability
Implementing Robust Build Scripts
All operational logic lives in the scripts/ directory. The repository recommends writing these in Bash with strict error handling and Go-specific build optimizations.
Here is the standard pattern for scripts/build.sh as derived from the project layout standards:
#!/usr/bin/env bash
set -euo pipefail
# Enable module support for Go < 1.14 if needed
GO_BUILD_FLAGS="-mod=vendor"
# Build flags for reproducible builds
LDFLAGS="-s -w -X main.version=$(git describe --tags --always)"
echo "▶ Building binary..."
go build ${GO_BUILD_FLAGS} -ldflags="${LDFLAGS}" -o ./bin/your_app ./cmd/your_app
echo "✅ Build complete: ./bin/your_app"
Critical script requirements per the source analysis:
set -euo pipefailforces immediate exit on errors, undefined variables, or pipeline failures-mod=vendorensures vendored dependencies are used when the repository pins them (referenced in the mainREADME.mdat line 93)-ldflags="-s -w …"strips debug symbols to reduce binary size while injecting version information derived from Git tags
Integrating with CI Pipelines
Because all build logic resides in executable scripts rather than Makefile syntax, CI systems can invoke the exact same commands used by local developers. This eliminates "works on my machine" discrepancies between local make usage and automated pipelines.
A typical GitHub Actions workflow referencing these scripts:
# .github/workflows/ci.yml (excerpt)
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.22'
- name: Build
run: ./scripts/build.sh
- name: Test
run: ./scripts/test.sh
This approach ensures that ./scripts/build.sh executes identically whether triggered locally via make build or inside a CI container.
Advanced Configuration and Versioning
The golang-standards/project-layout repository provides additional guidance for complex build scenarios through its build/ directory structure and versioning strategies.
Directory organization:
- Place packaging-specific scripts under
build/package/for OS-specific installers and distribution packages - Store CI-specific configurations under
build/ci/for Jenkinsfiles, Azure Pipelines, or custom automation - Maintain Docker-related assets in
build/package/or dedicated directories as appropriate
Version injection:
Embed Git-derived version information into binaries using the -X linker flag pattern shown in the build script example. This enables applications to report accurate version strings via your_app --version without manual code changes during releases.
Script permissions:
Ensure all scripts in the repository are executable (chmod +x scripts/*.sh) so they run immediately on clean checkouts without additional setup steps.
Summary
- Keep the root
Makefileminimal and delegate all logic to version-controlled scripts inscripts/ - Use
set -euo pipefailin Bash scripts to enforce fail-fast behavior and prevent silent failures - Include
-mod=vendorin Go build commands when vendoring dependencies (as specified in the projectREADME.md) - Reference scripts directly in CI configurations to guarantee parity between local
makecommands and pipeline execution - Organize supporting files using the
build/ci/andbuild/package/directories for CI configs and packaging scripts respectively
Frequently Asked Questions
Why not put all build logic directly in the Makefile?
Centralizing logic in shell scripts under scripts/ creates a single source of truth that CI systems can invoke directly without requiring make on the runner. This separation also prevents the root Makefile from becoming an unmaintainable monolith as the project grows, and allows non-Makefile workflows (such as Windows PowerShell scripts) to coexist without breaking existing targets.
How do I handle cross-platform builds for Windows and Linux?
The script-based approach naturally supports cross-platform flexibility. While Linux and macOS developers use the Bash scripts in scripts/, Windows developers can add equivalent PowerShell scripts (e.g., scripts/build.ps1) without modifying the Makefile. CI pipelines can detect the OS and call the appropriate script, or you can add OS-specific Makefiles that include the relevant scripts.
Where should I store CI-specific configuration files?
According to the build/README.md in the repository, place CI-specific configuration files under build/ci/. This includes Jenkins pipeline definitions, Azure Pipelines YAML, or custom automation scripts. Keeping these separate from the main scripts/ directory distinguishes continuous integration configuration from general build logic.
How do I inject version information into Go binaries?
Use the -ldflags flag with the -X linker option during the go build command. The standard pattern injects the Git tag or commit hash into a package-level variable: -ldflags="-X main.version=$(git describe --tags --always)". This requires defining a version variable in your main package and ensures the binary reports accurate version metadata without code modifications.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →