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: 10sThe runner stops at the first failing step and exits with code 1, which CI systems use to mark the build as failed.