YAML Pipeline Runner

A CI/CD pipeline runner that loads a pipeline definition from a YAML file, executes steps sequentially as subprocesses with timeout enforcement, stores build artifacts, and reports status via webhooks. It exits with a non-zero code on failure for CI integration.

// Run: go run . pipeline.yaml ./my-project
package main

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "os"
    "os/exec"
    "path/filepath"
    "time"

    "gopkg.in/yaml.v3"
)

// Pipeline defines a CI/CD pipeline loaded from YAML
type Pipeline struct {
    Name      string   `yaml:"name"`
    Steps     []Step   `yaml:"steps"`
    Artifacts []string `yaml:"artifacts"`
}

// Step is a single command to execute in the pipeline
type Step struct {
    Name    string        `yaml:"name"`
    Command string        `yaml:"command"`
    Timeout time.Duration `yaml:"timeout"`
}

// StepResult holds the outcome of executing a single step
type StepResult struct {
    Name     string        `json:"name"`
    ExitCode int           `json:"exit_code"`
    Duration time.Duration `json:"duration"`
    Err      string        `json:"error,omitempty"`
}

// BuildStatus is the final report for the entire pipeline run
type BuildStatus struct {
    Pipeline string        `json:"pipeline"`
    RunID    string        `json:"run_id"`
    Status   string        `json:"status"`
    Steps    []StepResult  `json:"steps"`
    Duration time.Duration `json:"duration"`
}

// ArtifactStore persists build artifacts to a local directory
type ArtifactStore struct {
    BaseDir string
}

func (s *ArtifactStore) Store(runID string, patterns []string, workDir string) ([]string, error) {
    dest := filepath.Join(s.BaseDir, runID)
    if err := os.MkdirAll(dest, 0o755); err != nil {
        return nil, err
    }

    var stored []string
    for _, pattern := range patterns {
        matches, _ := filepath.Glob(filepath.Join(workDir, pattern))
        for _, m := range matches {
            data, err := os.ReadFile(m)
            if err != nil {
                continue
            }
            target := filepath.Join(dest, filepath.Base(m))
            if err := os.WriteFile(target, data, 0o644); err != nil {
                continue
            }
            stored = append(stored, target)
        }
    }
    return stored, nil
}

// WebhookReporter sends build status to an HTTP endpoint
type WebhookReporter struct {
    URL    string
    Client *http.Client
}

func (r *WebhookReporter) Report(status BuildStatus) error {
    data, err := json.Marshal(status)
    if err != nil {
        return err
    }
    resp, err := r.Client.Post(r.URL, "application/json", bytes.NewReader(data))
    if err != nil {
        return err
    }
    resp.Body.Close()
    if resp.StatusCode >= 400 {
        return fmt.Errorf("webhook returned %d", resp.StatusCode)
    }
    return nil
}

// ExecuteStep runs a single pipeline step as a subprocess with timeout
func ExecuteStep(ctx context.Context, step Step, workDir string) StepResult {
    timeout := step.Timeout
    if timeout == 0 {
        timeout = 5 * time.Minute
    }

    stepCtx, cancel := context.WithTimeout(ctx, timeout)
    defer cancel()

    start := time.Now()
    cmd := exec.CommandContext(stepCtx, "sh", "-c", step.Command)
    cmd.Dir = workDir
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr

    err := cmd.Run()
    duration := time.Since(start)

    result := StepResult{Name: step.Name, Duration: duration}
    if err != nil {
        result.ExitCode = 1
        result.Err = err.Error()
        if exitErr, ok := err.(*exec.ExitError); ok {
            result.ExitCode = exitErr.ExitCode()
        }
    }
    return result
}

func main() {
    if len(os.Args) < 2 {
        log.Fatal("usage: runner <pipeline.yaml> [work-dir]")
    }

    pipelineFile := os.Args[1]
    workDir := "."
    if len(os.Args) > 2 {
        workDir = os.Args[2]
    }

    // Parse pipeline
    data, err := os.ReadFile(pipelineFile)
    if err != nil {
        log.Fatalf("read pipeline: %v", err)
    }

    var p Pipeline
    if err := yaml.Unmarshal(data, &p); err != nil {
        log.Fatalf("parse pipeline: %v", err)
    }

    runID := fmt.Sprintf("%s-%d", p.Name, time.Now().Unix())
    log.Printf("Starting pipeline %q (run: %s)", p.Name, runID)

    // Execute steps
    ctx := context.Background()
    var results []StepResult
    overallStatus := "success"
    start := time.Now()

    for _, step := range p.Steps {
        log.Printf("  → Running step: %s", step.Name)
        result := ExecuteStep(ctx, step, workDir)
        results = append(results, result)

        log.Printf("    Exit: %d | Duration: %s", result.ExitCode, result.Duration)
        if result.ExitCode != 0 {
            log.Printf("    FAILED: %s", result.Err)
            overallStatus = "failed"
            break // Stop on first failure
        }
    }

    // Store artifacts on success
    if overallStatus == "success" && len(p.Artifacts) > 0 {
        store := &ArtifactStore{BaseDir: "/tmp/ci-artifacts"}
        stored, err := store.Store(runID, p.Artifacts, workDir)
        if err != nil {
            log.Printf("  Artifact storage failed: %v", err)
        } else {
            log.Printf("  Stored %d artifacts", len(stored))
        }
    }

    // Report status
    status := BuildStatus{
        Pipeline: p.Name,
        RunID:    runID,
        Status:   overallStatus,
        Steps:    results,
        Duration: time.Since(start),
    }

    webhookURL := os.Getenv("WEBHOOK_URL")
    if webhookURL != "" {
        reporter := &WebhookReporter{
            URL:    webhookURL,
            Client: &http.Client{Timeout: 10 * time.Second},
        }
        if err := reporter.Report(status); err != nil {
            log.Printf("  Webhook failed: %v", err)
        }
    }

    log.Printf("Pipeline %s: %s (%s)", p.Name, overallStatus, time.Since(start))
    if overallStatus == "failed" {
        os.Exit(1)
    }
}

This gives you a fully functional pipeline runner in under 200 lines. Production runners add features like parallel step groups, caching layers, container-based isolation (using Docker API), and retry logic, all following the same foundational pattern.

💻 Run locally

Copy the code above and run it on your machine

© 2026 ByteLearn.dev. Free courses for developers. · Privacy