CI/CD Architecture Comparison: Tekton, Drone/Gitness, Buildkite Agent#

Summary#

Three Go projects — Tekton Pipelines, Drone/Gitness, and the Buildkite Agent — each solve CI/CD execution with a fundamentally different architectural philosophy. Tekton delegates state to Kubernetes, making pipelines declarative CRDs in an operator model; Drone/Gitness builds a self-contained monolith combining git hosting and CI, using Redis Streams as an internal event bus; the Buildkite Agent is a thin executor that offloads orchestration to a SaaS backend and uses a two-process design where the job subprocess is isolated from the agent. These three designs encode three different answers to the same question: who owns state and where does execution happen?


Comparison dimensions#

Execution model: where does the job actually run?#

ProjectModelIsolation unitStep sequencing
TektonKubernetes Pod per TaskRunOne Pod per task, one container per stepFile semaphores via injected entrypoint binary
Drone/GitnessDocker container per step (via external drone-runner)Per-step Docker container; runner is separate processrunner-go runner manages sequential step execution
Buildkite AgentOS subprocess per jobSeparate bootstrap subprocess; entire job in one processSequential function calls (PluginPhaseCheckoutPhaseCommandPhase)

The contrast is sharpest in step sequencing. Tekton’s Kubernetes constraint — all Pod containers start simultaneously — forces a creative workaround: the entrypoint binary is injected into every step container and uses shared emptyDir volume files as semaphores to enforce ordering. This is architecturally clever but invisible to the container spec and surprising to newcomers. Drone delegates sequencing to the runner process (out-of-process). Buildkite’s Go Executor runs phases as simple sequential function calls — the most direct representation, because it owns the entire job lifecycle in a single process.

Architectural coupling: who owns state?#

ProjectState storageImplications
TektonKubernetes etcd (CRDs)Crash-safe, resumable; deeply Kubernetes-dependent
Drone/GitnessPostgreSQL/SQLite (primary) + Redis (events, livelog, cache)Self-contained; operable without Kubernetes; Redis adds external dependency
Buildkite AgentBuildkite SaaS API (remote)Agent is stateless; no local DB; SaaS is the source of truth

Tekton’s level-triggered reconciliation model turns state ownership into a strength: because all state is in Kubernetes objects, reconcilers can crash and restart at any time — the informer will re-enqueue the resource and reconciliation continues from where it left off. The cost is that Tekton is operationally meaningless outside a Kubernetes cluster.

Drone/Gitness makes the opposite bet: own your own state (PostgreSQL) and build the event bus on top of Redis Streams. This lets it run anywhere but requires the operator to manage two external services. The Redis Streams event bus is architecturally significant — domain boundaries (git push → CI trigger, PR created → webhook) are crossed by emitting to a stream, not by direct function calls. This decoupling is explicit code policy.

The Buildkite Agent has no state of its own. It registers, polls or streams for jobs, runs them in a subprocess, and reports back. The SaaS platform is the orchestrator. This is the cleanest separation of concerns for a commercial product, but it means the open-source component (the agent) cannot be understood in isolation.

Job acquisition: polling vs. streaming vs. operator reconciliation#

ProjectMechanismGo primitives
TektonKubernetes informer + work queueKnative controller.Impl, channel-backed work queue, goroutine pool
Drone/GitnessLong-poll HTTP (GET /rpc/v2/stage); in-process runner also pollsworkers map[*worker]struct{}, channel signals, ParallelWorkers cap
Buildkite AgentDual mode: HTTP ping loop + gRPC/ConnectRPC SSE stream; “baton” coordinates between themCustom baton primitive + debouncer goroutine, sync.Once for safe shutdown

Buildkite’s dual-mode job acquisition is the most sophisticated goroutine design in this group. The AgentWorker runs four concurrent loops (heartbeat, ping, streaming, action handler) coordinated by a project-specific baton struct. The baton is held by whichever acquisition mode is active; when the gRPC stream is unhealthy, the baton transfers to the HTTP poll loop. A debouncer goroutine sits between the streaming loop and the action handler to collapse redundant events — solving a real at-least-once delivery problem that would cause stale-state bugs without it. This is more inventive Go concurrency design than anything in Tekton or Drone, driven by the operational requirement to support two incompatible job-assignment protocols simultaneously.

Tekton’s job acquisition is the simplest to reason about: Kubernetes informers watch CRD resources and enqueue work items when state changes. The work queue is Knative infrastructure; Tekton contributors never had to write it. The payoff is that the reconciliation model is crash-safe by construction.

Dependency injection: three different philosophies#

ProjectDI approachVerificationTrade-offs
TektonKnative context injection (service locator)Runtime panic if missingImplicit; context becomes a grab-bag
Drone/GitnessGoogle Wire (compile-time codegen)Build failure if graph is brokenExplicit; wire_gen.go is 2000+ lines
Buildkite AgentManual constructor injectionNone (implicit)Readable; no tooling; appropriate at scale

