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 and include statements, typically under 20 lines
  • scripts/: Houses concrete Bash scripts for building, testing, linting, and releasing (documented in scripts/README.md)
  • build/: Holds CI configuration files, packaging scripts, and Dockerfiles (documented in build/README.md)

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 like make help
  • .PHONY declarations prevent conflicts when files named build or test exist
  • 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 pipefail forces immediate exit on errors, undefined variables, or pipeline failures
  • -mod=vendor ensures vendored dependencies are used when the repository pins them (referenced in the main README.md at 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 Makefile minimal and delegate all logic to version-controlled scripts in scripts/
  • Use set -euo pipefail in Bash scripts to enforce fail-fast behavior and prevent silent failures
  • Include -mod=vendor in Go build commands when vendoring dependencies (as specified in the project README.md)
  • Reference scripts directly in CI configurations to guarantee parity between local make commands and pipeline execution
  • Organize supporting files using the build/ci/ and build/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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →