線路 senro, railway track

A pipeline engine, defined in Go.

Write your pipeline as ordinary Go code. Run it locally, in containers, on Kubernetes or over SSH, and watch it live from a second terminal while it works.

go get github.com/xavidop/senro

A real run: test and lint start together, build waits for both. Here it hits a dropped connection, retries once, and recovers before deploy runs.

Why

YAML has none of the tools you'd want for programming.

A dependency graph, retries, conditional branching, failure recovery: that's real programming, asked for in a format with no functions, no types, no tests, and no debugger. Teams end up either fighting the YAML or routing around it with shell scripts the YAML merely calls, which just moves the real logic out of the tool meant to run it.

senro is a pipeline engine first. CI/CD is the obvious first thing to build with it, not the limit of what it's for.

senro starts from the other end: the pipeline is the Go program. You write the graph, build it into a plan, and hand that plan to senro to run. Your code never runs a step directly, which is exactly what lets a second process (an attached terminal, in particular) watch, retry into, and replay the exact thing that's running. You get a real language's tooling, compiler, types, go test, a debugger, for the code that decides whether your build ships.

Quick start

Two steps, one retry rule.

exec.Command runs any command exactly as written: no shell, no surprises. Add Needs to order steps, and Retry(3, retry.OnInfra()) to retry only a dropped connection or the like, never a failing test.

Full walkthrough →
p := senro.New("ci")

verify := p.Workflow("verify")
verify.Step("test", exec.Command("go", "test", "./..."))
verify.Step("build", exec.Command("go", "build", "./...")).
	Needs("test").
	Retry(3, retry.OnInfra())

senro.Run(ctx, p)

Features

Everything a pipeline needs, in the box.

Each card is one docs page deep. All of it ships in this build; the docs keep an honest list of what does not, so nothing here is aspirational.

Four executors

Run a workflow locally, in a container on your Docker daemon, as one pod per step on Kubernetes, or on a remote host over your own SSH config. One option picks it, per workflow.

Kubernetes & SSH →

Go functions as steps

senro.Func runs a registered, typed Go function with the same retries, conditions and caching a command gets: on the coordinator, on an SSH host, or in a container.

Func steps →

Fan-out for monorepos

Expand builds one step per module over eight unit graphs, with per-unit edges, duration-balanced shards, and runs narrowed to what a change actually affects.

Fan-out →

A cache a fleet can share

Snapshotted workspaces, an opt-in Pure() action cache, and a shared tier over an S3 bucket or an OCI registry, so a fresh runner starts warm. senro verify audits purity claims.

Shared cache →

Secrets stay out of logs

A secret arrives as a file, never argv or an environment value. Output is redacted, and channels nothing could clean up afterwards are refused before anything runs.

Secrets →

Triggers without a mirror

The pipeline binary is its own matcher: hand it a GitHub, GitLab, Bitbucket or Gitea event and it decides to run, not run (exit 78), or error. No config duplicating what the code declares.

Triggers →

Watch and steer a live run

Attach a terminal UI to a pipeline while it runs. Focus a step, read its log, retry it, skip it, pause the run, set a breakpoint, or open a shell inside the step's own sandbox.

Attach →

A browser view too

senro ui serves a live run on loopback from a one-time link. The page is Go compiled to WebAssembly, folding the same events as the TUI, with the same controls.

Browser UI →

Retries that know what broke

retry.OnInfra() retries a dropped connection or a registry hiccup, and never a failing test. Retrying a real failure until it passes only deletes what the test told you.

Retries →

Real outcomes, not pass or fail

A step ends in one of ten states. One that needed three tries is recovered, not succeeded, so flaky infrastructure cannot hide inside a green build.

Step states →

Notifications and traces

Slack, GitHub Checks, webhooks, or a destination you write against one small interface; OpenTelemetry-style traces exported straight from the event stream.

Notifications →

Failure analysis, gated

Hand a failed step to an analyzer you wrote, with any model behind it. Its proposal applies only when a person or an explicit policy accepts it, never on its own.

Analyzers →

See it running

A pipeline you can watch, not just launch.

One line before senro.Run lets a second terminal attach while the run is happening. A pipeline that never adds it pays nothing: no open port, no background process.

Read the attach guide →
$ senro attach
ci  run 20260807T101503-a1b2c3
┌─ steps ──────────────┐ ┌─ build ──────────────────────┐
│  test      succeeded│ │ go build ./...               │
│  build     running  │ │ #cgo CFLAGS: -O2             │
│                      │ │ compiling ./cmd/senro ...    │
└──────────────────────┘ └──────────────────────────────┘
 enter focus · r retry · c cancel · q detach

failedrunning / retryingsucceededcached / skipped

How it's built

Four decisions everything else follows from.

1

Write the pipeline, run the pipeline

Build a plan from ordinary Go code, then run it. Your code describes the work; senro is the one thing that actually executes it, so a second process can always watch what's happening.

2

One timeline, always accurate

Every step starting, finishing, or retrying shows up on one timeline. The terminal UI, a finished run you reopen later, and anything else that watches all read that same timeline, so they never disagree about what happened.

3

Two kinds of step, both running today

Wrap any command in exec.Command and it runs on any of the four executors. Register a Go function and senro.Func runs it, with the same retries, conditions and caching a command step gets, on the coordinator or on an SSH host, where senro stages a copy of this binary and re-enters it there.

4

Content-addressed, and shareable

Mark a step Pure() and declare its inputs, and a re-run with nothing changed skips it entirely, restored from the content store. Point SENRO_REMOTE_CACHE at an S3-compatible bucket or an OCI registry and a fresh CI runner starts warm on what another machine already built.

Read the docs, or go straight to the source.