These three approaches sit at different points on the explicitness spectrum. Tekton’s Knative injection (kubeclient.Get(ctx)) is a service locator — dependencies are pulled from context at construction time, making the dependency graph invisible to static analysis. It is idiomatic within the Knative ecosystem but unusual elsewhere. The test penalty is significant: setting up a Knative-injected context requires knowledge of Knative’s injection machinery.

Drone/Gitness at ~200 constructors and 120 WireSets is the most ambitious use of Google Wire in this comparison set. The compile-time guarantee — missing constructor = build error — is material at this scale. Wire’s wire.Build() acts as an architectural manifest: reading it gives the complete list of components in the system. The cost is wire_gen.go (the generated file), which no human would write by hand, and re-running wire on every new dependency.

Buildkite’s manual wiring is the right choice at its scale (~10 major components). agent_start.go is readable top-to-bottom; the construction sequence is obvious. The narrow APIClient interface in core/ is the single test seam. No tooling required.

Configuration: three distinct patterns#

ProjectMechanismHot reload?Config type
TektonKubernetes ConfigMaps (watched live) + CLI flagsYes (ConfigMaps)config.Store in context
Drone/GitnessEnvironment variables (envconfig) + optional .env fileNo (restart required)types.Config struct, Wire-injected
Buildkite AgentCLI flags > env vars > INI .cfg fileNoPer-command config structs

Tekton’s live ConfigMap reloading is the only hot-reload story among the three — a genuine operational advantage for tuning a long-running Kubernetes operator. The config.FromContext(ctx) API in reconcilers is an elegant read pattern, though it reinforces the context-as-grab-bag problem.

Drone/Gitness’s pure-environment-variable model is deliberately 12-factor: no config files to manage in production, container-native from day one. The Wire DI of narrow sub-configs (ProvideGitConfig, ProvideLockConfig) is an excellent pattern — each package receives only the fields it needs, preventing accidental cross-package config coupling.

Buildkite’s multi-source priority (CLI > env > INI > defaults) is the most user-friendly for a locally-installed agent. The config-to-env bridge pattern — translating AgentConfiguration fields to BUILDKITE_* env vars for the bootstrap subprocess, which then decodes them back into BootstrapConfig — is the most creative use of environment variables in this set: the OS environment becomes an IPC channel between the two processes.

Error handling: domain-shaped error types#

ProjectPrimary styleTyped errorsHTTP mapping
Tektonfmt.Errorf %w dominant; string-typed sentinels for step lifecycle signalsSkipError, ContextError, DebugBeforeStepErrorN/A (Kubernetes events)
Drone/GitnessCustom errors.Error struct with Status fieldStatusNotFound, StatusConflict, StatusUnauthorizedCentral AsStatus() mapping in handlers
Buildkite Agentfmt.Errorf %w; typed exit errorsExitError (with code), SilentExitError, ErrorResponseN/A (agent process)

The most interesting error type in this set is Tekton’s string-typed sentinel:

type SkipError string
func (s SkipError) Error() string { return string(s) }

A named type over string implements error and allows errors.Is matching without a struct. This is unusual — it achieves typed error discrimination while keeping the error message as the type’s value. The tradeoff is that changing the message string breaks equality.

Drone/Gitness has the most web-service-appropriate error model: errors.Error carries both a human-readable message and a machine-readable Status code. HTTP handlers call errors.AsStatus(err) to convert domain errors to HTTP status codes in a single central location, keeping controllers free of HTTP knowledge. The generic errors.IsType[T error](err) is a concise, type-safe alternative to errors.As.

Concurrency: shared idioms, distinct applications#

All three projects use:

  • context.Context pervasively (Tekton: 34 sync primitives; Drone: 6,807 context usages; Buildkite: 344 usages)
  • errgroup.WithContext for structured fork-join
  • Graceful shutdown via signal context (signal.NotifyContext or signals.NewContext)
  • sync.WaitGroup or errgroup for parallel workers

The divergence is in project-specific concurrency inventions:

Tekton: The Knative work queue is the entire concurrency story — no custom primitives needed. The most creative Go concurrency in Tekton is the entrypoint file-semaphore pattern (OS filesystem as a channel between containers), which is not Go concurrency at all.

Drone/Gitness: The in-memory/Redis dual-mode broker is the standout design. stream.MemoryBroker and stream.RedisBroker both implement Broker, selected at startup. This makes the event bus fully testable in-process (no Redis needed in unit tests) and production-ready (Redis for durability and multi-process fanout). The worker pool in app/pipeline/scheduler/queue.go uses a live map[*worker]struct{} rather than a fixed channel, accommodating dynamic runner registrations.

Buildkite: The baton primitive (mutual exclusion between streaming and polling job acquisition loops) and the debouncer goroutine are the most inventive Go concurrency designs in this set. The sync.Once-guarded done channel appears in multiple places (AgentWorker, kubernetes.Runner) to safely signal completion from multiple goroutines without risking a double-close panic. The re-jitter ticker that adjusts polling frequency based on a job’s observed run length to prevent thundering herd is a subtle operational concern elevated to code.

Extensibility: plugins and resolvers#

