Development

The supported everyday workflow runs Go tooling inside the project’s Docker development image. That removes host-version drift and means a clean machine needs Docker rather than a separately curated Go toolchain.

The Makefile has two execution shapes:

  • ordinary build, lint, formatting, dependency, and vulnerability work runs in the development container with the repository mounted at /work;
  • make test-unit uses the ordinary socketless container; full, integration, and coverage targets mount the Docker socket for Testcontainers.

The exact scripts and override lookup live in the framework Make-script deep dive.

Common targets

Target Runs where Purpose
make dev-image Docker build Build the development image used by normal tooling.
make test Docker + socket Race-enabled go test ./....
make test-unit Docker Race-enabled go test ./... without Docker-socket access.
make test-integration Docker + socket Uncached, race-enabled test run with a ten-minute timeout.
make test-coverage Docker + socket Race-enabled coverage check; default floor is MIN_TEST_COVERAGE=90.
make lint Docker shfmt, ShellCheck, go fix diff check, and golangci-lint.
make lint-fix Docker Apply supported formatting and lint fixes. Review its diff.
make format Docker Run gofumpt and shfmt.
make sec Docker Full security scan: govulncheck + semgrep, merged into sec.sarif for the Security tab. Fails on any finding.
make generate Docker Execute package-local go:generate directives.
make build Docker Build a static executable under build/.
make docker-build Docker Build the production image.
make run-dev Docker Build/run the development image with the race detector.

Use make help as the current target list. Do not replace these calls with a host go test or host linter in project automation: that gives two different toolchains two chances to disagree.

CPU limit

Every dev container runs with --cpus set to the host thread count minus two, never below 1, so lint and tests leave the machine responsive. --cpus is the hard quota, and Go 1.25+ sizes GOMAXPROCS from it automatically. Containers that tests start themselves are not limited. These Make variables change it:

Variable Default Effect
DEV_CPUS_RESERVED 2 Threads kept free for the host.
DEV_CPUS host threads minus DEV_CPUS_RESERVED, minimum 1 Threads the container may use, for example make lint DEV_CPUS=4.
DEV_CPU_LIMIT_ARGS --cpus=$(DEV_CPUS) The Docker flags added to DEV_RUN and DEV_RUN_DIND. Set it empty (DEV_CPU_LIMIT_ARGS=) to remove the limit.
DEV_RUN_EXTRA_ARGS empty Extra docker run arguments for DEV_RUN.
DEV_RUN_DIND_EXTRA_ARGS empty Extra docker run arguments for DEV_RUN_DIND.

Tests, integration tests, and coverage

make test is the standard full suite. It enables Go’s race detector and has Docker available for Testcontainers-backed tests. make test-integration uses the same package pattern but disables the Go test cache and applies a ten-minute timeout. Use it when checking changes to real-infrastructure tests. Use make test-unit for the same race-enabled package walk without exposing the Docker socket; Testcontainers-backed cases cannot start infrastructure in that target.

make test-coverage also runs the race detector. It instruments every module package (-coverpkg=<module>/...) and runs -tags=integration, so a test under tests/ credits coverage to the production package it drives, and services are covered. A service-heavy project does not need to override the script to gate its own code. A service that runs out-of-process in a real container is covered too: the script exports SERVICEPACK_COVDATA_DIR, an integration test mounts it into that container as GOCOVERDIR, and the native covdata is merged into the total. Only what is not hand-written code under test is excluded from the floor: cmd/ mains, the tests/ harness, generated *.gen.go, the service-manager mocks, and the framework’s own example-* / hello-world demo services. Override the threshold deliberately:

make test-coverage MIN_TEST_COVERAGE=95

The target writes coverage-percent.txt for the badge workflow and removes the temporary coverage profiles. Testcontainers needs access to the Docker daemon; if Docker cannot create containers, fix Docker access rather than working around the integration tests.

Integration tests live under tests/. tests/testinfra is the harness: its baseline Setup builds the servicepack image from the repo Dockerfile and runs it, and tests/integration boots that image to confirm the app comes up with whatever services are registered. Both are a starting point you grow. Extend Infra with the real dependencies your services need (a database, a cache, a broker via testcontainers-go) and add tests that drive them. tests/ is protected by the update’s mechanical floor, so a framework update never overwrites your copy. The Docker runner already supports this: DEV_RUN_DIND runs on the host network, so testcontainers’ host-published ports are reachable from the test process.

Build outputs and runtime identity

make build uses the pinned Go build image, installs the static-build requirements inside that temporary container, and writes build/<module-tail> back with your host UID/GID. It injects three values with linker flags:

  • main.appName: the final segment of the module path; it sets the binary name and root Cobra command name;
  • main.buildCommit: the checked-out HEAD commit when available.
  • main.buildVersion: the exact Git tag at HEAD, or dev for an untagged source build.

cmd/main.go puts those into the global log scope as binary, commit, and version. Build output is therefore traceable without hardcoding identity in service code. A source tree without a resolvable Git HEAD builds, but has no commit field to inject.

make docker-build is a separate production-image path. Do not assume a Dockerfile build and make build have the same customization hooks; inspect the relevant script before changing either.

Dependencies and code generation

Use the Make targets so dependency metadata and vendor/ stay synchronized:

make dep
make pkg-add PKG=example.com/module@v1.2.3
make pkg-update PKG=example.com/module
make pkg-upgrade
make pkg-remove PKG=example.com/module

The framework commits Go’s vendor/ tree. Do not edit vendored sources by hand. make service already regenerates service registration; use make service-registration after manual service changes. Treat internal/pkg/services/services.gen.go as generated output.

Override, do not fork framework plumbing

Makefile includes Makefile.servicepack. Define the same target in your project Makefile to replace a framework target, or add your own targets next to it. Framework script lookup prefers scripts/make/<script>.sh over scripts/make/servicepack/<script>.sh.

cp scripts/make/servicepack/test.sh scripts/make/test.sh
# edit scripts/make/test.sh for project-specific behavior

Likewise, project Dockerfile, Dockerfile.dev, cmd/init.go, and cmd/commands.go are yours. The framework-owned versions are updated by make servicepack-update; see framework updates.

Before you hand off a change

For a normal code change, run the narrowest useful target first, then the relevant full check, for example make test, make lint, and make test-coverage when a framework behavior changes. The repository’s pre-commit hook calls make lint && make test-coverage, so it will repeat those checks during the project’s normal commit flow.

Read getting started for ownership boundaries and architecture for where a change should live.