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.