13 - CI/CD Pipeline Tooling
📋 Jump to Takeaways🎁 GitHub Actions runs your pipeline, but what actually runs GitHub Actions? What if you could build the runner itself in under 200 lines of Go?
CI/CD systems like GitHub Actions, GitLab CI, and Jenkins all share a common architecture: a runner process that picks up pipeline definitions, executes steps in isolation, collects artifacts, and reports status. Go's strong concurrency model, subprocess management, and cross-compilation make it ideal for building pipeline tooling. This lesson builds a mini CI pipeline runner from scratch.
Imports in code snippets are trimmed for brevity. See the full example linked at the end for complete, compilable source.
Anatomy of a CI Runner
Every CI runner follows the same loop:
- Poll for work (or receive a webhook trigger)
- Parse the pipeline definition (YAML, JSON, or DSL)
- Execute steps sequentially or in parallel as subprocesses
- Capture output, exit codes, and artifacts
- Report status back to the orchestrator or external system
The runner itself is stateless. Pipeline definitions declare everything. This is why CI systems are so portable: the runner is just an execution engine.
Pipeline Definition as a Go Struct
Define your pipeline schema as Go structs with YAML tags:
package pipeline
import "time"
type Pipeline struct {
Name string `yaml:"name"`
Trigger string `yaml:"trigger"`
Steps []Step `yaml:"steps"`
Artifacts []string `yaml:"artifacts"`
}
type Step struct {
Name string `yaml:"name"`
Command string `yaml:"command"`
Timeout string `yaml:"timeout"` // "30s", "2m", parsed with time.ParseDuration
Env []string `yaml:"env"`
}A corresponding YAML pipeline file:
name: build-and-test
trigger: push
steps:
- name: lint
command: golangci-lint run ./...
timeout: 2m
- name: test
command: go test -v -cover ./...
timeout: 5m
- name: build
command: go build -o dist/app ./cmd/server
timeout: 3m
artifacts:
- dist/appStep Execution and Isolation
Each step runs as an isolated subprocess using os/exec. This provides process-level isolation. A failing step doesn't crash the runner:
package pipeline
import (
"bytes"
"context"
"fmt"
"os/exec"
"time"
)
type StepResult struct {
Name string
ExitCode int
Output string
Duration time.Duration
Err error
}
func ExecuteStep(ctx context.Context, step Step, workDir string) StepResult {
start := time.Now()
timeout := 5 * time.Minute
if step.Timeout != "" {
if d, err := time.ParseDuration(step.Timeout); err == nil {
timeout = d
}
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd := exec.CommandContext(ctx, "sh", "-c", step.Command)
cmd.Dir = workDir
cmd.Env = append(os.Environ(), step.Env...)
var output bytes.Buffer
cmd.Stdout = &output
cmd.Stderr = &output
err := cmd.Run()
result := StepResult{
Name: step.Name,
Output: output.String(),
Duration: time.Since(start),
}
if err != nil {
result.Err = err
if exitErr, ok := err.(*exec.ExitError); ok {
result.ExitCode = exitErr.ExitCode()
} else {
result.ExitCode = -1
}
}
fmt.Printf("[%s] exit=%d duration=%v\n", result.Name, result.ExitCode, result.Duration)
// Output: [lint] exit=0 duration=1.23s
return result
}The context.WithTimeout ensures runaway commands are killed. The shell wrapper (sh -c) lets pipeline authors use pipes and redirects in their commands.
Artifact Management
After a successful build, artifacts need to be stored for downstream consumption. A simple artifact manager copies outputs to a structured directory:
package pipeline
import (
"fmt"
"io"
"os"
"path/filepath"
)
type ArtifactStore struct {
BaseDir string
}
func (s *ArtifactStore) Store(runID string, patterns []string, workDir string) ([]string, error) {
destDir := filepath.Join(s.BaseDir, runID)
if err := os.MkdirAll(destDir, 0755); err != nil {
return nil, fmt.Errorf("create artifact dir: %w", err)
}
var stored []string
for _, pattern := range patterns {
matches, err := filepath.Glob(filepath.Join(workDir, pattern))
if err != nil {
return nil, fmt.Errorf("glob %s: %w", pattern, err)
}
for _, src := range matches {
dest := filepath.Join(destDir, filepath.Base(src))
if err := copyFile(src, dest); err != nil {
return nil, fmt.Errorf("copy %s: %w", src, err)
}
fmt.Printf("Stored artifact: %s\n", dest)
// Output: Stored artifact: /tmp/ci-artifacts/run-42/app
stored = append(stored, dest)
}
}
return stored, nil
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
}Status Reporting and Webhooks
CI systems report build status to external services (GitHub commit status, Slack, etc.) via webhooks:
package pipeline
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"time"
)
type BuildStatus struct {
Pipeline string `json:"pipeline"`
RunID string `json:"run_id"`
Status string `json:"status"` // running, success, failed
Steps []StepResult `json:"steps"`
Duration time.Duration `json:"duration_ms"`
}
type WebhookReporter struct {
URL string
Client *http.Client
}
func (r *WebhookReporter) Report(status BuildStatus) error {
body, err := json.Marshal(status)
if err != nil {
return fmt.Errorf("marshal status: %w", err)
}
req, err := http.NewRequest(http.MethodPost, r.URL, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := r.Client.Do(req)
if err != nil {
return fmt.Errorf("send webhook: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("webhook returned %d", resp.StatusCode)
}
fmt.Printf("Webhook sent: %s (status %d)\n", r.URL, resp.StatusCode)
// Output: Webhook sent: https://hooks.slack.com/builds (status 200)
return nil
}Pipeline DSL in Go
For programmatic pipeline creation (useful in testing or dynamic pipelines), define a builder DSL:
package pipeline
func NewPipeline(name string) *Pipeline {
return &Pipeline{Name: name}
}
func (p *Pipeline) OnTrigger(trigger string) *Pipeline {
p.Trigger = trigger
return p
}
func (p *Pipeline) AddStep(name, command string) *Pipeline {
p.Steps = append(p.Steps, Step{Name: name, Command: command})
return p
}
func (p *Pipeline) WithArtifacts(patterns ...string) *Pipeline {
p.Artifacts = append(p.Artifacts, patterns...)
return p
}Usage:
p := NewPipeline("deploy").
OnTrigger("tag").
AddStep("build", "go build -o app .").
AddStep("package", "tar czf app.tar.gz app").
WithArtifacts("app.tar.gz")
fmt.Printf("Pipeline: %s (%d steps)\n", p.Name, len(p.Steps))
// Output: Pipeline: deploy (2 steps)YAML Pipeline Runner
A fully functional CI runner that loads a YAML pipeline definition, executes steps sequentially with timeout enforcement, stores build artifacts on success, and reports status via webhooks. It stops on the first failing step and exits with a non-zero code for CI integration.
Input: A YAML pipeline file path and an optional working directory (e.g., runner pipeline.yaml ./my-project). Optionally set WEBHOOK_URL env var for status reporting.
Output: Executes each step as a subprocess, logs exit codes and durations, stores artifacts to /tmp/ci-artifacts/<run-id>/, sends a JSON webhook on completion, and exits 0 on success or 1 on failure.
Full source: examples/yaml-pipeline-runner
Key Takeaways
- CI runners are execution loops: parse definition → run steps → collect artifacts → report status
- Use
os/execwith context timeouts for safe subprocess isolation - Model pipelines as Go structs with YAML tags for easy serialization
- Artifact management is just structured file copying with metadata
- Webhook reporters decouple the runner from notification concerns
- Go's builder pattern creates clean pipeline DSLs for programmatic use
- The entire runner pattern fits in under 200 lines. Production systems layer caching, parallelism, and container isolation on top
🎁 Your pipeline produces a tested binary. But how do you replace running software with new software without your users noticing?