Development
Project structure, coding conventions, testing, and release process for contributors.
Project structure
cmd/
deeplo/ main.go — single binary entry point (daemon + CLI)
internal/
bootstrap/ Bootstrap env config, config source resolution
build/ Build-time version string and shared logger
cli/ CLI command implementations (cobra commands)
compose/ Docker Compose executor: upload, preflight, deploy, ps
config/ YAML config types, loader, defaults, validation
daemon/ Runtime wiring for the long-running daemon
engine/ Deploy orchestration and event handling
gitrepo/ Git mirror management, file extraction, SSH env helpers
planner/ Maps repo events to (project, host) deploy targets
poller/ Per-repo goroutines that poll git remotes
runner/ Worker pool with global + per-host concurrency limits
runlog/ Per-deploy plain-text run logs and HTTP serving
state/ File-backed state: JSON snapshots, deployment records
transport/ Transport interface
ssh/ SSH client: upload files, run commands
util/ Small shared helpers
webhook/
github/ HMAC validation, push event parsing
internal/testutil/ In-process SSH server for integration tests
examples/
config/ Annotated example config
website/ Fumadocs documentation siteLocal development
Prerequisites: Go 1.25+, Git, Make
make build # compile deeplo binary into bin/
make test # run all tests
make vet # go vet
make fmt # go fmt
make check # build + validate examples/config/example.ymlBuild with version string:
make build VERSION=v1.2.3Run integration tests (requires Docker):
make integration-testIntegration tests start a real Docker daemon and test the compose executor end-to-end.
Testing
Tests use the standard library testing package. No test framework.
Conventions:
- Table-driven tests:
[]struct{ name string; ... }witht.Run(tc.name, ...) - Temp directories:
t.TempDir()— cleaned up automatically - No global state: functions receive their dependencies explicitly
- Avoid
time.Sleepin tests where possible; prefer channel-based synchronisation or real timeouts viaselect
Mocking: external dependencies (SSH, git) are abstracted behind interfaces or function types that tests replace with lightweight fakes. See poller_test.go for examples of injecting fake LSRemoteFunc and OpenIfExistsFunc.
Integration tests live alongside unit tests but are gated behind the integration build tag:
go test -tags integration ./internal/compose/...Architecture principles
Interfaces near consumers, not implementations. The transport package defines the Connection interface; transport/ssh implements it. The poller package defines RepoOpener; gitrepo.Repo satisfies it. This keeps packages decoupled and testable.
No import cycles. transport and state must not import config. Use local adapter types (e.g. transport.DialConfig) to break potential cycles.
No global state. Loggers and configs are passed explicitly. No init() side effects.
Atomic writes. State files are written to a temp file and then renamed. Never written directly. This prevents partial writes from corrupting state.
Panic on unrecoverable conditions. crypto/rand unavailability panics rather than silently producing weak IDs. Reserve panic for conditions that indicate a broken environment, not runtime errors.
Adding a new feature
-
Read the relevant packages first. Understand the existing abstractions before adding new ones.
-
Add to existing packages where appropriate. Don't create a new package for one function.
-
Keep config changes backward-compatible where possible. New fields should have sensible defaults applied in
applyDefaults(). -
Add validation for new config fields in
validate.go. Follow the existing pattern: collect all issues, separate errors from warnings. -
Write table-driven tests for any logic that has multiple code paths.
-
Document new config fields in the docs site.
Release
make release # cross-compile: linux/darwin × amd64/arm64Outputs land in bin/ named deeplo_<os>_<arch>.
The Docker image uses a multi-stage build:
- Builder:
golang:1.25-alpine— compiles the binary - Final:
alpine:3— addsgit,openssh-client, andca-certificates; default command isdeeplo daemon
docker build -t deeplo:latest .Known limitations
-
No rollback command. Push a prior commit to trigger a redeploy with the old files.
-
No history pruning.
history/runs/accumulates one JSON file per deploy indefinitely. A cleanup command or TTL-based rotation doesn't exist yet. -
Single global SSH key. All hosts use the same
DEEPLO_SSH_KEY_FILE. Per-host key overrides are not supported.