Updated Jul 20, 2026

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:

  1. Poll for work (or receive a webhook trigger)
  2. Parse the pipeline definition (YAML, JSON, or DSL)
  3. Execute steps sequentially or in parallel as subprocesses
  4. Capture output, exit codes, and artifacts
  5. 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/app

Step 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/exec with 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?

💻 Examples

Complete examples for this lesson. Copy and run locally.

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

Spot something off? Report an issue
© 2026 ByteLearn.dev. Free courses for developers. · Privacy