Documentation
¶
Overview ¶
Package senro defines pipelines in Go and executes them locally, in containers, as Kubernetes pods, or on a remote host over SSH (senro.Local, container.Image, k8s.Pod, ssh.Host; anything else is refused by Build). It is a pipeline engine first: CI/CD is the most familiar thing to build on it, but data pipelines, batch jobs and release automation are equally in scope.
A pipeline is built as an immutable DAG, resolved into a plan, and executed by the engine; user code never drives execution. Every observable fact about a run is an event in an append-only stream, which realtime UI, attach, replay, re-run and audit all read. The wire contract lives in github.com/xavidop/senro/api, a package of this module that depends only on the standard library (enforced by api/nodeps_test.go).
Terminology ¶
The name is ç·šč·Ż (senro), railway track. The metaphor carries through the documentation and error messages: steps are stations, a workflow is a line, a resolved plan is a timetable. The API itself says Pipeline, Workflow, Step and Plan.
The three levels ¶
A pipeline holds workflows; a workflow holds steps:
p := senro.New("monorepo")
setup := p.Workflow("setup")
setup.Step("install", exec.Command("pnpm", "install"))
verify := p.Workflow("verify", senro.Needs("setup"))
verify.Step("test", exec.Command("pnpm", "test"))
A workflow carries Needs and On: groups of steps depend on other groups, and a group is targeted at one executor. Steps carry their own, finer-grained Needs within a workflow.
Index ¶
- Constants
- Variables
- func RegisterFunc[P any](name string, fn func(Ctx, P) error)
- func Run(ctx context.Context, pipe *Pipeline, opts ...Option) error
- func RunPlan(ctx context.Context, p *Plan, opts ...Option) error
- func RunSubgraph(ctx Ctx, f *Fragment) error
- func StepChild(ctx context.Context) (handled bool, err error)
- type Action
- type AnalyzeOption
- type Analyzer
- type AnalyzerFunc
- type Appender
- type ChangeSource
- type Condition
- type Ctx
- type DurationHistory
- type ExecutorSpec
- type ExecutorTarget
- type ExpandBuilder
- func (e *ExpandBuilder) Affected(src ChangeSource) *ExpandBuilder
- func (e *ExpandBuilder) MaxNodes(n int) *ExpandBuilder
- func (e *ExpandBuilder) MaxParallel(n int) *ExpandBuilder
- func (e *ExpandBuilder) Needs(ids ...string) *ExpandBuilder
- func (e *ExpandBuilder) NeedsEach(expansions ...string) *ExpandBuilder
- func (e *ExpandBuilder) Partition(n int, h DurationHistory) *ExpandBuilder
- func (e *ExpandBuilder) Template(fn func(Unit) *StepBuilder) *ExpandBuilder
- func (e *ExpandBuilder) TemplateShard(fn func(Shard) *StepBuilder) *ExpandBuilder
- func (e *ExpandBuilder) When(c Condition) *ExpandBuilder
- type Flusher
- type Fragment
- type GenCtx
- type GenFunc
- type Generator
- type Mount
- type MountMode
- type Option
- func WithAnalyzer(a Analyzer, opts ...AnalyzeOption) Option
- func WithAttach(att *attach.Attach) Option
- func WithCacheDir(dir string) Option
- func WithDir(dir string) Option
- func WithFuncBuild(pkg string) Option
- func WithLocalClass(class string) Option
- func WithMaxDepth(n int) Option
- func WithMaxNodes(n int) Option
- func WithOnlySteps(ids ...string) Option
- func WithParams(p Params) Option
- func WithRegenerate() Option
- func WithRemoteCache(rc RemoteCache) Option
- func WithRunID(id string) Option
- func WithSecrets(cfg any) Option
- func WithSink(s Sink) Option
- func WithTraceContext(traceparent, tracestate string) Option
- func WithTrigger(ev *trigger.Event, ts ...trigger.Trigger) Option
- type Params
- type Pipeline
- type Plan
- type RegistryCache
- type RemoteCache
- type Reporter
- type RunError
- type RunErrorStep
- type RunManifest
- type ScopeKind
- type ScratchOption
- type ScratchRef
- type Shard
- type Sink
- type SinkFunc
- type StepBuilder
- func (s *StepBuilder) Always(handlers ...*StepBuilder) *StepBuilder
- func (s *StepBuilder) CacheEnv(names ...string) *StepBuilder
- func (s *StepBuilder) ContinueOnError() *StepBuilder
- func (s *StepBuilder) Env(key, value string) *StepBuilder
- func (s *StepBuilder) Generates(g Generator) *StepBuilder
- func (s *StepBuilder) ID() string
- func (s *StepBuilder) Inputs(sel ...artifact.Selector) *StepBuilder
- func (s *StepBuilder) Mount(m ...Mount) *StepBuilder
- func (s *StepBuilder) Needs(ids ...string) *StepBuilder
- func (s *StepBuilder) NoSnapshot() *StepBuilder
- func (s *StepBuilder) OnFailure(handlers ...*StepBuilder) *StepBuilder
- func (s *StepBuilder) Outputs(sel ...artifact.Selector) *StepBuilder
- func (s *StepBuilder) Pure() *StepBuilder
- func (s *StepBuilder) Retry(maxAttempts int, p retry.Predicate) *StepBuilder
- func (s *StepBuilder) RetryPolicy(policy retry.Policy) *StepBuilder
- func (s *StepBuilder) SecretEnv(envName, field string) *StepBuilder
- func (s *StepBuilder) Timeout(d time.Duration) *StepBuilder
- func (s *StepBuilder) When(c Condition) *StepBuilder
- func (s *StepBuilder) WorkDir(dir string) *StepBuilder
- type StepFailure
- type TriggerRecord
- type Unit
- type UnitAffector
- type UnitGraph
- type WorkflowBuilder
- type WorkflowOption
- type WorkspaceOption
- type WorkspacePath
- type WorkspaceRef
Constants ¶
const ( // EnvRemoteCache turns the shared cache on and says where it is, which is // also what chooses the backend: "s3://<bucket>" or "s3://<bucket>/<prefix>" // for a bucket, "oci://<registry>/<repository>" for a registry. Unset means // no shared cache, which is the default and is not an error. EnvRemoteCache = remotecache.EnvTarget // EnvRemoteCacheEndpoint is the bucket store's URL. EnvRemoteCacheEndpoint = remotecache.EnvEndpoint // EnvRemoteCacheRegion scopes the bucket request signature. EnvRemoteCacheRegion = remotecache.EnvRegion // EnvRemoteCachePathStyle overrides the bucket addressing style; see // RemoteCache.PathStyle. Unset means "work it out from the endpoint". EnvRemoteCachePathStyle = remotecache.EnvPathStyle // EnvRemoteCacheUsername and EnvRemoteCachePassword are the credential a // registry target presents to the registry's token endpoint. Unset means // anonymous, which works against a registry that demands nothing. EnvRemoteCacheUsername = remotecache.EnvUsername EnvRemoteCachePassword = remotecache.EnvPassword // EnvRemoteCachePlainHTTP talks to the registry over http rather than // https. EnvRemoteCachePlainHTTP = remotecache.EnvPlainHTTP // EnvRemoteCacheTimeout bounds one request, as a Go duration ("45s"). EnvRemoteCacheTimeout = remotecache.EnvTimeout // EnvRemoteCacheReadOnly makes the run read the cache and never write it. EnvRemoteCacheReadOnly = remotecache.EnvReadOnly // EnvRemoteScratch shares scratch caches through the bucket too. Off by // default, and ignored by the registry backend; see RemoteCache.Scratch. EnvRemoteScratch = remotecache.EnvScratch )
Environment variables RemoteCacheFromEnv reads. A bucket's credentials come from the standard AWS names as well: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_SESSION_TOKEN.
const DefaultAnalyzeGrace = 10 * time.Second
DefaultAnalyzeGrace bounds how long a run waits at shutdown for answers that have not arrived yet. Without the wait, the last failure of a run, usually the interesting one, would be the one whose explanation never landed in the ledger. It bounds SHUTDOWN and nothing else: no scheduling decision ever waits on an analyzer.
const DefaultAnalyzeTimeout = 30 * time.Second
DefaultAnalyzeTimeout bounds one call to Analyze. Long enough for a model to answer, short enough that a hung one is not the reason a queue backs up. Override with AnalyzeTimeout.
const ManifestFile = "run.json"
ManifestFile is the name of a run's manifest inside its run directory.
Variables ¶
var ErrNoAffectedSet = unit.ErrNoAffectedSet
ErrNoAffectedSet reports that ExpandBuilder.Affected was used over a graph that does not implement UnitAffector. Build wraps it, so errors.Is can tell "this graph cannot narrow" apart from "narrowing failed".
Functions ¶
func RegisterFunc ¶
RegisterFunc registers a Go function as a step kind, under a stable name:
type DeployParams struct {
App string `json:"app"`
Namespace string `json:"namespace"`
}
func init() { senro.RegisterFunc("deploy/helm", HelmUpgrade) }
func HelmUpgrade(ctx senro.Ctx, p DeployParams) error {
kubeconfig := ctx.Secret("kubeconfig")
chart, _ := ctx.Workspace("charts")
return helm.Upgrade(ctx, p.App, chart.Path("apps", p.App), kubeconfig)
}
The name is API: it is the step's cache key, its name in plan.json, and its address for `senro rerun --step`, which a closure's identity could be none of. Changing it invalidates the cache for every step that used it and breaks any recorded plan that names it, exactly as renaming a command would. Registering the same name twice panics.
P must be JSON-serializable and is decoded strictly: a recorded parameter field that P does not have is an error, not a silent zero value.
A func step runs on every executor: the coordinator, an ssh host, a container and a pod. Its body is compiled into this binary and no plan can describe it, so running it elsewhere means putting THIS BINARY over there and re-entering it as a step child: senro ships its own executable when the target's platform matches the coordinator's, and cross-compiles (CGO_ENABLED=0, -tags netgo,osusergo) when it does not. Over ssh the binary is transferred once per host; in a container it is bind-mounted read-only; in a pod it is sent over the apiserver's exec subresource once per pod, and the image must carry sh and tar for it. See WithFuncBuild and `senro func check`.
func Run ¶
Run builds pipe and executes the result to completion, with no Build() visible at the call site: `senro.Run(ctx, pipeline(cfg), senro.WithSecrets(cfg), ...)`.
Build runs first, before anything here touches disk, and its error is returned unwrapped and never as a *RunError: a pipeline that failed to build produced no run, so there is no status to report; see TestRunReturnsTheBuildErrorDirectlyForAnInvalidPipeline.
A caller that already holds a *Plan calls RunPlan instead. Building the same *Pipeline twice is not guaranteed to reproduce an already-inspected plan: a *StepBuilder can still be mutated after Build returns, and a second Build picks up whatever was added since. RunPlan takes the resolved Plan itself, so nothing added afterward can reach the run it executes.
With no options, Run costs exactly what the engine costs: no attach server, no extra goroutines (TestRunWithNoOptionsStartsNoGoroutines proves it by counting). A run directory and ID are still generated under runs/<id> via attach.NewRunID, so an option-less Run still produces a real, inspectable run on disk.
The DEFAULT executor is local (internal/executor/localexec). A workflow targeted with On runs on the executor buildExecutors constructs, one per distinct target the plan names; resolving it here from the plan keeps Option additive rather than making every caller name an executor.
func RunPlan ¶
RunPlan executes an already-built plan directly, without calling Build: the EXACT plan a caller already validated or inspected, not a plan re-resolved from whatever a *Pipeline looks like now. See Run.
The run's first event names no pipeline: a plan carries no name, and run.started asserting one this call was never given would be a guess. A caller who wants the ledger to carry a name has Run.
func RunSubgraph ¶ added in v1.1.0
RunSubgraph runs f as a nested graph, from inside a registered function, and returns when it has finished.
This is the imperative escape hatch. Some control flow genuinely is not a DAG: "roll out to clusters one at a time until quorum, then stop" cannot be drawn as one, because whether the next node runs depends on what the previous ones did. A function can express that directly, calling this once per iteration.
senro.RegisterFunc("deploy/rolling", func(ctx senro.Ctx, p RollParams) error {
for _, c := range p.Clusters {
if err := senro.RunSubgraph(ctx, deployFragment(c)); err != nil {
return err
}
if quorumReached(ctx) {
return nil
}
}
return errNoQuorum
})
Prefer a GENERATOR (Generates) for anything that is a graph. A generator's nodes are ordinary steps: individually cached, individually retried, individually visible. A subgraph is work this step is doing, so the cache and re-run granularity is the WHOLE subgraph and re-running means re-running the step. That is the price of expressing a loop with a stopping condition.
A free function rather than a method on Ctx because not every Ctx can offer it: a func step running on a remote host is a staged binary on the far side of a transport, and the engine a subgraph needs is back on the coordinator. Called from there, this returns an error saying so by name.
func StepChild ¶
StepChild runs this process as a remote step child, if that is what a coordinator launched it as, and reports whether it did.
Run and RunPlan call it first, so an ordinary pipeline needs nothing:
func main() {
if err := senro.Run(context.Background(), pipeline()); err != nil {
log.Fatal(err)
}
}
really does run a func step on an ssh host, with no line about re-entry anywhere in it.
Call it yourself when main does something before Run and might not get there: a main that parses flags with a package that exits on an unrecognised one never reaches Run when launched as `<binary> __step --state-fd 0`. Calling this first is the fix:
func main() {
if handled, err := senro.StepChild(context.Background()); handled {
if err != nil { log.Fatal(err) }
return
}
flag.Parse()
...
}
handled is false, instantly and with no side effect, for every ordinary invocation: it is decided by os.Args[1] alone. Deliberately no environment variable: a marker in the environment is inherited by every child process a step launches, and a pipeline that ran itself would re-enter as a step child of a step.
The error it returns is the CHILD's failure, never the step's: a function's error, panic or timeout is a verdict that travels back in the protocol. An error from here means the protocol did not happen at all, which is why it is worth exiting non-zero on.
It never calls os.Exit on the ordinary path. It does end the process on the step's own deadline, because a registered function that never selects on its context cannot be made to return by anything less. See internal/stepchild.
Types ¶
type Action ¶
Action is what a step does.
func Func ¶
Func makes a step out of a registered function and its parameters:
deploy.Step("apply", senro.Func("deploy/helm", DeployParams{App: "web"}))
The parameters are canonicalised here, at Build time, so an unserializable value is an error where it was written rather than a failure on the twentieth step of a run.
type AnalyzeOption ¶
type AnalyzeOption interface {
// contains filtered or unexported methods
}
AnalyzeOption configures WithAnalyzer.
func AcceptWithoutHumanApproval ¶
AcceptWithoutHumanApproval lets a proposal be applied with nobody watching: the one way to defeat the gate, spelled out at the call site on purpose.
senro.WithAnalyzer(a, senro.AcceptWithoutHumanApproval(
func(f api.Failure, p api.Proposal) bool {
return p.Remedy == api.RemedyRetry && f.Attempt == 1
}))
policy is called once per proposal, on the analyzer's own goroutine, and only for a proposal whose remedy this build can apply (an advisory proposal is never offered). Returning false leaves the proposal waiting for a person, the default.
It cannot widen what "applied" means: the policy chooses whether to apply a remedy, not the remedy, and api.Remedy is closed with one member. The most an unsupervised run can do on an analyzer's say-so is retry a step.
Every proposal applied this way is recorded with api.AnalysisDecisionBody's Policy set, so a run nobody watched can be identified afterwards from the ledger alone. A nil policy restores the default.
func AnalyzeGrace ¶
func AnalyzeGrace(d time.Duration) AnalyzeOption
AnalyzeGrace bounds how long shutdown waits for outstanding answers. See DefaultAnalyzeGrace.
func AnalyzeReportWriter ¶
func AnalyzeReportWriter(w io.Writer) AnalyzeOption
AnalyzeReportWriter redirects the shutdown report (the proposals that could not be recorded, the failures never analyzed, the analyzers that errored) away from standard error. Mostly for a caller that already has somewhere better to put operator-facing text, and for senro's own tests.
func AnalyzeTimeout ¶
func AnalyzeTimeout(d time.Duration) AnalyzeOption
AnalyzeTimeout bounds one call to Analyze. See DefaultAnalyzeTimeout.
The context handed to Analyze carries this deadline, derived with context.WithoutCancel from the run's own: a cancelled run is frequently the one whose failure most wants explaining, and an inherited cancellation would return nothing for exactly those failures.
func AnalyzerName ¶
func AnalyzerName(name string) AnalyzeOption
AnalyzerName is the caller's own name for this analyzer, recorded on every analysis.proposed. A name, never a model, an endpoint or a key: it is persisted, streamed and routinely pasted into bug reports, the same rule notify.Named follows.
type Analyzer ¶
Analyzer explains a failed step.
senro takes no dependency on any model provider, holds no API key, and does not know which model anybody uses: describing a failure is senro's job, explaining it is somebody else's program against their own SDK. The same split the trace exporter is built on; see /docs/writing-an-exporter/. An implementation needs to import github.com/xavidop/senro/api and nothing else of senro's, checked mechanically: see examples/extensions/fakeanalyzer and TestAnExtensionImportsOnlySenrosPublicSurface.
It is handed api.Failure and no handle to read more: everything on that struct is a field senro has decided may leave the machine, reviewable precisely because it is a fixed list. See api.Failure for the redaction rule.
Analyze runs within a timeout (AnalyzeTimeout) on a goroutine that is not the engine's: it is never called from the append path, never holds the ledger lock, and a run whose analyzer hangs still finishes, reporting unexplained failures on standard error after a bounded grace. Failures are offered through a bounded queue; an analyzer that cannot keep up loses offers rather than slowing the run. An error means no proposal: it is counted in the shutdown report, never appended to the ledger, because a run did not fail because somebody's API was down. A panic is recovered and treated as an error.
A proposal causes nothing on its own: it becomes an action only when an attached client accepts it, or when the caller configured a policy in so many words. See WithAnalyzer and AcceptWithoutHumanApproval.
type AnalyzerFunc ¶
AnalyzerFunc adapts an ordinary function to Analyzer.
senro.WithAnalyzer(senro.AnalyzerFunc(
func(ctx context.Context, f api.Failure) (api.Proposal, error) {
return api.Proposal{Summary: "flaky: " + f.Step}, nil
}))
type Appender ¶
Appender appends one event to the run's ledger and reports whether it landed there.
False means it did not and never will: the event is not one a Sink may append (only api.NotifyDelivered, api.NotifyFailed and api.NotifyDropped are), or the run's stream is already sealed. Sealed is the interesting one: run.finished is appended and the stream sealed in one critical section, so an outcome only known after run.finished (the outcome of delivering it always is) has no ledger left to go in, and this return value says so at the moment it happens. notify.Notifier writes those outcomes to standard error at shutdown.
An alias, not a defined type, so a Sink written against this package and the same Sink seen through the engine's internal interface are the same method.
type ChangeSource ¶
ChangeSource answers what a run is asked to build, for ExpandBuilder.Affected. See package change (github.com/xavidop/senro/change), whose FromTrigger reads the mode and the base that the event which started this run already recorded.
type Condition ¶
Condition gates a node on something known at run start. See When.
func Branch ¶
Branch runs a node only on a named branch, read from the run's "branch" parameter (see WithParams).
type Ctx ¶
Ctx is what a registered function receives: a context.Context that also knows the run, the step, its mounted workspaces and its delivered secrets.
type DurationHistory ¶
type DurationHistory = unit.DurationHistory
DurationHistory reports how long each unit's step took in previous runs, which is what Partition balances its buckets by.
The shipped implementation is github.com/xavidop/senro/duration (FromFile, Record, and None for "no history"). The history file belongs IN THE REPOSITORY: a partition derived from timing is a plan that depends on the timing, so a per-machine history would give two machines on one commit two different plans, digests and cache keys.
WRITE YOUR OWN, the same way UnitGraph invites one; nothing needs registering. Two obligations: report the SAME durations on every machine building one commit, or the plan moves; and report an empty map with no error when nothing has been recorded, because that is the first run of every pipeline that uses it.
type ExecutorSpec ¶
type ExecutorSpec = plan.ExecutorSpec
ExecutorSpec is where a workflow's steps run, as declared: the type an ExecutorTarget hands to Build. An alias for the wire-identical internal type, not a copy, so a caller can name it with nothing to convert.
type ExecutorTarget ¶
type ExecutorTarget interface {
ExecutorSpec() ExecutorSpec
}
ExecutorTarget is where a workflow's steps run: a value produced by an executor package, which On carries into the pipeline.
One method returning a struct, rather than one method per property: a new executor family adds a FIELD to ExecutorSpec, which is additive for every existing implementation, instead of a METHOD to this interface, which is not.
This build ships four implementations: Local, container.Image, k8s.Pod and ssh.Host (under github.com/xavidop/senro/executor/...). Build refuses any other kind rather than ignoring it.
func Local ¶
func Local() ExecutorTarget
Local is the coordinator's own machine (internal/executor/localexec), and the executor every workflow gets by saying nothing.
type ExpandBuilder ¶
type ExpandBuilder struct {
// contains filtered or unexported fields
}
ExpandBuilder configures one expansion: one template, one unit graph, and one node per unit, all resolved when Build runs.
Expansion happens at PLAN time, not mid-run: definition, plan and execution stay distinct phases, child ids are deterministic, a re-run reconstitutes exactly the same children because they are IN the plan, and the UI knows the whole node set before anything starts. What it gives up is expanding over a list only a running step could produce; that is not supported yet.
func (*ExpandBuilder) Affected ¶
func (e *ExpandBuilder) Affected(src ChangeSource) *ExpandBuilder
Affected narrows the expansion to the units a change actually reaches: the units that own a changed file, and every unit that depends on one of those, at any depth. This is the point of fanning out over a monorepo at all: without it a push that touched one package builds every package.
verify.Expand("test", gowork.Packages()).
Affected(change.FromTrigger(ev)).
Template(func(u senro.Unit) *senro.StepBuilder {
return senro.NewStep(exec.Command("go", "test", "./...")).WorkDir(u.Dir)
})
It is a plan-time filter, not a run-time skip: unaffected children are NOT in the plan, deliberately unlike When, which prunes a node the plan contains. Two runs of the same commit against the same base produce the same plan, and a re-run reconstitutes exactly the children the first run had. An empty affected set materializes no children, the same ordinary empty expansion (plan.expansion_skipped) a glob that matched nothing produces.
The unit graph has to know which unit imports which (see UnitAffector). gowork does; glob does not, and Build REFUSES an Affected over a glob graph rather than quietly running everything: a silent run-everything would be indistinguishable in a plan or a log from a computed answer.
It deliberately runs too much at the edges: a changed file that belongs to no unit (a root Makefile, a CI workflow) affects every unit, and a change source that cannot tell what changed says "everything" rather than "nothing". Running an unneeded unit costs minutes; skipping a needed one costs trust. See package change and gowork's Owns.
MaxNodes is still checked against the WHOLE graph, not the narrowed set: a 40k-unit graph is a mistake whether or not today's pull request touched three of them.
func (*ExpandBuilder) MaxNodes ¶
func (e *ExpandBuilder) MaxNodes(n int) *ExpandBuilder
MaxNodes refuses an expansion wider than n, defaulting to plan.DefaultMaxNodes. This guards against a bad glob turning into 40k pods: the refusal happens at Build, with the count and the pattern named, rather than at run time with a scheduler already holding hundreds of sandboxes.
func (*ExpandBuilder) MaxParallel ¶
func (e *ExpandBuilder) MaxParallel(n int) *ExpandBuilder
MaxParallel bounds how many of this expansion's children run at once, on top of the run's own global limit. Unset, only the global limit applies.
func (*ExpandBuilder) Needs ¶
func (e *ExpandBuilder) Needs(ids ...string) *ExpandBuilder
Needs declares upstream steps every child waits for, the same step-level dependency (*StepBuilder).Needs declares.
func (*ExpandBuilder) NeedsEach ¶
func (e *ExpandBuilder) NeedsEach(expansions ...string) *ExpandBuilder
NeedsEach declares a PER-UNIT dependency on another expansion: the child for a unit waits on that expansion's child for the SAME unit, and on nothing else.
verify.Expand("build", gowork.Modules()).Template(...)
verify.Expand("test", gowork.Modules()).
NeedsEach("build").
Template(...)
It takes EXPANSION ids, the id given to Expand, not step ids: Needs takes those. A name matching no expansion in the pipeline is refused at Build, because a NeedsEach that quietly did nothing would be a fan-out with no ordering at all.
Without it, ordering one fan-out after another means the whole-expansion barrier: no child of "test" starts until every child of "build" has settled, so one slow module holds up every other module's tests. With it, test[unit=web] starts the moment build[unit=web] finishes. Beware that two expansions in two workflows joined by the workflow-level senro.Needs get entry-to-exit edges ON TOP of these, which are the barrier again; put both expansions in ONE workflow when the point is to pipeline them.
The two unit sets will not always match (a module with no tests, two different graphs), and both easy answers are wrong: dropping the edge lets a step run before its input exists, dropping the step silently skips work. So neither, in either direction:
- A unit HERE with no counterpart THERE keeps its step and falls back to the whole-expansion barrier: that child waits for EVERY child of the named expansion. That can only order more, never less.
- A unit THERE with no counterpart HERE is ordinary and is not an error; it simply has no per-unit dependent.
Naming an empty expansion (a glob that matched nothing) gains no edges, the same nothing an empty group already means everywhere else.
func (*ExpandBuilder) Partition ¶
func (e *ExpandBuilder) Partition(n int, h DurationHistory) *ExpandBuilder
Partition groups the units into at most n buckets and makes ONE STEP PER BUCKET rather than one per unit, balancing the buckets by how long each unit's step took in previous runs.
verify.Expand("test", gowork.Modules()).
Partition(8, duration.FromFile(".senro/durations.json")).
TemplateShard(func(sh senro.Shard) *senro.StepBuilder {
return senro.NewStep(exec.Command(append([]string{"go", "test"}, sh.Dirs()...)...))
})
It takes TemplateShard rather than Template: a bucket is several units, and the per-unit template has nowhere to put the others. Declaring one without the other, or both templates at once, is refused at Build.
Balancing by duration exists because an alphabetical or round-robin split puts the slowest units together often enough that the fan-out takes as long as that one shard. No history (the first run) is not an error: every unit weighs the same and the fill degenerates to a round robin over the sorted unit set. A unit missing from a non-empty history gets the median. See internal/unit.Partition.
A child is "test[shard=0]", numbered, never named after its contents, and the NUMBER of shards is min(n, number of units): the id set is a function of the repository alone, so two machines holding two different histories build the same step ids, and the cache keys hanging off them stay put. What the history does move is which unit is in which bucket, and so each shard's command, inputs, cache key and the plan digest. That is correct: a step that runs three modules is not the step that ran two of them. It is also why the history has to be a committed file; see DurationHistory.
A shard's time cannot be attributed to the units that spent it, so duration.Record ignores shard steps; record from a run of the same expansion UNPARTITIONED when the numbers go stale.
MaxNodes is still checked against the whole graph, before any of this: partitioning is not a way around the guard, and a glob matching forty thousand directories is a mistake whether or not it would have collapsed into eight steps.
func (*ExpandBuilder) Template ¶
func (e *ExpandBuilder) Template(fn func(Unit) *StepBuilder) *ExpandBuilder
Template builds the step for one unit. It is called once per unit, in unit order, and must return a fresh builder each time: two units sharing one builder would produce one node, with whichever unit's command was applied last.
func (*ExpandBuilder) TemplateShard ¶
func (e *ExpandBuilder) TemplateShard(fn func(Shard) *StepBuilder) *ExpandBuilder
TemplateShard builds the step for one bucket of a partitioned expansion. It is called once per shard, in shard order, and must return a fresh builder each time, for the same reason Template must. It replaces Template rather than joining it: Build refuses an expansion declaring both, one partitioned with only a per-unit Template, and one declaring this with no Partition.
func (*ExpandBuilder) When ¶
func (e *ExpandBuilder) When(c Condition) *ExpandBuilder
When gates every child of an expansion, the same way the workflow-level When gates every step of a workflow: a condition declared here is appended to every materialized child's own When, in addition to (and ANDed with) anything the child's own Template call declares.
type Flusher ¶
Flusher is an optional interface a Sink may implement to be given a bounded chance to finish its work before Run returns.
Run calls Flush after the engine has emitted run.finished and closed the ledger, with a context derived via context.WithoutCancel: a cancelled run still wants its "cancelled" notification to go out, and that is precisely the run whose context is already dead. A Flusher must bound its own wait, since Run's context may have no deadline.
The error is not propagated: a run that did everything it was asked did not fail because a webhook was down. A Flusher with something to say says it on a channel it chose; notify.Notifier writes to standard error.
type Fragment ¶ added in v1.1.0
type Fragment struct {
// contains filtered or unexported fields
}
Fragment is a piece of graph a generator builds: the steps to splice into the running graph, and the boundary the generator's dependents wait on.
Step ids are RELATIVE to the generator. The engine prefixes them with the generator's own id, so a fragment does not need to know where it will land and the ids it produces are hierarchical and stable.
func NewFragment ¶ added in v1.1.0
func NewFragment() *Fragment
NewFragment starts an empty fragment. An empty fragment is legal and common: it means "nothing to do here", and the generator's dependents run immediately rather than being skipped.
func (*Fragment) Boundary ¶ added in v1.1.0
func (f *Fragment) Boundary(steps ...*StepBuilder) *Fragment
Boundary declares which of this fragment's steps the generator's existing dependents must wait on.
Without it a downstream step would run as soon as the generator finished, which is the moment the generated work STARTS rather than the moment it is done. Declaring nothing is legal and means exactly that: the generator produced work nobody downstream consumes.
func (*Fragment) MarshalJSON ¶ added in v1.1.0
MarshalJSON writes the fragment in the public wire schema, the same one a generator in any other language writes.
A Go fragment reaching the engine as bytes, and being parsed back exactly as a JSON one is, is deliberate: the two forms then cannot drift, they share one validation path, and the blob recorded in the CAS is the same whichever produced it. The cost is one round trip per generator, which is nothing next to running the step that produced it.
func (*Fragment) Step ¶ added in v1.1.0
func (f *Fragment) Step(id string, a Action) *StepBuilder
Step adds one step to the fragment, with the same shape a workflow's Step has so a fragment is written in the vocabulary the rest of a pipeline already uses. Needs names other steps IN THIS FRAGMENT, by their relative id.
type GenCtx ¶ added in v1.1.0
type GenCtx interface {
// Step is the id of the generator step, which is also the prefix every
// id in the returned fragment receives.
Step() string
// Dir is the step's own output root, where the files it produced are.
Dir() string
// OutputJSON decodes a JSON file the step wrote, relative to Dir, into
// v. The common case, and the one the design's own example uses.
OutputJSON(name string, v any) error
}
GenCtx is what a Go generator is called with: enough to read what the step it belongs to produced, and nothing else.
Deliberately narrow. A generator decides SHAPE, and a generator handed the engine could change a run it is only supposed to describe.
type Generator ¶ added in v1.1.0
type Generator struct {
// contains filtered or unexported fields
}
Generator declares where a step's plan fragment comes from.
A generator is a step whose OUTPUT is a piece of graph: the nodes it describes are spliced into the run that is already executing, and they become ordinary steps with their own cache entries, retries, logs and states. It is the general case that Expand is the cheap special case of. Reach for When first and Expand second; a generator is for the list that only exists once something has run, which is the one thing neither can do.
Build with GenerateFromJSON. The zero Generator declares nothing.
func Generate ¶ added in v1.1.0
Generate declares that a Go function on the coordinator turns this step's output into a plan fragment.
The function may be as nondeterministic as it likes: it can call an API, read a clock, iterate a map. senro records the fragment it produced and REPLAYS that recording rather than calling it again (design §2.8.1), so reproducibility comes from the record, not from a promise about the code. That is the constraint Temporal-style workflow engines put on user code and this one does not.
func GenerateFromJSON ¶ added in v1.1.0
GenerateFromJSON declares that the step writes its plan fragment to path, as JSON, relative to the step's own output root.
This form matters as much as the Go one: "write a plan fragment to this path" is a contract a shell script, a Python tool or a Terraform wrapper can honour, and the fragment schema is public. A pipeline whose graph is decided by a tool that is not written in Go is exactly the case a generator exists for.
type Mount ¶
type Mount struct {
// contains filtered or unexported fields
}
Mount is one workspace or scratch cache realized into one step. Whether an RO mount is actually enforced read-only depends on the executor; see RO.
type MountMode ¶
type MountMode string
MountMode is whether a step may write through a mount. See RO for what "read-only" actually means, which differs by executor.
const ( // RO marks a mount read-only. Enforcement depends on the executor: the // container and Kubernetes executors enforce it (a read-only bind; a pod // spec with ReadOnly set), while the local and ssh executors cannot (no // unprivileged bind mounts; a transferred directory carries no per-step // mode), so a write there succeeds. The backstop for those two is // detection: a read-only mount whose content digest changed while a step // ran fails that step, naming the workspace, because a workspace digest // that does not describe what the step read makes every cache key // computed from it wrong. RO MountMode = "ro" // RW marks a mount read-write. RW MountMode = "rw" )
type Option ¶
type Option func(*runConfig)
Option configures Run.
func WithAnalyzer ¶
func WithAnalyzer(a Analyzer, opts ...AnalyzeOption) Option
WithAnalyzer hands Run an analyzer of the caller's own. Every step that settles in a failed terminal state is offered to it, and what it proposes becomes an analysis.proposed event in the run's ledger.
err := senro.Run(ctx, pipe,
senro.WithAnalyzer(myAnalyzer,
senro.AnalyzerName("claude"),
senro.AnalyzeTimeout(20*time.Second)))
Not repeatable: a later call replaces an earlier one. Two analyzers would mean two proposals per failure competing for one gate; a caller who wants two writes an Analyzer that consults both and returns one answer.
A run given no analyzer emits no analysis events, starts no goroutine and costs nothing.
Nothing is applied without a human, by default: a proposal sits in the run until an attached client accepts it (api.OpAnalysisAccept, the TUI's 'a' key) or rejects it, and a run nobody was watching applies nothing. AcceptWithoutHumanApproval is the one way to change that.
func WithAttach ¶
WithAttach hands Run the Sink of an already-listening attach server; see attach.Listen. Every event Run emits fans out to whatever is attached, and Run adopts att's run directory and RunID unless WithDir/WithRunID override them, so the attach server and the engine agree on exactly one run; see TestRunWithAttachSharesDirectoryAndRunID.
Only *attach.Attach is accepted, not a bind-address string: a string sugar would make the wildcard TCP bind (which attach.Listen refuses without a certificate) the easiest thing to type, and would have nowhere to hand the bearer token back. Call attach.Listen yourself, read att.Token() if it bound TCP, and hand the result here.
func WithCacheDir ¶
WithCacheDir overrides where the content-addressed store, the action cache and the scratch cache live. Unset, Run uses storage.DefaultRoot: $SENRO_CACHE_DIR when set, and os.UserCacheDir()/senro otherwise.
Unlike WithDir, this is deliberately NOT per-run. A run directory is one run's record; a cache root is shared by every run on the machine, and that sharing is the entire point of a cache.
func WithDir ¶
WithDir overrides the run directory Run uses. Unnecessary when WithAttach is also given (Run adopts att.Dir() by default), and unnecessary when neither is given, since Run then generates one under runs/<id> the same way attach.Listen does (attach.NewRunID). Set this to pin a specific, known path instead.
func WithFuncBuild ¶
WithFuncBuild names the package this program was built from, so senro can cross-compile it for a target that does not share the coordinator's platform. A func step runs a function compiled into THIS binary; when the target's platform matches, senro ships os.Executable() and needs nothing from you, but a Go program does not record the package it was compiled from, so a cross-build needs this. The common surprise is a func step in a CONTAINER: an image is linux, so a macOS coordinator cross-compiles for every one, however local the daemon is.
The value is anything `go build` accepts, resolved as `go build` would:
senro.Run(ctx, pipeline(cfg), senro.WithFuncBuild("./ci"))
A run with no remote func step is completely unaffected. A run that needs a cross-build and was not given this reads SENRO_FUNC_PKG (which `senro run` sets to the package it just built); with neither, the run fails at second zero naming both, rather than on the step that needed it.
The cross-build is CGO_ENABLED=0 with -tags netgo,osusergo, so no cgo-dependent package may appear in the module's transitive closure; `senro func check` answers that in advance, and a failing build names the offending import path and the chain that pulled it in. The result is cached under the cache root, keyed by this binary's digest and the target platform, so a release compiles once per architecture.
Explicit wins over the environment, for the reason WithTraceContext gives. Passing "" means "no package": a run that will refuse to cross-build rather than fall back to the environment.
func WithLocalClass ¶
WithLocalClass overrides the local executor's cache equivalence class, e.g. "local/darwin/arm64/go1.26" instead of the bare "local/darwin/arm64": the executor has no generic way to know what toolchain a step invokes, and two machines differing only in an unfingerprinted toolchain would otherwise silently share cache entries. Unset, Class() reports exactly what it always has.
func WithMaxDepth ¶ added in v1.1.0
WithMaxDepth bounds how deep generators may NEST: a generated step can itself be a generator, and without a bound that recurses until the machine gives out. Zero, the default, means three.
Raise it for a pipeline that genuinely discovers work in layers; lower it to one to allow generation but forbid a generator producing generators.
func WithMaxNodes ¶ added in v1.1.0
WithMaxNodes bounds how many nodes the whole run may hold, the plan's own included, and every splice is checked against it. Zero, the default, means five thousand.
Run-wide rather than per-fragment: a hundred generators producing fifty nodes each is the same runaway as one producing five thousand, and only a run-wide count sees it.
func WithOnlySteps ¶ added in v1.1.0
WithOnlySteps restricts the run to these steps. Everything else is skipped with a reason, exactly as an unmet When condition is.
The set is taken literally: senro does not add dependents for you, because "and everything below it" and "only this" are both things a caller legitimately wants and only the caller knows which.
func WithParams ¶
WithParams supplies the run's parameters. See Params and senro.When.
func WithRegenerate ¶ added in v1.1.0
func WithRegenerate() Option
WithRegenerate makes generator steps ignore the action cache, so each one runs and produces a FRESH fragment instead of replaying the recorded one.
Reach for it when the world has changed and the recorded graph describes a fleet that no longer exists. It is a separate switch, and not the default, because silently re-deriving a graph during what looked like a retry is a genuinely confusing failure: the run would do different work than the one it claims to repeat.
func WithRemoteCache ¶
func WithRemoteCache(rc RemoteCache) Option
WithRemoteCache points this run at a shared cache. See RemoteCache.
A configuration that cannot possibly work (no bucket, a malformed endpoint, credentials in the endpoint, both a bucket and a registry) fails the run before it starts, the only remote-cache problem that does: it is a mistake in what somebody wrote, not a condition of the network, and degrading it to "your cache is down" would send them looking in the wrong place. Everything afterwards, including an unreachable store, degrades instead.
The zero RemoteCache configures nothing at all, so
rc, ok, err := senro.RemoteCacheFromEnv() ... senro.Run(ctx, p, senro.WithRemoteCache(rc))
is correct whether or not ok was true.
func WithRunID ¶
WithRunID overrides the run's ID, the same way WithDir overrides its directory and for the same reasons.
func WithSecrets ¶
WithSecrets hands Run the resolved configuration struct mamori.Load returned:
cfg, err := mamori.Load[Config](ctx, mamori.WithProvider(awssm.New()))
if err != nil { return err }
senro.Run(ctx, pipeline(cfg), senro.WithSecrets(cfg))
Load, not Watch: a run lasts minutes and reads each value once, so senro takes a snapshot of the struct, and a value that rotates mid-run is a step that fails on use rather than a re-delivery path.
What senro does with it, once:
- Every self-redacting field (mamori's secret.String and secret.Bytes, or any type internal/secrets/reveal.go recognizes) has its value read exactly once, in that one file. A plain string or int field with a source tag is configuration, not a credential, and is left alone.
- Those values seed the run's redactor, which sits in front of the event ledger and every log file.
- Their identities (field name, source URI with userinfo removed) are emitted as one secret.resolved event each. A value is never in an event.
- A step that declared SecretEnv receives its value as a file, with the path in the environment. The value never enters a command argument, an environment variable, a cache key, or plan.json.
Run refuses to start if a resolved value is shorter than six bytes (it could not be redacted without redacting unrelated output), or if any step or handler would put a resolved value into a command argument, an environment variable, WorkDir, an Inputs/Outputs pattern, or a mount's names or path: the first two are visible in ps(1) and /proc/<pid>/environ, the rest are recorded verbatim in plan.json and the cache root, all beyond the redactor's reach. See the secrets section of the README.
Passing anything that is not a struct or a pointer to one is an error Run returns rather than an empty set it silently proceeds with.
hasSecrets, not a nil check on the stored value: an `any` holding a nil pointer of a concrete type is not itself == nil, so a nil check would not even catch the common form of the WithSecrets(nil) mistake.
func WithSink ¶
WithSink hands Run a sink of the caller's own, which receives every event the run appends to its ledger, in order.
Repeatable. Each call adds a sink, and every one of them sees every event:
senro.Run(ctx, p,
senro.WithSink(notifier),
senro.WithSink(senro.SinkFunc(func(e api.Event) { ... })),
)
It composes with WithAttach rather than competing with it. A run given both feeds the attach server and every sink here from the same stream.
Read Sink's own doc before writing one: Emit must not block, because the engine calls it inline.
func WithTraceContext ¶
WithTraceContext continues an inbound W3C trace, making this run a child of whatever started it.
Both arguments are header values exactly as W3C Trace Context defines them (https://www.w3.org/TR/trace-context/), which is also how they arrive everywhere else: as an HTTP header, as a CI job's environment variable, as a field in a webhook delivery. A caller that already holds a span in a context.Context renders one from it:
sc := trace.SpanContextFromContext(ctx)
senro.Run(ctx, pipeline, senro.WithTraceContext(
fmt.Sprintf("00-%s-%s-%02x", sc.TraceID(), sc.SpanID(), sc.TraceFlags()),
sc.TraceState().String(),
))
tracestate may be empty, and usually is. senro never parses it: it is opaque vendor routing data that only has to reach downstream unchanged.
Without this option, Run reads TRACEPARENT and TRACESTATE from its own environment (lowercase spellings too): a senro run is almost always a child of a CI job, webhook delivery or deploy tool, and every one of those exports the variables already. This option WINS over the environment when given, including when given empty strings: WithTraceContext("", "") is how an embedder says "this run is a root, ignore the ambient variables".
A malformed value is ignored and the run starts a fresh trace: never propagated (a salvaged link to a nonexistent trace is indistinguishable from a real one whose other half was lost), and never a reason to refuse to run (an unset shell variable must not break a build).
The trace context reaches every event as api.Event.TraceID; see api.RunStartedBody and api.StepStartedBody. senro emits no spans and depends on no OpenTelemetry package: an exporter is a Sink in the caller's own program; see WithSink and examples/otelexport.
The trace also goes back out: every step's command is launched with TRACEPARENT set to that ATTEMPT's own span (and TRACESTATE beside it), on every executor, so a traced tool inside a step becomes a child of the step rather than the root of a disconnected trace. Handlers get their own span; a step declaring its own TRACEPARENT keeps it. It never enters a cache key: the key digests only the names a step declared in CacheEnv, from the step's declared environment.
func WithTrigger ¶
WithTrigger makes the pipeline decide for itself whether ev is its business: the event this process was handed, and the triggers this pipeline declares.
ev, err := trigger.LoadEvent(*eventPath)
if err != nil {
return err
}
err = senro.Run(ctx, pipeline, senro.WithTrigger(ev,
trigger.OnPush(trigger.Branches("main")),
trigger.OnPullRequest(trigger.Actions("opened", "synchronize")),
))
if errors.Is(err, trigger.ErrNoMatch) {
os.Exit(78)
}
Three outcomes. A match runs the pipeline, and Run's error means what it always meant. No match is trigger.ErrNoMatch, wrapped: Run starts no run, creates no directory, emits no event, and returns before touching disk. Anything else wrong with the wiring is an ordinary error, so "not my business" and "somebody wired this wrong" never look alike.
Run never exits. Exit 78 (EX_CONFIG) is the convention for a no-match, and mapping the sentinel to it is main's decision: a library that calls os.Exit has taken it for every host that embeds it.
A match carries the trigger's Params, laid over the event's own, into the run's senro.Params; the event's branch becomes the "branch" param senro.Branch reads, so a trigger-driven run needs nothing extra for a branch condition. WithParams still wins over both. The mode and the affected-set base are recorded in run.json (see RunManifest) and on the trigger.Match; they are deliberately NOT injected as parameters, since senro computes no affected set and should not claim parameter names on that work's behalf.
A Run with no WithTrigger gates nothing and costs nothing; so does WithTrigger with a nil event, which is what makes the local loop work: `./pipeline` with no --trigger-event runs everything, and a dispatcher that forgets the flag over-runs visibly rather than silently never running.
type Params ¶
Params are a run's parameters: the small, flat, string-valued facts a run is started with, which conditions read (senro.Branch, senro.ParamIs).
A map rather than a struct because a trigger produces them and a CLI passes them through, neither of which knows the pipeline's Go types. Values are never recorded in an event or a cache key, so a credential passed here leaks into nothing durable; passing one into a step's Env or argv still gets the refusal WithSecrets produces.
type Pipeline ¶
type Pipeline struct {
// contains filtered or unexported fields
}
Pipeline accumulates workflows: the top of the three-level hierarchy (pipeline, workflow, step). Build snapshots it into the *Plan the engine executes.
func (*Pipeline) Build ¶
Build resolves and validates the pipeline. The returned plan is a snapshot: further building does not change it.
The workflow layer is resolved away here: a plan is a flat set of step nodes and edges, each workflow-level Needs is lowered into step-level edges (see Needs), and a pipeline of one workflow builds into exactly the plan its steps alone describe. That keeps a plan's digest, and every cache entry keyed under it, a function of the steps a pipeline declares rather than of how they happen to be grouped.
A node's Env is exactly what the caller declared; Build supplies no default PATH. A default would put the host's $PATH into plan.Digest(), giving two developers on one commit two plan identities. A search path is a property of the host, which belongs in executor.Executor.Class(), not in the timetable; the default lives in the local executor. A PATH set explicitly through StepBuilder.Env is part of the pipeline's definition, belongs in the digest, and no executor overrides it.
Expansions resolve here, exactly once, into ordinary []*StepBuilder: from this point a child of Expand is a step like any other. Resolving in a helper that might run twice could see two different filesystem trees and would double every expansion's children. Build passes context.Background() to Units because changing Build's signature would break every caller, senro.Run included; the cost is that a slow Build (gowork shells out to `go list -deps -json` over a whole workspace) cannot be cancelled. A BuildContext variant would be the fix, added rather than changed.
func (*Pipeline) Workflow ¶
func (p *Pipeline) Workflow(name string, opts ...WorkflowOption) *WorkflowBuilder
Workflow adds a named group of steps to the pipeline: the unit that carries cross-group dependencies (Needs) and executor targeting (On). Two workflows may not share a name, and no step id may repeat anywhere in the pipeline: Build refuses both, naming the workflows involved.
type Plan ¶
Plan is the resolved, immutable result of (*Pipeline).Build: a type alias for the wire-identical internal/plan type, not a copy or a wrapper. The alias exists so code outside this module can NAME Build's return type (internal/plan cannot be imported), which is what RunPlan's parameter and everything attach, replay and the golden fixtures build on require. An alias rather than a defined type, so *Plan and *plan.Plan are identical and there is nothing to convert.
type RegistryCache ¶
type RegistryCache struct {
// Host is the registry's host and optional port: "ghcr.io",
// "registry.internal:5000". A host, not a URL, and it must not carry a
// username or password, for the reason Endpoint must not: a host is named
// in error messages and events.
Host string
// Repository is the path inside the registry that holds the cache, such as
// "acme/senro-cache". Lowercase, as the distribution specification
// requires. Most registries create it on first push.
Repository string
// Username and Password are the credential presented to the registry's
// token endpoint, which is the one authentication flow senro implements and
// the one every hosted registry serves. Both empty means anonymous, which
// works against a registry that demands nothing.
//
// senro runs no credential helper, reads no ~/.docker/config.json and
// contacts no metadata service. For a registry whose credential is issued
// by another service, resolve it first and pass the result: "AWS" and
// `aws ecr get-login-password` for Elastic Container Registry,
// "oauth2accesstoken" and an access token for Artifact Registry.
Username string
Password string
// PlainHTTP talks to the registry over http rather than https, for a
// registry on a trusted network that serves no certificate. Off by default,
// because a credential sent in clear text to a host on the internet is a
// leaked credential.
PlainHTTP bool
}
RegistryCache is the half of a RemoteCache that names an OCI registry.
A nested struct rather than five more fields on RemoteCache: a bucket and a registry agree on almost nothing, and flattened together half the fields would always be wrong with no way to tell which half was live. Timeout and ReadOnly stay on RemoteCache because they mean the same thing on either.
type RemoteCache ¶
type RemoteCache struct {
// Endpoint is the object store's URL: "https://s3.eu-west-1.amazonaws.com",
// "https://<account>.r2.cloudflarestorage.com", "http://minio.internal:9000".
// Scheme and host; a path on it is used as a prefix, for a store behind a
// reverse proxy.
//
// It must not carry a username or password. Credentials go in the fields
// below, because an endpoint is named in error messages and events and a
// credential in one would travel into every log that saw it.
Endpoint string
// Region scopes the request signature. Required even for a store that has
// no regions of its own, because it is signed over and both ends have to
// expect the same one. "us-east-1" is the conventional answer for a store
// that does not care.
Region string
// Bucket holds the cache.
Bucket string
// Prefix is the key prefix inside the bucket, so one bucket can hold
// senro's cache alongside other things. Empty means "senro".
Prefix string
// The credentials. In CI these usually come from an assumed role, in which
// case all three are set and they expire; senro reads them once, at the
// start of a run, and a run outlasting its credentials degrades to no
// cache exactly as any other authentication failure does.
AccessKeyID string
SecretAccessKey string
SessionToken string
// PathStyle chooses bucket-in-path (endpoint/bucket/key) over
// bucket-in-host (bucket.endpoint/key) addressing.
//
// Nil means "work it out from the endpoint", which is right almost always:
// bucket-in-host for Amazon, which requires it for buckets created since
// 2020, and bucket-in-path for everything else, which either requires it
// or accepts it and rarely has the wildcard DNS the other style needs.
// Set it only when a store disagrees with that.
PathStyle *bool
// Timeout bounds one request to the store, including reading its body.
// Zero means five minutes, which is generous because the largest object a
// cache moves is a workspace snapshot and a cold runner's uplink is not
// always fast.
Timeout time.Duration
// ReadOnly reads the shared cache and never writes to it.
//
// This is what a pull-request build, especially one from a fork, should
// use: it gets the speed of the cache the trunk builds fill, and it cannot
// put anything into a cache that other people's builds will trust. Set it
// alongside a credential that also cannot write, rather than instead of
// one: this is a courtesy, and the store's policy is the control.
ReadOnly bool
// Scratch shares [ScratchCache] entries through the bucket as well,
// which is off by default.
//
// Worth it when your CI runners start cold and your dependency install
// dominates the build. Not worth it when the tree is large and the key
// churns: an entry is one whole-tree tarball, so a lock-file edit means
// uploading all of it again to save a download the toolchain already
// does incrementally.
//
// Two things to know before turning it on. It needs s3:ListBucket, which
// nothing else senro does requires, because the RestoreKeys fallback is a
// prefix listing. And a scratch tree carries no platform in its key, so a
// cache filled on one operating system or architecture will be restored
// on another unless your own key says otherwise; put the platform in
// [Key] if the content is not portable.
//
// Ignored by the registry backend, whose API cannot list by prefix.
Scratch bool
// Registry holds the cache in an OCI registry repository instead of a
// bucket. Setting Host on it selects that backend, and the bucket fields
// above must then be left alone.
Registry RegistryCache
}
RemoteCache points a run at a shared cache, so machines can reuse each other's work: a fresh CI runner starts with an empty disk, and a shared cache is already warm on its first build.
The cache lives in an S3-compatible bucket (the fields below) or in an OCI registry repository (Registry). One or the other, never both: two places to keep one cache, not two caches. It is a second tier behind the local cache, never a replacement: a run reads its own disk first, falls back to the shared store, and writes what it fetches through to disk.
Two properties are guaranteed rather than best effort. Nothing is served without being verified: every object is checked against the digest it was asked for, and every cache entry against its key, so a truncated, stale or foreign body is a cache MISS, never a wrong build. And a cache that is down never fails a run: unreachable, unauthenticated, refusing writes, slow or corrupt all mean "no shared cache", reported once on standard error and once as an api.CacheDegraded event, after which the run stops trying so it does not pay a timeout per lookup.
The zero value configures nothing. See RemoteCacheFromEnv for the form CI usually wants.
func RemoteCacheFromEnv ¶
func RemoteCacheFromEnv() (RemoteCache, bool, error)
RemoteCacheFromEnv reads a shared-cache configuration from the environment.
It returns ok=false, and no error, when SENRO_REMOTE_CACHE is unset: no shared cache is the ordinary state of a machine. When it IS set, anything else missing is an error rather than a silently disabled cache, because a cache that is quietly not there looks exactly like a cold cache and nobody investigates one of those.
A bucket's credentials come from AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_SESSION_TOKEN, which CI already sets when a job assumes a role. A registry's come from SENRO_REMOTE_CACHE_USERNAME and SENRO_REMOTE_CACHE_PASSWORD, senro's own names because no standard pair exists to borrow.
The scheme of SENRO_REMOTE_CACHE chooses the backend, and variables belonging to the OTHER backend are refused rather than ignored: a leftover that is silently ignored is how somebody spends an afternoon wondering which endpoint their cache is really using.
senro reads no credential file and contacts no metadata service: the credentials are whatever the process was given, resolved once. A caller who wants a fuller resolution chain fills in a RemoteCache directly.
type Reporter ¶
type Reporter interface {
SetAppender(Appender)
}
Reporter is an optional interface a Sink may implement to record its own events in the run's ledger, which is the only place an event is real.
Run calls SetAppender once, before the run's first event, and only on a Sink that implements it. The Appender may be called from any goroutine, including after the run has ended, where it returns false.
It must NOT be called from inside Emit: Emit runs under the engine's append lock, and an Appender call from there would deadlock the run. Report from the goroutine that does the work.
The events a Sink may append are restricted to the notify.* set: an observer is authoritative about its own behaviour and about nothing else.
type RunError ¶
type RunError struct {
// Status is the run's rolled-up outcome; see api.RollUp and
// api.RunInfo.Status. Never api.RunSucceeded or
// api.RunSucceededWithRecovery: Run returns nil for both of those.
Status api.RunStatus
// Dir is the run directory Run wrote to: events.jsonl and every
// step's per-attempt logs live there. Empty only when a caller builds
// a RunError itself rather than receiving one from Run.
Dir string
// Steps names the steps behind Status, in the order Run created
// them, capped at a handful (see StepsOmitted). Which State qualifies
// depends on Status: State.Failed() for RunFailed, StateCancelled for
// RunCancelled, StateSkippedUpstreamFailed for RunPartial. Empty when
// the fold has no step to blame; Error still names Dir rather than
// inventing a cause.
Steps []RunErrorStep
// StepsOmitted is how many further qualifying steps exist beyond
// Steps, so Error can say "and N more" instead of naming dozens.
StepsOmitted int
}
RunError reports that a run reached a terminal state other than success (failed, partially failed, or cancelled), as opposed to an ENGINE failure (an invalid plan, a ledger write failure), which Run returns as a plain wrapped error, since a run that never really happened has no status to report.
Run's signature stays a plain `error`; a caller that needs more than the one-line summary recovers this with errors.As(err, &runErr) and reads Status, Dir and Steps off it.
func (*RunError) Error ¶
Error renders one line: the run's status, which step(s) are behind it (named, not dumped: a step's error text or command line can carry values that must not be repeated here), and where to look next. For example:
senro: run failed: step "test" failed (exit 1); see runs/20260810T073610-64c2f4b40c/events.jsonl
type RunErrorStep ¶
type RunErrorStep struct {
// ID is the step's id, exactly as declared with (*WorkflowBuilder).Step.
ID string
// State is the step's own terminal state, see api.State.
State api.State
// ExitCode is the step's process exit code. Shown by String only when
// State is StateFailed and the code is nonzero: a step that never ran a
// process reports exit 0, and "(exit 0)" next to "failed" would read as
// a passing process on a failing run.
ExitCode int
}
RunErrorStep is one step behind a RunError's Status. See RunError.Steps for which State qualifies a step for this list.
func (RunErrorStep) String ¶
func (s RunErrorStep) String() string
String renders one step the way RunError.Error embeds it: `"id" state[ (exit N)]`.
type RunManifest ¶
type RunManifest struct {
// RunID is the run's ID, the same one the ledger's events carry.
RunID string `json:"run_id"`
// Pipeline is the pipeline's name, empty for a RunPlan (a resolved plan
// carries no name; see RunPlan).
Pipeline string `json:"pipeline,omitempty"`
// StartedAt is when Run began this run, in UTC.
StartedAt time.Time `json:"started_at"`
// Trigger is what triggered the run, absent for a run nobody triggered
// (a local ./pipeline, an embedder calling Run directly).
Trigger *TriggerRecord `json:"trigger,omitempty"`
}
RunManifest is what runs/<id>/run.json holds: what this run is and, when something triggered it, what.
A file of its own rather than a field on run.started: the event schema is published and pinned by golden fixtures, and provenance is a fact about the run that is true before the first event and does not change.
Written once, before the run's first event, so anything watching the run can read it while the run is still going. A run whose engine refused to start leaves a manifest and nothing else, which says what was attempted.
func ReadRunManifest ¶
func ReadRunManifest(dir string) (*RunManifest, error)
ReadRunManifest reads runs/<id>/run.json from a run directory.
m, err := senro.ReadRunManifest("runs/20260812T101500-4f1c2d")
fmt.Println(m.Trigger.Ref, m.Trigger.Mode)
The counterpart to the file Run writes, so a caller that wants to know what triggered a finished run does not have to know the file's name or its JSON shape. A run directory from a build before manifests existed has no run.json, and the error says so.
type ScopeKind ¶
type ScopeKind string
ScopeKind is a workspace's lifetime.
const ( // ScopeStep is one fresh directory PER STEP, shared with nobody and // discarded with the run. // // It buys isolation a run-scoped workspace cannot: every step mounting a // ScopeRun workspace mounts the same directory, so a step sees what its // siblings left there and can stamp on what they are still using. Reach // for this when a step wants a clean tree to work in and nothing // downstream reads what it produced. Nothing is snapshotted from one, for // the same reason: there is no later step to hand it to. ScopeStep ScopeKind = "step" // ScopeRun is shared across the steps of one run. The common case, and // the default. ScopeRun ScopeKind = "run" // ScopePersistent survives runs: one directory on this machine, named by // the workspace's own name, that every later run mounting the same name // starts from. For expensive trees (a dependency cache, a checkout) not // worth rebuilding every run. // // It requires an explicit MaxAge and MaxSize, both: an unbounded // persistent workspace fills the disk silently. // // It is MACHINE-GLOBAL and keyed by name alone, so two pipelines that // both declare a workspace called "cache" share one directory. Name it // for what is IN it ("go-mod-cache"), not for the role it plays. // // Its content is digested at the start of every run and that digest // enters the cache key of every step mounting it, so a Pure() step whose // persistent workspace changed between runs misses, correctly. The // measurement is a full snapshot per run, before the first step, and is // visible on trees big enough for this scope to be worth using. // // One run at a time holds a given persistent workspace; a second run is // refused before any step has run, naming the holder. See senro.MaxAge. ScopePersistent ScopeKind = "persistent" )
type ScratchOption ¶
type ScratchOption func(*scratchConfig)
ScratchOption configures a scratch cache.
func Key ¶
func Key(template string) ScratchOption
Key sets the scratch cache's lookup key. The value is a template evaluated once per run, with one function available: hashFiles, which takes globs relative to the pipeline process's working directory.
func RestoreKeys ¶
func RestoreKeys(prefixes ...string) ScratchOption
RestoreKeys are prefixes tried, in order, when the exact key misses. The newest entry under the first matching prefix wins.
type ScratchRef ¶
type ScratchRef struct {
// contains filtered or unexported fields
}
ScratchRef names a scratch cache: a mutable directory restored best-effort, such as a module cache. Distinct from a workspace because a miss is not an error and a stale hit only costs time, and because it is NEVER an input to an action cache key.
func ScratchCache ¶
func ScratchCache(name string, opts ...ScratchOption) *ScratchRef
ScratchCache declares one.
func (*ScratchRef) At ¶
func (c *ScratchRef) At(at string) Mount
At mounts the scratch cache into a step. There is no mode: a scratch cache is always writable, since the point is that the step fills it.
type Shard ¶
Shard is what a PARTITIONED expansion's TemplateShard is called with: one bucket of units, and where it sits among its siblings. An alias for the same reason Unit is.
type Sink ¶
Sink observes a run. Every event the engine appends to the run's ledger is handed to Emit, in ledger order, on the engine's own goroutine.
One method, not the engine's internal Sink interface: driving a run (Control) is a bigger contract than watching one and already has an owner in the attach package. Adding control later stays additive.
Emit must NOT block: the engine calls it while holding the lock that makes an append and its delivery one atomic unit, so a slow Emit slows the run and a wedged one stops it. An implementation that talks to anything must hand the event off and return; see the notify package's sinks.
A panic in Emit does not kill the run: Run recovers it and drops the event, because an observer must not be able to end a build. It is still a bug in the sink.
A Sink may also implement Reporter (record its own events in the ledger) and Flusher (a bounded chance to finish before Run returns); both are optional.
type SinkFunc ¶
SinkFunc adapts an ordinary function to Sink.
senro.Run(ctx, p, senro.WithSink(senro.SinkFunc(func(e api.Event) {
log.Printf("%d %s %s", e.Seq, e.Type, e.Step)
})))
type StepBuilder ¶
type StepBuilder struct {
// contains filtered or unexported fields
}
StepBuilder configures one station.
func Handler ¶
func Handler(id string, a Action) *StepBuilder
Handler builds a node for use as a step's OnFailure or Always handler. It is a *StepBuilder with the same Env, WorkDir and Timeout methods, but it is deliberately not appended to any workflow: passing its result to OnFailure or Always is what makes it reachable, and a handler that also became a step would run twice. OnFailure and Always refuse a *StepBuilder returned by Step instead of Handler; see StepBuilder.handler.
The step methods a handler has no meaning for are refused by Build rather than accepted and dropped: Needs and When (it runs because its parent settled), its own Executor or mounts (it inherits its parent's), cache settings, handlers of its own, and Retry (the engine runs a handler exactly once, with no attempt loop; retry the step instead, which retries before any handler runs).
Inheritance is literal: the handler's sandbox is given the parent's workspace mounts at the same paths, and it starts in the parent's WorkDir unless it sets one of its own. A step that wrote build.log into a workspace mounted at /repo has a handler that can `cat build.log`, on the same executor the step ran on.
The inherited view is READ-ONLY: the step's workspace snapshot is taken before any handler starts, so a handler write would move bytes the run's event log already describes. As with a step's own senro.RO mount, only the container executor enforces this. A handler that needs to write has its own sandbox working directory.
Not inherited: anything the step wrote outside a declared workspace (the executor's private sandbox, which a container removes before the handler starts), and scratch caches.
func NewStep ¶
func NewStep(a Action) *StepBuilder
NewStep builds a step that is not attached to any workflow, which is what an expansion's Template returns:
verify.Expand("lint", glob.Dirs("apps/*")).
Template(func(u senro.Unit) *senro.StepBuilder {
return senro.NewStep(exec.Command("pnpm", "--filter", u.Name, "lint")).
Pure().Inputs(u.Sources()...)
})
It has no id: the expansion assigns one from the unit ("lint[unit=apps/web]"), because a template-chosen id could not be guaranteed unique across units. It is not a handler either, so OnFailure and Always refuse it exactly as they refuse a workflow's step.
func (*StepBuilder) Always ¶
func (s *StepBuilder) Always(handlers ...*StepBuilder) *StepBuilder
Always runs handlers, in order, after this step settles, whether it succeeded or failed. Build handlers with Handler, not Step. Passing a *StepBuilder returned by Step is rejected at Build, since that step would then run twice: once on its own, once as this handler.
func (*StepBuilder) CacheEnv ¶
func (s *StepBuilder) CacheEnv(names ...string) *StepBuilder
CacheEnv names environment variables that belong in this step's cache key. Only a digest of each value enters the key, so a credential that reached the environment by mistake cannot reach a cache entry, which outlives the run directory.
Nothing is allowlisted by default, on purpose: keys built from the whole environment would differ between machines, and the cache would never hit for a reason nobody could see.
func (*StepBuilder) ContinueOnError ¶
func (s *StepBuilder) ContinueOnError() *StepBuilder
ContinueOnError lets dependents run even if this step fails. Use for advisory steps such as lint or coverage upload.
func (*StepBuilder) Env ¶
func (s *StepBuilder) Env(key, value string) *StepBuilder
Env sets one environment variable, as a key and a value:
Env("PNPM_HOME", "/pnpm-store").Env("CI", "1")
One pair per call, rather than a variadic list of pairs, whose arity the compiler cannot check: Env("A", "1", "B") would build, and the missing value could only be reported by Build, some distance from the typo.
The key must be non-empty and must not contain "=": Env("A=1", "2") would quietly produce the entry "A=1=2". Build reports either mistake.
Env on a FUNC step is refused at Build: the function receives a Ctx, not an environment, so the variable has no way to arrive, yet it would still move the step's cache key on the way to being dropped. Read the value in a closure where you call RegisterFunc, or pass it in the parameters Func records.
func (*StepBuilder) Generates ¶ added in v1.1.0
func (s *StepBuilder) Generates(g Generator) *StepBuilder
Generates declares that this step produces a plan fragment, spliced into the running graph when the step succeeds.
The step is otherwise ordinary: it runs, it can fail, it can be cached. A step that fails generates nothing, because a fragment is something a successful step produced.
func (*StepBuilder) ID ¶ added in v1.1.0
func (s *StepBuilder) ID() string
ID is the step's declared identifier.
Exists for building a fragment in a loop, where a step depends on the sibling it just created (see Fragment.Step). Without it the id is written twice, once to declare and once to depend on, and the second copy is where a typo lives: a mistyped need is caught at splice time, mid-run, rather than by the compiler.
func (*StepBuilder) Inputs ¶
func (s *StepBuilder) Inputs(sel ...artifact.Selector) *StepBuilder
Inputs declares the files this step reads. They are hashed into its cache key: you cannot hash what you have not declared.
func (*StepBuilder) Mount ¶
func (s *StepBuilder) Mount(m ...Mount) *StepBuilder
Mount realizes workspaces and scratch caches into this step. Whether an RO mount is actually enforced read-only depends on the executor; see RO.
func (*StepBuilder) Needs ¶
func (s *StepBuilder) Needs(ids ...string) *StepBuilder
Needs declares upstream STEPS that must finish first: the step-level dependency, naming step ids, distinct from the package-level Needs, which names whole workflows. Naming a step in another workflow is allowed (step ids are unique across the pipeline) and expresses a single edge rather than the barrier the workflow-level Needs gives you.
func (*StepBuilder) NoSnapshot ¶
func (s *StepBuilder) NoSnapshot() *StepBuilder
NoSnapshot suppresses the workspace snapshot this step would otherwise take when it settles. For a step whose filesystem output nobody consumes.
func (*StepBuilder) OnFailure ¶
func (s *StepBuilder) OnFailure(handlers ...*StepBuilder) *StepBuilder
OnFailure runs handlers, in order, when this step's attempts are exhausted and it still failed. Build handlers with Handler, not Step. Passing a *StepBuilder returned by Step is rejected at Build, since that step would then run twice: once on its own, once as this handler.
func (*StepBuilder) Outputs ¶
func (s *StepBuilder) Outputs(sel ...artifact.Selector) *StepBuilder
Outputs declares the files this step produces. They are stored when the step's result is saved and restored when it is served from cache, so a cached step still leaves behind what an uncached one would have.
func (*StepBuilder) Pure ¶
func (s *StepBuilder) Pure() *StepBuilder
Pure declares this step eligible for the action cache.
Steps are impure by default (never cached, never skipped), the correct default for a tool that can ssh into production and restart a service. Pure() is trusted rather than enforced: nothing sandboxes the network in this build, so it is a claim a reviewer can see, and `senro verify --recheck-pure` is how the claim is CHECKED: it re-runs a cached step against the exact input its key records and reports digests that do not come back the same.
A Pure() step must declare Inputs. Build refuses one that does not, because a key that cannot change when the sources change is worse than no cache at all.
func (*StepBuilder) Retry ¶
func (s *StepBuilder) Retry(maxAttempts int, p retry.Predicate) *StepBuilder
Retry lets this step run again, up to maxAttempts total tries, when p judges a failed attempt worth retrying. Backoff between attempts uses retry.Backoff's zero-value defaults; use RetryPolicy to set it explicitly.
Build records p.Serial(): a Plan is JSON and cannot carry a func, so a predicate built with retry.Func fails Build rather than silently becoming a policy that retries on every failure. Use retry.OnInfra, retry.OnExitCode, retry.OnLogMatch or retry.Any.
Retry on a HANDLER is refused: the engine runs a handler exactly once, so a policy declared on one would be recorded and never honoured. Retry the step instead, which exhausts its attempts before any handler runs.
func (*StepBuilder) RetryPolicy ¶
func (s *StepBuilder) RetryPolicy(policy retry.Policy) *StepBuilder
RetryPolicy is Retry for a caller that also wants to control backoff: the same declaration, so everything Retry documents (the predicate and the refusal on a handler alike) applies here too.
func (*StepBuilder) SecretEnv ¶
func (s *StepBuilder) SecretEnv(envName, field string) *StepBuilder
SecretEnv delivers a resolved secret to this step as a FILE, and puts that file's PATH into the named environment variable:
setup.Step("install", exec.Command("pnpm", "install")).
SecretEnv("NPM_TOKEN", "NPMToken")
field names a field of the struct handed to senro.WithSecrets. A field inside a named nested struct is spelled with a dot ("Registry.Token"); a field promoted from an embedded struct keeps its bare name.
The variable holds a PATH, not a value: a value in an environment variable is readable through /proc/<pid>/environ for the whole life of the process, where senro's redactor cannot reach. The value goes to a 0600 file in a tmpfs-preferring directory:
SecretEnv("NPM_TOKEN", "NPMToken") // NPM_TOKEN=/run/user/1000/senro-secret-xyz/NPMToken
// in the step: npm config set //registry.npmjs.org/:_authToken="$(cat "$NPM_TOKEN")"
Every declared secret ALSO arrives under the uniform name SENRO_SECRET_<NAME> (the field name uppercased, characters outside A-Z, 0-9 and _ replaced by _); SecretEnv's variable is the ergonomic second name for the same path. A run whose plan puts a resolved value into a command argument or an environment variable is refused before the first step starts, because both are visible outside the process (ps(1), /proc/<pid>/environ, shell history) where redaction cannot follow; see the secrets section of the README.
The secret's IDENTITY (its source URI, and a digest of its value salted with that URI) enters the step's cache key; the value never does. Naming one variable in both SecretEnv and CacheEnv is refused at build time: the path is per-attempt, so the key would change on every run and never hit.
func (*StepBuilder) Timeout ¶
func (s *StepBuilder) Timeout(d time.Duration) *StepBuilder
Timeout bounds how long a single attempt of this step may run.
func (*StepBuilder) When ¶
func (s *StepBuilder) When(c Condition) *StepBuilder
When gates one step on a condition, the same way the workflow-level When gates a whole workflow. A step in a workflow that also declares a workflow-level When must satisfy both; see the package-level When.
func (*StepBuilder) WorkDir ¶
func (s *StepBuilder) WorkDir(dir string) *StepBuilder
WorkDir sets the working directory the step's command runs in.
Refused at Build on a FUNC step: a func runs in the coordinator's own process, where the working directory is process-global, so honouring one would move every step running alongside it. Reach files through Ctx.Workspace instead.
type StepFailure ¶ added in v1.2.0
StepFailure is what a func HANDLER is told about the step it is cleaning up after, through Ctx.Failure. It is the same evidence an Exec handler reads out of SENRO_FAILURE_STEP, SENRO_FAILURE_STATE, SENRO_FAILURE_EXIT_CODE and SENRO_FAILURE_ATTEMPT, plus the error text and the failed attempt's log tail, which an environment is the wrong shape for.
func Collect(ctx senro.Ctx, p CollectParams) error {
f, ok := ctx.Failure()
if !ok {
return errors.New("collect only runs as a handler")
}
fmt.Fprintf(ctx.Stdout(), "%s ended %s (exit %d) on attempt %d\n",
f.Step, f.State, f.ExitCode, f.Attempt)
return nil
}
type TriggerRecord ¶
type TriggerRecord struct {
// Kind is what happened: push, pull_request, tag, schedule, manual.
Kind trigger.Kind `json:"kind"`
// Provider is where the event came from: github, or senro for the
// provider-neutral shape.
Provider string `json:"provider,omitempty"`
// Repo is the repository, "owner/name" for GitHub.
Repo string `json:"repo,omitempty"`
// Ref is the full git ref the event is about.
Ref string `json:"ref,omitempty"`
// Branch is the branch, which for a pull request is its base.
Branch string `json:"branch,omitempty"`
// Tag is the tag name, for a tag event.
Tag string `json:"tag,omitempty"`
// Action is a pull request's action.
Action string `json:"action,omitempty"`
// Number is a pull request's number.
Number int `json:"number,omitempty"`
// Schedule is the cron expression a scheduled event named.
Schedule string `json:"schedule,omitempty"`
// Matched is the declaration that claimed the event, as it reads:
// "push(branches=[main])".
Matched string `json:"matched"`
// Mode is how much of the repository this run covers. See trigger.Mode.
Mode trigger.Mode `json:"mode"`
// Base is what an affected-set computation would diff. See trigger.Base.
Base trigger.Base `json:"base,omitzero"`
// Files is how many paths the event's changed-file list held, or -1 when
// the provider supplied none. The count and not the paths: a monorepo
// push can carry thousands, and this file is provenance, not a diff.
Files int `json:"files"`
}
TriggerRecord is the provenance half of a RunManifest: the event that arrived, and what the pipeline concluded from it.
It deliberately carries NO parameters, neither the event's nor the matched trigger's. WithParams promises that a parameter value never lands in anything durable, and this file is durable and has no redactor in front of it. Parameters are the run's input; this records why it started.
type Unit ¶
Unit is what an expansion's Template is called with: one thing a unit graph discovered, such as a directory glob.Dirs matched. An alias, not a copy, so a caller passing one to a helper has nothing to convert.
type UnitAffector ¶
UnitAffector is a UnitGraph that can also say which unit owns a changed file and which units break when a unit changes, which is what ExpandBuilder.Affected needs to narrow a fan-out to what a change reaches.
A SEPARATE interface, because growing the published UnitGraph would break every implementation outside this repository. A graph without it gets ErrNoAffectedSet from Affected at build time rather than quietly covering everything.
Implement it only when you can answer HONESTLY: a wrong affected set skips the unit a change broke and reports a green build for a tree that does not build. Where an answer is unclear the answer is "affected". unit/glob, unit/pyproject and unit/bazel deliberately do not implement this.
type UnitGraph ¶
UnitGraph discovers the units an expansion fans out over.
Eight implementations ship, under github.com/xavidop/senro/unit: glob matches paths; gowork asks the Go toolchain; cargo, jswork, maven and gradle read the manifests of a Cargo, npm/pnpm/Yarn, Maven or Gradle workspace; pyproject discovers Python distributions; bazel discovers Bazel packages. All but glob, pyproject and bazel also implement UnitAffector.
WRITE YOUR OWN: a graph in your own module satisfies this and UnitAffector by having the methods, with nothing to register and nothing under internal/ to import. See https://senro.dev/docs/unit-graphs/ and examples/customgraph.
Units must be DETERMINISTICALLY ORDERED. Child step ids derive from the unit set in this order, so an order that varies between builds varies the plan and the digest every cache entry hangs off. Sort before returning; map iteration is the usual way this goes wrong.
type WorkflowBuilder ¶
type WorkflowBuilder struct {
// contains filtered or unexported fields
}
WorkflowBuilder accumulates the steps of one workflow.
func (*WorkflowBuilder) Expand ¶
func (w *WorkflowBuilder) Expand(id string, g UnitGraph) *ExpandBuilder
Expand adds one step per unit the graph discovers.
func (*WorkflowBuilder) Name ¶
func (w *WorkflowBuilder) Name() string
Name reports the workflow's name.
func (*WorkflowBuilder) Step ¶
func (w *WorkflowBuilder) Step(id string, a Action) *StepBuilder
Step adds a station to the workflow.
type WorkflowOption ¶
type WorkflowOption func(*workflowConfig)
WorkflowOption configures a workflow. See Needs, On and When.
func Needs ¶
func Needs(names ...string) WorkflowOption
Needs declares WORKFLOWS this workflow waits for: a barrier, the only form of workflow dependency there is. Every step of this workflow starts only once every step of each named workflow has settled.
It names WORKFLOWS; the step-level (*StepBuilder).Needs names STEPS. A name matching no workflow declared on the same pipeline is refused by Build, naming both sides, rather than being read as a step id.
Build lowers the barrier onto step edges: each of this workflow's entry steps gains a dependency on each named workflow's exit steps. A workflow with no steps satisfies the barrier immediately.
func On ¶
func On(target ExecutorTarget) WorkflowOption
On targets a workflow at an executor: Local, container.Image, k8s.Pod or ssh.Host. Build refuses a target this build cannot honour rather than silently running the steps on the coordinator instead.
A target other than Local is recorded as plan.Node.Executor on every step of the workflow: the executor decides where a step runs and its cache equivalence class, so a plan that did not record it could not be re-run faithfully. A workflow targeted at Local, or at nothing, records nothing, so every plan built before executors existed keeps its exact digest.
func When ¶
func When(c Condition) WorkflowOption
When gates every step of a workflow on a condition:
deploy := p.Workflow("deploy",
senro.Needs("build"),
senro.On(deployer),
senro.When(senro.Branch("main")))
A step whose conditions are not all true is SKIPPED, not failed: it settles as skipped_condition, its dependents settle the same way, and the run's status is unaffected, so a main-only deploy workflow leaves a pull request run green rather than partial.
Two When calls, or a workflow-level When plus a step-level one, are ANDed.
type WorkspaceOption ¶
type WorkspaceOption func(*workspaceConfig)
WorkspaceOption configures a workspace.
func Exclude ¶
func Exclude(patterns ...string) WorkspaceOption
Exclude keeps paths out of the workspace's snapshots. Patterns use the same syntax everywhere in senro: "*" and "?" within a segment, "**" across segments, and a trailing "/" for a directory and everything under it.
func MaxAge ¶
func MaxAge(d time.Duration) WorkspaceOption
MaxAge is how long a ScopePersistent workspace survives without being used. A run that finds one older than this evicts it and starts from an empty directory: a cold cache, never a failure.
"Used" means "leased by a run", recorded at release, so a workspace a nightly build touches every night never ages out however old the tree is.
Mandatory for ScopePersistent and refused for every other scope: how long a cache should outlive its last use is a property of the pipeline, not of senro. Evaluated only at the START of a run: deleting a directory a running step is reading is the exact hazard the workspace locks exist to prevent.
func MaxSize ¶
func MaxSize(bytes int64) WorkspaceOption
MaxSize is how large a ScopePersistent workspace's content may grow, in bytes, measured the way a snapshot measures it: the regular files that survive the workspace's excludes.
A workspace over the bound is evicted whole, not trimmed: half a dependency tree is a broken one. A workspace evicted on every run has a MaxSize set below what the tree actually needs; the ws.evicted event says which bound fired and by how much.
Enforced when a run releases the workspace and again when the next run leases it (so a run killed before releasing cannot leave an unbounded tree behind), never mid-run: see MaxAge. Mandatory for ScopePersistent and refused for every other scope.
func PreserveSymlinks ¶
func PreserveSymlinks() WorkspaceOption
PreserveSymlinks declares that this workspace's own directories literally named "node_modules" must survive a snapshot, not just the symlinks that point into them.
Every workspace excludes ".git" and "node_modules" by default, which is wrong for a workspace that IS a tree of symlinks, such as pnpm's node_modules: pnpm realizes each package under a directory ALSO named "node_modules", one level down, and the default would strip exactly what every symlink points at. Restoring such a snapshot would still produce the symlinks, every one pointing at nothing.
It widens this one workspace's default excludes to keep its node_modules-shaped directories; ".git" stays excluded regardless. It changes what a snapshot includes, so declaring it moves the plan's digest; omitting it does not.
type WorkspacePath ¶
type WorkspacePath = funcs.WorkspacePath
WorkspacePath is a mounted workspace's path, as Ctx.Workspace reports it.
type WorkspaceRef ¶
type WorkspaceRef struct {
// contains filtered or unexported fields
}
WorkspaceRef names a workspace. It is a declaration, not a directory: what directory it becomes is the executor's business.
func Workspace ¶
func Workspace(name string, opts ...WorkspaceOption) *WorkspaceRef
Workspace declares a named, versioned directory with a content digest.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package api defines senro's wire contract: the event envelope written to events.jsonl, the frame protocol spoken over the attach socket, and the fold that turns an event stream into RunState.
|
Package api defines senro's wire contract: the event envelope written to events.jsonl, the frame protocol spoken over the attach socket, and the fold that turns an event stream into RunState. |
|
schema
Package schema embeds senro's published JSON Schema documents.
|
Package schema embeds senro's published JSON Schema documents. |
|
Package artifact selects the files a step reads and the files it produces.
|
Package artifact selects the files a step reads and the files it produces. |
|
Package attach is senro's embedding API: the one call a pipeline's own main() makes to expose a live attach server, and hand the engine a Sink that fans events out to whoever connects.
|
Package attach is senro's embedding API: the one call a pipeline's own main() makes to expose a live attach server, and hand the engine a Sink that fans events out to whoever connects. |
|
Package change answers what a run is asked to build: everything, or the files a change touched.
|
Package change answers what a run is asked to build: everything, or the files a change touched. |
|
cmd
|
|
|
senro
command
Command senro is the CLI companion to the senro pipeline engine: it builds and runs an embedded pipeline (senro run), attaches to one already running or finished (senro attach), lists what's under ./runs (senro runs), opens a session on a live step (senro shell), reads what a run left behind (senro cache, senro ws, senro logs, senro verify), and serves a browser view of a live run (senro ui).
|
Command senro is the CLI companion to the senro pipeline engine: it builds and runs an embedded pipeline (senro run), attaches to one already running or finished (senro attach), lists what's under ./runs (senro runs), opens a session on a live step (senro shell), reads what a run left behind (senro cache, senro ws, senro logs, senro verify), and serves a browser view of a live run (senro ui). |
|
contrib
|
|
|
dispatcher
command
Command dispatcher receives a webhook, verifies it, and runs a pipeline binary: receive, verify, exec, forget.
|
Command dispatcher receives a webhook, verifies it, and runs a pipeline binary: receive, verify, exec, forget. |
|
genkitanalyzer
module
|
|
|
Package duration is where a fan-out's partition gets its balance from: how long each unit's step took last time, folded out of a run's event stream into a small file, and read back when the next pipeline is built.
|
Package duration is where a fan-out's partition gets its balance from: how long each unit's step took last time, folded out of a run's event stream into a small file, and read back when the next pipeline is built. |
|
examples
|
|
|
analyze
command
Command analyze runs a real pipeline with a failure analyzer wired in, and prints what it proposed and what the run did about it.
|
Command analyze runs a real pipeline with a failure analyzer wired in, and prints what it proposed and what the run did about it. |
|
attach
command
Command attach runs a short, two-step pipeline behind a live attach socket, the way a longer build or deploy pipeline would, so a second terminal can watch it while it's running.
|
Command attach runs a short, two-step pipeline behind a live attach socket, the way a longer build or deploy pipeline would, so a second terminal can watch it while it's running. |
|
basic
command
Command basic is the smallest pipeline that says something: two steps in one workflow, wired together with Needs, and a retry policy that only retries an infrastructure failure (a dropped connection, a registry hiccup), never a command that ran and simply returned a non-zero exit code.
|
Command basic is the smallest pipeline that says something: two steps in one workflow, wired together with Needs, and a retry policy that only retries an infrastructure failure (a dropped connection, a registry hiccup), never a command that ran and simply returned a non-zero exit code. |
|
customgraph
command
Command customgraph fans a step out over a unit graph that senro does not ship, written entirely against senro's published API: nothing here imports internal/, nothing is registered, and the graph below is an ordinary type with the right methods.
|
Command customgraph fans a step out over a unit graph that senro does not ship, written entirely against senro's published API: nothing here imports internal/, nothing is registered, and the graph below is an ordinary type with the right methods. |
|
extensions/fakeanalyzer
Package fakeanalyzer is a senro failure analyzer, written the way one in somebody else's repository would be: it imports github.com/xavidop/senro/api and nothing else of senro's (extension_static_test.go checks that; analyze_e2e_test.go drives it through a real run).
|
Package fakeanalyzer is a senro failure analyzer, written the way one in somebody else's repository would be: it imports github.com/xavidop/senro/api and nothing else of senro's (extension_static_test.go checks that; analyze_e2e_test.go drives it through a real run). |
|
extensions/gitlabcomment
Package gitlabcomment teaches senro to run for a comment on a GitLab merge request, written the way a provider in somebody else's repository would be: it imports github.com/xavidop/senro/trigger and nothing else of senro's (extension_static_test.go checks that; extension_e2e_test.go drives it through a real run).
|
Package gitlabcomment teaches senro to run for a comment on a GitLab merge request, written the way a provider in somebody else's repository would be: it imports github.com/xavidop/senro/trigger and nothing else of senro's (extension_static_test.go checks that; extension_e2e_test.go drives it through a real run). |
|
extensions/otelspan
Package otelspan turns a senro run's event stream into spans.
|
Package otelspan turns a senro run's event stream into spans. |
|
extensions/pagerduty
Package pagerduty is a senro notifier for PagerDuty's Events API v2, written the way one in somebody else's repository would be: it imports github.com/xavidop/senro/api and .../notify and nothing else of senro's (extension_static_test.go checks that; extension_e2e_test.go drives it through a real run).
|
Package pagerduty is a senro notifier for PagerDuty's Events API v2, written the way one in somebody else's repository would be: it imports github.com/xavidop/senro/api and .../notify and nothing else of senro's (extension_static_test.go checks that; extension_e2e_test.go drives it through a real run). |
|
monorepo
command
Command monorepo runs only the units a change affects.
|
Command monorepo runs only the units a change affects. |
|
notify
command
Command notify runs a short pipeline that reports to a webhook, and to a stand-in for Slack, without either of them existing anywhere but this process.
|
Command notify runs a short pipeline that reports to a webhook, and to a stand-in for Slack, without either of them existing anywhere but this process. |
|
otelexport
command
Command otelexport runs a real pipeline with an OpenTelemetry-shaped exporter wired in as an ordinary senro.Sink, and prints the span tree.
|
Command otelexport runs a real pipeline with an OpenTelemetry-shaped exporter wired in as an ordinary senro.Sink, and prints the span tree. |
|
server
command
Command server is a pipeline that IS its own webhook endpoint: one process that receives a delivery from GitHub, GitLab, Bitbucket or Gitea and runs the pipeline in place.
|
Command server is a pipeline that IS its own webhook endpoint: one process that receives a delivery from GitHub, GitLab, Bitbucket or Gitea and runs the pipeline in place. |
|
trigger
command
Command trigger is a pipeline that decides for itself whether an event is its business, which is the whole of what a trigger is for.
|
Command trigger is a pipeline that decides for itself whether an event is its business, which is the whole of what a trigger is for. |
|
webui
command
Command webui runs a pipeline shaped to exercise senro's browser UI, so there is something worth looking at while it runs.
|
Command webui runs a pipeline shaped to exercise senro's browser UI, so there is something worth looking at while it runs. |
|
workspace
command
Command workspace shows a workspace shared between two steps, and a downstream step opting into the action cache with Pure(), Inputs and Outputs.
|
Command workspace shows a workspace shared between two steps, and a downstream step opting into the action cache with Pure(), Inputs and Outputs. |
|
Package exec constructs command steps, the step kind portable to every executor.
|
Package exec constructs command steps, the step kind portable to every executor. |
|
executor
|
|
|
container
Package container targets a workflow at a container on the coordinator's own Docker daemon.
|
Package container targets a workflow at a container on the coordinator's own Docker daemon. |
|
k8s
Package k8s targets a workflow at a pod in a Kubernetes cluster.
|
Package k8s targets a workflow at a pod in a Kubernetes cluster. |
|
ssh
Package ssh targets a workflow at a command running on a remote host over SSH.
|
Package ssh targets a workflow at a command running on a remote host over SSH. |
|
internal
|
|
|
attachsrv
Package attachsrv implements the engine's attach surface: the hub that fans a run's event stream out to attached clients, and, in a later task, the server that exposes it over a socket.
|
Package attachsrv implements the engine's attach surface: the hub that fans a run's event stream out to attached clients, and, in a later task, the server that exposes it over a socket. |
|
binprov
Package binprov obtains the binary a remote step will be re-entered as.
|
Package binprov obtains the binary a remote step will be re-entered as. |
|
cache
Package cache is senro's action cache: the one that skips a step entirely because nothing it depends on changed.
|
Package cache is senro's action cache: the one that skips a step entirely because nothing it depends on changed. |
|
cas
Package cas is senro's content-addressed store: bytes in, a digest out, and the same bytes back for that digest anywhere the store is reachable.
|
Package cas is senro's content-addressed store: bytes in, a digest out, and the same bytes back for that digest anywhere the store is reachable. |
|
cgocheck
Package cgocheck finds cgo in a module's transitive dependencies.
|
Package cgocheck finds cgo in a module's transitive dependencies. |
|
cond
Package cond is pruning: a node that is in the plan and does not run.
|
Package cond is pruning: a node that is in the plan and does not run. |
|
dockerd
Package dockerd speaks the Docker Engine API over its unix socket.
|
Package dockerd speaks the Docker Engine API over its unix socket. |
|
dockerd/dockertest
Package dockertest gates the tests that need a real Docker daemon.
|
Package dockertest gates the tests that need a real Docker daemon. |
|
engine
Package engine is senro's scheduler: it turns a resolved plan into a running set of steps and an append-only event stream.
|
Package engine is senro's scheduler: it turns a resolved plan into a running set of steps and an append-only event stream. |
|
eventlog
Package eventlog writes a run's append-only ledger.
|
Package eventlog writes a run's append-only ledger. |
|
executor
Package executor defines where a step runs.
|
Package executor defines where a step runs. |
|
executor/containerexec
Package containerexec runs steps inside containers on the coordinator's own Docker daemon.
|
Package containerexec runs steps inside containers on the coordinator's own Docker daemon. |
|
executor/k8sexec
Package k8sexec runs steps as pods in a Kubernetes cluster.
|
Package k8sexec runs steps as pods in a Kubernetes cluster. |
|
executor/localexec
Package localexec runs steps as child processes on the coordinator's host.
|
Package localexec runs steps as child processes on the coordinator's host. |
|
executor/mountsnap
Package mountsnap captures one mounted workspace, the same way for every executor that shares the coordinator's filesystem.
|
Package mountsnap captures one mounted workspace, the same way for every executor that shares the coordinator's filesystem. |
|
executor/mountxfer
Package mountxfer moves one mounted workspace between the coordinator and an execution target that does not share its filesystem.
|
Package mountxfer moves one mounted workspace between the coordinator and an execution target that does not share its filesystem. |
|
executor/secretdir
Package secretdir owns the host directory a step's secret files live in.
|
Package secretdir owns the host directory a step's secret files live in. |
|
executor/sshexec
Package sshexec runs steps on a remote host over SSH.
|
Package sshexec runs steps on a remote host over SSH. |
|
executor/sshexec/sshdtest
Package sshdtest gates the tests that need a real SSH server, and pins that server to one this test run started itself.
|
Package sshdtest gates the tests that need a real SSH server, and pins that server to one this test run started itself. |
|
funcs
Package funcs is senro's registry of Go functions that are steps.
|
Package funcs is senro's registry of Go functions that are steps. |
|
kubeapi
Package kubeapi speaks the Kubernetes API over plain HTTPS and JSON.
|
Package kubeapi speaks the Kubernetes API over plain HTTPS and JSON. |
|
kubeapi/kindtest
Package kindtest gates the tests that need a real Kubernetes cluster, and pins that cluster to one this test run created with kind.
|
Package kindtest gates the tests that need a real Kubernetes cluster, and pins that cluster to one this test run created with kind. |
|
ndjson
Package ndjson decodes the newline-delimited JSON body that GET /api/stream answers with: a run's events, one JSON object per line, optionally terminated by the single api.StreamEndMarker line that says why the stream ended.
|
Package ndjson decodes the newline-delimited JSON body that GET /api/stream answers with: a run's events, one JSON object per line, optionally terminated by the single api.StreamEndMarker line that says why the stream ended. |
|
oci
Package oci is a small, standard-library-only client for the subset of the OCI distribution API a content-addressed cache needs: push a blob, pull a blob, ask whether one is there, and write the small manifest that names it.
|
Package oci is a small, standard-library-only client for the subset of the OCI distribution API a content-addressed cache needs: push a blob, pull a blob, ask whether one is there, and write the small manifest that names it. |
|
persist
Package persist owns the directories a ScopePersistent workspace lives in between runs: one tree per workspace name, on this machine, leased to one run at a time and bounded by an age and a size.
|
Package persist owns the directories a ScopePersistent workspace lives in between runs: one tree per workspace name, on this machine, leased to one run at a time and bounded by an age and a size. |
|
persist/kubelock
Package kubelock excludes two runs from one PersistentVolumeClaim-backed workspace, using a coordination.k8s.io Lease.
|
Package kubelock excludes two runs from one PersistentVolumeClaim-backed workspace, using a coordination.k8s.io Lease. |
|
plan
Package plan is the resolved timetable: what the engine executes.
|
Package plan is the resolved timetable: what the engine executes. |
|
ptyx
Package ptyx opens a pseudo-terminal on the two platforms senro targets.
|
Package ptyx opens a pseudo-terminal on the two platforms senro targets. |
|
redact
Package redact removes secret values from a byte stream.
|
Package redact removes secret values from a byte stream. |
|
remotecache
Package remotecache puts a shared, remote cache behind senro's local one, so two machines can reuse each other's work: a fresh CI runner starts warm instead of empty.
|
Package remotecache puts a shared, remote cache behind senro's local one, so two machines can reuse each other's work: a fresh CI runner starts warm instead of empty. |
|
render
Package render turns a run's folded state into output a human can read.
|
Package render turns a run's folded state into output a human can read. |
|
s3
Package s3 is a small, standard-library-only client for the subset of the S3 API a content-addressed cache needs: get, put, and head of one key.
|
Package s3 is a small, standard-library-only client for the subset of the S3 API a content-addressed cache needs: get, put, and head of one key. |
|
scratch
Package scratch is senro's best-effort cache: a mutable directory such as a module cache, restored by key with prefix fallbacks.
|
Package scratch is senro's best-effort cache: a mutable directory such as a module cache, restored by key with prefix fallbacks. |
|
secrets
Package secrets holds a run's resolved credentials.
|
Package secrets holds a run's resolved credentials. |
|
shellwire
Package shellwire is the frame format an interactive session speaks over one hijacked connection, and the only place either side of it is defined.
|
Package shellwire is the frame format an interactive session speaks over one hijacked connection, and the only place either side of it is defined. |
|
sink
Package sink defines the pipeline engine's one coupling to observers.
|
Package sink defines the pipeline engine's one coupling to observers. |
|
source
Package source defines the seam between a client (the TUI, the WASM browser UI, a scripted debugger) and wherever a run's events actually live: an attach server watching a live engine, or a directory left behind by one that already finished.
|
Package source defines the seam between a client (the TUI, the WASM browser UI, a scripted debugger) and wherever a run's events actually live: an attach server watching a live engine, or a directory left behind by one that already finished. |
|
stepchild
Package stepchild is the far side of a remote func step: this binary, staged on a target by a coordinator and re-entered there as `senro-<binaryDigest> __step --state-fd 0`, with the step's state as JSON on stdin and length-prefixed frames back on stdout (internal/stepwire is the protocol).
|
Package stepchild is the far side of a remote func step: this binary, staged on a target by a coordinator and re-entered there as `senro-<binaryDigest> __step --state-fd 0`, with the step's state as JSON on stdin and length-prefixed frames back on stdout (internal/stepwire is the protocol). |
|
stepid
Package stepid owns senro's step identifier grammar.
|
Package stepid owns senro's step identifier grammar. |
|
stepwire
Package stepwire is the protocol a remote step child speaks, and the only place either side of it is defined.
|
Package stepwire is the protocol a remote step child speaks, and the only place either side of it is defined. |
|
storage
Package storage is the one handle the engine holds on everything content-addressed: the CAS, the action cache, the scratch cache and the workspace snapshotter, all rooted in one directory.
|
Package storage is the one handle the engine holds on everything content-addressed: the CAS, the action cache, the scratch cache and the workspace snapshotter, all rooted in one directory. |
|
tail
Package tail is the client half of the attach protocol's resume contract: snapshot the run, tail from the snapshot's sequence number, and recover when the server's retained ring moves past you.
|
Package tail is the client half of the attach protocol's resume contract: snapshot the run, tail from the snapshot's sequence number, and recover when the server's retained ring moves past you. |
|
toml
Package toml reads the slice of TOML a package manifest is written in (Cargo.toml, pyproject.toml), so a unit graph needs no toolchain present.
|
Package toml reads the slice of TOML a package manifest is written in (Cargo.toml, pyproject.toml), so a unit graph needs no toolchain present. |
|
tui
Package tui is the interactive terminal client for a run's attach protocol: bubbletea + lipgloss.
|
Package tui is the interactive terminal client for a run's attach protocol: bubbletea + lipgloss. |
|
unit
Package unit is what an expansion expands over.
|
Package unit is what an expansion expands over. |
|
verify
Package verify re-executes cached Pure() steps and compares what they produce against what the action cache recorded for them.
|
Package verify re-executes cached Pure() steps and compares what they produce against what the action cache recorded for them. |
|
webui
Package webui serves senro's browser UI: a page, a Go client compiled to WebAssembly, and a view onto one live run that an operator can also steer.
|
Package webui serves senro's browser UI: a page, a Go client compiled to WebAssembly, and a view onto one live run that an operator can also steer. |
|
webui/client
command
This file exists so `go build ./...` and `go vet ./...` on an ordinary host still have a buildable package here: every other file is behind //go:build js && wasm, and a package whose files are all excluded is an error for ./..., not a skip.
|
This file exists so `go build ./...` and `go vet ./...` on an ordinary host still have a buildable package here: every other file is behind //go:build js && wasm, and a package whose files are all excluded is an error for ./..., not a skip. |
|
webui/present
Package present turns a folded api.RunState into the handful of strings a renderer draws, and nothing else.
|
Package present turns a folded api.RunState into the handful of strings a renderer draws, and nothing else. |
|
workspace
Package workspace turns a directory into a digest and a digest back into a directory.
|
Package workspace turns a directory into a digest and a digest back into a directory. |
|
Package notify sends a run's events somewhere outside the process: a webhook, a Slack channel, anything that speaks HTTP.
|
Package notify sends a run's events somewhere outside the process: a webhook, a Slack channel, anything that speaks HTTP. |
|
Package retry is the policy a pipeline author writes against to decide whether a failed step is worth running again.
|
Package retry is the policy a pipeline author writes against to decide whether a failed step is worth running again. |
|
Package trigger decides whether an incoming event is this pipeline's business.
|
Package trigger decides whether an incoming event is this pipeline's business. |
|
unit
|
|
|
bazel
Package bazel discovers the packages of a Bazel workspace.
|
Package bazel discovers the packages of a Bazel workspace. |
|
cargo
Package cargo discovers the crates of a Rust workspace, and knows which one depends on which.
|
Package cargo discovers the crates of a Rust workspace, and knows which one depends on which. |
|
glob
Package glob discovers units by matching paths, with no dependency graph.
|
Package glob discovers units by matching paths, with no dependency graph. |
|
gowork
Package gowork discovers Go modules and packages, and knows which one imports which.
|
Package gowork discovers Go modules and packages, and knows which one imports which. |
|
gradle
Package gradle discovers the projects of a Gradle build, and knows which one depends on which, when the build says so in a form that can be read.
|
Package gradle discovers the projects of a Gradle build, and knows which one depends on which, when the build says so in a form that can be read. |
|
jswork
Package jswork discovers the packages of a JavaScript workspace, and knows which one depends on which.
|
Package jswork discovers the packages of a JavaScript workspace, and knows which one depends on which. |
|
maven
Package maven discovers the modules of a Maven reactor, and knows which one depends on which.
|
Package maven discovers the modules of a Maven reactor, and knows which one depends on which. |
|
pyproject
Package pyproject discovers the distributions of a Python monorepo.
|
Package pyproject discovers the distributions of a Python monorepo. |