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.

Setup

mkdir yaml-pipeline-runner
cd yaml-pipeline-runner
go mod init github.com/yourorg/yaml-pipeline-runner
go get gopkg.in/yaml.v3
// Run: go run . pipeline.yaml ./my-project
package main

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "os"
    "os/exec"
    "path/filepath"
    "syscall"
    "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.
// Timeout is a duration string like "30s" or "2m"; defaults to 5m if empty.
type Step struct {
    Name    string `yaml:"name"`
    Command string `yaml:"command"`
    Timeout string `yaml:"timeout"` // e.g. "30s", "2m"
}

// 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
}

// Store copies files matching patterns from workDir into BaseDir/runID.
// Returns paths of successfully stored files.
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, err := filepath.Glob(filepath.Join(workDir, pattern))
        if err != nil {
            return nil, fmt.Errorf("glob %s: %w", pattern, err)
        }
        for _, m := range matches {
            data, err := os.ReadFile(m)
            if err != nil {
                return nil, fmt.Errorf("read %s: %w", m, err)
            }
            target := filepath.Join(dest, filepath.Base(m))
            if err := os.WriteFile(target, data, 0o644); err != nil {
                return nil, fmt.Errorf("write %s: %w", target, err)
            }
            stored = append(stored, target)
        }
    }
    return stored, nil
}

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

// Report POSTs the build status as JSON to r.URL. Returns an error if the
// request fails or the server responds with a 4xx/5xx status.
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
    }
    defer resp.Body.Close()
    if resp.StatusCode >= 400 {
        return fmt.Errorf("webhook returned %d", resp.StatusCode)
    }
    return nil
}

// killGroup kills the entire process group to avoid orphaned child processes.
// Use after cmd.Run() returns an error caused by context cancellation.
func killGroup(cmd *exec.Cmd) {
    if cmd.Process != nil {
        syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
    }
}

// ExecuteStep runs step.Command via sh -c in workDir, enforcing step.Timeout (default 5m).
// Always returns a StepResult; a non-zero ExitCode indicates failure.
func ExecuteStep(ctx context.Context, step Step, workDir string) StepResult {
    timeout := 5 * time.Minute
    if step.Timeout != "" {
        if d, err := time.ParseDuration(step.Timeout); err == nil {
            timeout = d
        }
    }

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

    start := time.Now()
    cmd := exec.CommandContext(stepCtx, "sh", "-c", step.Command)
    cmd.Dir = workDir
    cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} // isolate process group
    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 {
        if stepCtx.Err() != nil {
            killGroup(cmd) // kill orphaned children on timeout/cancel
        }
        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]
    }

    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)

    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
        }
    }

    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))
        }
    }

    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)
    }
}

Running It

Create a sample pipeline file:

# pipeline.yaml
name: my-app
steps:
  - name: hello
    command: echo "Building my-app"
    timeout: 10s
  - name: test
    command: echo "All tests passed"
    timeout: 30s
artifacts: []

Run the pipeline against the current directory:

go run . pipeline.yaml .

Expected output:

2026/08/11 10:00:00 Starting pipeline "my-app" (run: my-app-1755043200)
  → Running step: hello
Building my-app
    Exit: 0 | Duration: 5ms
  → Running step: test
All tests passed
    Exit: 0 | Duration: 4ms
2026/08/11 10:00:00 Pipeline my-app: success (14ms)

To test failure handling, add a step that exits non-zero:

steps:
  - name: fail
    command: exit 1
    timeout: 10s

The runner stops at the first failing step and exits with code 1, which CI systems use to mark the build as failed.

💻 Run locally

Copy the code above and run it on your machine

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