ProjectExtension mechanismExtension boundary
TektonResolution framework (CRD-based async protocol); Transformer functions for Pod mutationsTask/Pipeline definitions sourced from git, OCI bundles, cluster, HTTP
Drone/GitnessDocker containers as plugin steps (each step image is a plugin); event bus subscriptions for new domain servicesCI steps (Docker), webhooks, notification services
Buildkite AgentShell hook scripts (pre-checkout, pre-command, etc.); plugin system (Docker or code checkout); experiment flag systemAgent lifecycle hooks, per-job phases

Tekton’s resolution framework is the most Go-architecturally interesting extensibility story: new resolver types are added as separate controllers in cmd/resolvers, communicating with the main reconcilers via ResolutionRequest CRDs. This is an asynchronous protocol with crash safety and observability built in — but it is also more complex than a simple interface{} plugin.

Buildkite’s hook system is the most pragmatic: hooks are shell scripts executed at defined lifecycle points. The internal/job.Executor wraps every phase with before/after hook calls. No Go interface needed — shell scripts are the extension mechanism. This is maximally accessible to users (any language) but minimally structured.


Common patterns#

  1. Context-threaded cancellation. All three thread context.Context from the binary entry point down to subprocess/container execution. Cancellation is the universal shutdown signal.

  2. Graceful shutdown with timeout. All three implement graceful shutdown: finish the current job/reconcile cycle, then exit. Each uses a different implementation (Knative signals, errgroup with context, signal.Notify + WaitGroup) but the intent is the same.

  3. Table-driven tests. Tekton uses Knative’s reconcilertesting.TableTest; Drone uses 248 testCases patterns; Buildkite uses 139 t.Run table patterns. All three treat Go’s anonymous struct slice as the standard test matrix format.

  4. Small consumer-defined interfaces. All three define narrow interfaces at the consumer side (Tekton’s Requester, Drone’s store.RepoStore, Buildkite’s core.APIClient). No project exposes large god-interfaces.

  5. Retry with backoff. Tekton relies on Knative’s reconciler retry (requeueing); Drone uses Redis retry queues for events; Buildkite uses its own roko library with configurable strategies (constant, exponential, jitter).


Divergent choices#

Infrastructure coupling#

Tekton is only meaningful inside Kubernetes — it requires etcd (via CRDs), Kubernetes API server, and RBAC. Drone/Gitness runs anywhere with a PostgreSQL-compatible database and optionally Redis. The Buildkite Agent runs anywhere with network access to agent.buildkite.com. These represent a spectrum from maximally infrastructure-coupled to maximally infrastructure-agnostic.

DI philosophy#

The three DI approaches — Knative service locator via context, Google Wire compile-time codegen, manual constructor injection — each have legitimacy at different scales and team cultures. Wire’s wire_gen.go is machine-generated code that is always correct by construction; the Knative context pattern is idiomatic within its ecosystem but alien outside it; manual wiring is the right choice when the codebase is small enough to hold in one’s head.

Who draws the architectural boundary?#

Tekton draws its boundary at the Kubernetes API: everything is a CRD. Drone/Gitness draws its boundary at the Redis event bus: git hosting and CI are decoupled by events, not by process boundaries. Buildkite draws its boundary at the process: the agent and the bootstrap subprocess are completely separate, communicating only through environment variables and pipes.


Recommendations for practitioners#

Choose Tekton when: your organization is Kubernetes-native, you want pipelines as code that is stored in and managed by Kubernetes (GitOps), and you need the crash-safety of level-triggered reconciliation. Be prepared to operate it as an operator — CRDs, RBAC, admission webhooks, and Knative dependencies are all required.

Choose Drone/Gitness when: you need a self-hosted, all-in-one SCM + CI platform without Kubernetes. The Google Wire DI pattern at scale is worth studying — it demonstrates that compile-time dependency injection is tractable at real complexity levels. The dual-mode broker (in-memory/Redis) is an excellent pattern for any system that needs a testable event bus.

Choose the Buildkite Agent approach when: you want to separate orchestration (someone else’s problem) from execution (your agent’s job). The two-process architecture — agent process + bootstrap subprocess — is a strong isolation pattern. The baton/debouncer pattern for coordinating two competing job acquisition strategies is a real-world solution to at-least-once delivery with last-write-wins semantics.


Book angle#

This comparison illustrates that Go’s concurrency primitives are neutral tools — the same context, errgroup, sync.Once, and channels appear across radically different architectures. The interesting choices are at a higher level: who owns state, where does execution happen, and how are components wired together.

Tekton shows what happens when you fully commit to a framework (Knative): you inherit production-grade infrastructure at the cost of deep coupling. Drone shows that Google Wire is viable at 120-wiresets scale — compile-time DI is not just for small projects. Buildkite shows the most creative custom primitives (baton, debouncer) because its coordination problem (two competing acquisition modes with at-least-once delivery) doesn’t map cleanly onto standard Go patterns.

The deepest lesson: the hardest architectural decisions in CI/CD are not about Go at all — they are about where state lives, who owns the job lifecycle, and how extensions are added. Go’s patterns fall into place once those decisions are made.