| Tool | Minimum version | Purpose |
|---|---|---|
| Go | 1.25 | Build and test |
| Task | any | Taskfile automation (optional, used by some builders) |
govulncheck |
latest | Vulnerability scanning (devkit.VulnCheck) |
gitleaks |
any | Secret scanning (devkit.ScanSecrets) |
gocyclo |
any | External complexity tool (devkit.Complexity) |
| SSH access | — | Integration tests for ansible/ package |
Install optional tools:
go install golang.org/x/vuln/cmd/govulncheck@latest
go install github.com/zricethezav/gitleaks/v8@latest
go install github.com/fzipp/gocyclo/cmd/gocyclo@latestgo-devops depends on forge.lthn.ai/core/go (the parent framework). The go.mod replace directive resolves this locally:
replace forge.lthn.ai/core/go => ../core
The ../core path must exist relative to the go-devops checkout. If working in a Go workspace (go.work), add both modules:
go work init
go work use . ../core
Do not alter the replace directive path.
# Run all tests
go test ./...
# Run all tests with race detector
go test -race ./...
# Run a single test by name
go test -v -run TestName ./...
# Run tests in one package
go test ./ansible/...
# Static analysis
go vet ./...
# Check for vulnerabilities
govulncheck ./...
# View test coverage
go test -cover ./...
# Generate a coverage profile
go test -coverprofile=cover.out ./...
go tool cover -html=cover.outTests use _Good, _Bad, and _Ugly suffixes:
| Suffix | Meaning |
|---|---|
_Good |
Happy-path test; expected success |
_Bad |
Expected error condition; error must be returned |
_Ugly |
Panic, edge case, or degenerate input |
Example:
func TestParsePlaybook_Good(t *testing.T) { ... }
func TestParsePlaybook_Bad(t *testing.T) { ... }
func TestParsePlaybook_Ugly(t *testing.T) { ... }Use github.com/stretchr/testify. Prefer require over assert when subsequent assertions depend on the previous one passing:
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSomething_Good(t *testing.T) {
result, err := SomeFunction()
require.NoError(t, err)
assert.Equal(t, "expected", result.Field)
}Use net/http/httptest for API client tests. The infra/ tests demonstrate the pattern:
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"id": 1}`))
}))
defer srv.Close()
client := NewHCloudClient("token", WithHTTPClient(srv.Client()))The ansible/ package uses an sshRunner interface to decouple module implementations from real SSH connections. mock_ssh_test.go provides MockSSHClient with:
expectCommand(pattern, stdout, stderr, rc)— registers expected command patterns.hasExecuted(pattern)— asserts a command matching the pattern was called.hasExecutedMethod(method)— asserts a specific method (Run,RunScript,Upload) was called.- In-memory filesystem simulation for file operation tests.
Use MockSSHClient for all ansible/modules.go tests. Real SSH connections are not used in unit tests.
For devkit complexity tests, use AnalyseComplexitySource rather than writing temporary files:
src := `package foo
func Complex(x int) int {
if x > 0 { return x }
return -x
}`
results, err := AnalyseComplexitySource(src, "foo.go", 1)
require.NoError(t, err)Use t.TempDir() to create temporary directories for CoverageStore persistence tests:
dir := t.TempDir()
store := NewCoverageStore(filepath.Join(dir, "coverage.json"))All release/publishers/ tests use dryRun: true. No external services are called. Tests verify:
- Correct command-line argument construction.
- Correct file generation (formula text, manifest JSON, PKGBUILD content).
- Interface compliance: the publisher's
Name()is non-empty andPublishwith a nil config does not panic.
Use UK English in all documentation, comments, identifiers, log messages, and error strings:
- colour (not color)
- organisation (not organization)
- centre (not center)
- behaviour (not behavior)
- licence (noun, not license)
Every Go file must use strict typing. Avoid any at API boundaries where a concrete type is knowable. map[string]any is acceptable for Ansible task arguments and YAML-decoded data where the schema is dynamic.
Use the core.E helper from forge.lthn.ai/core/go for contextual errors:
return core.E("ansible.Executor.runTask", "failed to upload file", err)For packages that do not import core/go, use fmt.Errorf with %w:
return fmt.Errorf("infra.HCloudClient.ListServers: %w", err)Error strings must not be capitalised and must not end with punctuation (Go convention).
Three groups, each separated by a blank line:
- Standard library
forge.lthn.ai/core/...packages- Third-party packages
import (
"context"
"fmt"
"forge.lthn.ai/core/go/pkg/io"
"gopkg.in/yaml.v3"
"golang.org/x/crypto/ssh"
)Source files do not require a licence header comment beyond the package declaration. The devkit/ package uses a trailing // LEK-1 | lthn.ai | EUPL-1.2 comment; maintain this convention in devkit/ files only.
Define interfaces in the package that consumes them, not the package that implements them. The Builder, Publisher, Signer, Generator, Hypervisor, and ImageSource interfaces each live in the package that calls them.
All commits follow the Conventional Commits specification.
Format: type(scope): description
Scopes map to package names:
| Scope | Package |
|---|---|
ansible |
ansible/ |
build |
build/, build/builders/, build/signing/ |
container |
container/ |
devkit |
devkit/ |
devops |
devops/ |
infra |
infra/ |
release |
release/, release/publishers/ |
sdk |
sdk/, sdk/generators/ |
deploy |
deploy/ |
Examples:
feat(ansible): add docker_compose module support
fix(infra): handle nil Retry-After header in rate limiter
refactor(build): extract archive creation into separate function
test(devkit): expand coverage trending snapshot comparison tests
chore: update go.sum after dependency upgrade
Co-author line: every commit must include:
Co-Authored-By: Virgil <virgil@lethean.io>
All source files are licensed under the European Union Public Licence 1.2 (EUPL-1.2). Do not introduce dependencies with licences incompatible with EUPL-1.2. The github.com/kluctl/go-embed-python dependency (Apache 2.0) and golang.org/x/crypto (BSD-3-Clause) are compatible. Verify new dependencies before adding them.
- Remote:
ssh://git@forge.lthn.ai:2223/core/go-devops.git - Push:
git push forge main - HTTPS authentication is not supported on the Forge instance; SSH is required.
- Add the module name(s) to
KnownModulesintypes.go. - Implement a function
executeModuleName(ctx, ssh, args, vars) TaskResultinmodules.go. - Add a
case "modulename":branch in the dispatch switch inexecutor.go. - Add a shim to
mock_ssh_test.go'ssshRunnerinterface (if the module requires file operations, usesshFileRunner). - Write tests in
modules_*_test.gousing the mock infrastructure. Cover at minimum: success case, changed vs. unchanged, argument validation failure, and SSH error propagation.
- Create
release/publishers/myplatform.go. - Implement
Publisher:Name() string— return the platform name.Publish(ctx, release, pubCfg, relCfg, dryRun) error— whendryRunis true, log intent and return nil.
- Register the publisher in
release/config.goalongside existing publishers. - Write
release/publishers/myplatform_test.gowith dry-run tests. Follow the pattern of existing publisher tests: verify command arguments, generated file content, and interface compliance.
- Create
build/builders/mylang.go. - Implement
Builder:Name() stringDetect(fs io.Medium, dir string) (bool, error)— check for a marker file.Build(ctx, cfg, targets) ([]Artifact, error)
- Register the builder in
build/buildcmd/. - Write tests verifying
Detect(marker present/absent) andBuild(at minimum with a mockio.Medium).