Authoring idiomatic pipelines

A pipeline's Plan method and each job's Work method build the DAG. Plan inspection and execution must produce the same structure. Put I/O and host-dependent decisions in registered job or step callbacks, which execute after dispatch and may repeat on retry.

sparkwing pipeline lint checks each Plan body and the guards: blocks in .sparkwing/sparkwing.yaml, reports each violation by rule name, and exits non-zero so it can gate a push or a CI job. sparkwing pipeline lint --rules prints the live rule set.

Adding a pipelineSection anchor link

Start with the scaffold so the Go registration and YAML catalog entry are created together:

sparkwing pipeline new --pipeline deploy --template minimal

sparkwing.Register connects a name to its Go implementation in the pipeline binary. .sparkwing/sparkwing.yaml defines the repository catalog and its triggers, defaults, and guards. sparkwing pipeline list reads that catalog without compiling Go, so a Go registration alone will not appear.

When adding a pipeline by hand, pair its registration in .sparkwing/jobs/:

func init() {
    sparkwing.Register("Deploy", func() sparkwing.Pipeline[sparkwing.NoInputs] {
        return &Deploy{}
    })
}

with an entry under pipelines: in .sparkwing/sparkwing.yaml:

pipelines:
  - name: deploy
    entrypoint: Deploy

entrypoint names the registration key, and name is what sparkwing run invokes. The two may differ, and several names may share one entrypoint. The pipeline reads the invoked name from RunContext.Pipeline. Run sparkwing pipeline list to confirm the catalog entry, then sparkwing pipeline lint to check the pipeline source and guards.

Sequencing jobs with NeedsSection anchor link

A multi-job pipeline dispatches in the order its edges require, not the order Plan calls Job. Needs declares that ordering: a job never dispatches until every job it needs has succeeded.

type Deploy struct{ sparkwing.Base }

func (p *Deploy) Plan(ctx context.Context, plan *sparkwing.Plan, in sparkwing.NoInputs, rc sparkwing.RunContext) error {
    build := sparkwing.Job(plan, "build", p.build)
    test := sparkwing.Job(plan, "test", p.test).Needs(build)
    sparkwing.Job(plan, "deploy", p.deploy).Needs(test)
    return nil
}

func (p *Deploy) build(ctx context.Context) error {
    _, err := sparkwing.Bash(ctx, "go build ./...").Run()
    return err
}

func (p *Deploy) test(ctx context.Context) error {
    _, err := sparkwing.Bash(ctx, "go test ./...").Run()
    return err
}

func (p *Deploy) deploy(ctx context.Context) error {
    return sparkwing.Bash(ctx, "./deploy.sh").MustBeEmpty("deploy failed")
}

test will not dispatch until build succeeds, and deploy waits on test in turn. A job can chain any number of upstream Needs; a job with none dispatches as soon as the runner has a slot. When a downstream job needs an upstream job's typed output rather than just its completion, wire a Ref and still add the Needs edge explicitly (see "Discarded Ref results" below) -- RefTo does not add the edge for you.

The Work return contractSection anchor link

A job with more than one step implements Workable instead of passing a plain func to Job: it declares a Work(w *sparkwing.Work) (*sparkwing.WorkStep, error) method, registers its steps onto w via Step, and returns.

type ExampleDeploy struct{ sparkwing.Base }

func (j *ExampleDeploy) Work(w *sparkwing.Work) (*sparkwing.WorkStep, error) {
    sparkwing.Step(w, "apply", j.apply)
    return nil, nil
}

func (j *ExampleDeploy) apply(ctx context.Context) error { return nil }

The two return values are the job's typed output step and a Plan-time materialization error. An untyped job -- one that does not embed Produces[T] -- has no output to designate, so it returns nil, nil once its steps are registered. A typed job returns the step whose value becomes the Produces[T] output that RefTo exposes downstream:

func (j *ExampleBuild) Work(w *sparkwing.Work) (*sparkwing.WorkStep, error) {
    compile := sparkwing.Step(w, "compile", j.compile)
    publish := sparkwing.Step(w, "publish", j.publish)
    publish.Needs(compile)
    return publish, nil
}

I/O in Plan (plan-io)Section anchor link

A Plan body that shells out, touches the filesystem, or makes an HTTP call runs that I/O every time the plan is read. The SDK's side-effect helpers refuse outright: sparkwing.Bash / Exec / Shell and anything in sparkwing/docker, sparkwing/git, or sparkwing/services panic through the runtime plan-guard, naming the call. Plain os, os/exec, and net/http calls have no such guard -- they run silently on every read, and this lint rule catches them. Move I/O, including configuration reads, into a registered job or step callback. Work also constructs the graph before dispatch and must remain pure.

Don't shell out while the DAG is built:

type Release struct{ sparkwing.Base }

func (p *Release) Plan(ctx context.Context, plan *sparkwing.Plan, in sparkwing.NoInputs, rc sparkwing.RunContext) error {
    sha, _ := sparkwing.Bash(ctx, "git rev-parse HEAD").Lines() // refused during plan construction
    sparkwing.Job(plan, "publish-"+sha[0], p.publish)
    return nil
}

func (p *Release) publish(ctx context.Context) error { return nil }

Do the I/O inside the registered job callback:

type Release struct{ sparkwing.Base }

func (p *Release) Plan(ctx context.Context, plan *sparkwing.Plan, in sparkwing.NoInputs, rc sparkwing.RunContext) error {
    sparkwing.Job(plan, "publish", p.publish)
    return nil
}

func (p *Release) publish(ctx context.Context) error {
    sha, err := sparkwing.Bash(ctx, "git rev-parse HEAD").Lines()
    if err != nil {
        return err
    }
    return sparkwing.Exec(ctx, "publish", sha[0]).MustBeEmpty("publish failed")
}

Choosing Bash versus Exec to run a shell commandSection anchor link

Choose by where the values in the command come from.

Use Exec whenever an argument is dynamic -- a branch name, an image tag, anything built from a variable. Exec runs the argv directly with no shell, so there is no quoting to get wrong and no way for a value containing $, backticks, or ; to be read as shell syntax:

tag := "app:" + sha
_, err := sparkwing.Exec(ctx, "docker", "push", tag).Run()

Reserve Bash for a command line that itself needs shell features -- a pipe, a redirect, a glob, a conditional. Pass any dynamic value in through .Env() instead of interpolating it into the line, so it never reaches the shell parser:

sparkwing.Bash(ctx, `git -C "$R" status --porcelain`).Env("R", repo).MustBeEmpty("dirty tree")

Interpolating an untrusted value straight into a Bash line is a shell-injection risk; Exec, or Bash with .Env(), avoids it.

Branching on the runtime environment (plan-runtime-branch)Section anchor link

Plan renders the same DAG wherever it runs, so plan inspection and dispatch agree on the shape. Reading os.Getenv, switching on runtime.GOOS, or calling IsLocal() in the body branches the structure on the host that happens to read it. Express the condition where it belongs: a job-level SkipIf, evaluated on the executing host, or a pipeline guard that gates the whole run.

Don't branch the DAG on the host environment:

func (p *Deploy) Plan(ctx context.Context, plan *sparkwing.Plan, in sparkwing.NoInputs, rc sparkwing.RunContext) error {
    if os.Getenv("ENV") == "prod" { // a different DAG depending on where Plan runs
        sparkwing.Job(plan, "deploy-prod", p.deployProd)
    }
    return nil
}

Declare the job unconditionally. After dependencies complete, its SkipIf callback runs on the executing host under the job's award, inside its concurrency slot. An environment read there sees that host's environment:

func (p *Deploy) Plan(ctx context.Context, plan *sparkwing.Plan, in sparkwing.NoInputs, rc sparkwing.RunContext) error {
    sparkwing.Job(plan, "deploy-prod", p.deployProd).
        SkipIf(func(ctx context.Context) bool { return os.Getenv("ENV") != "prod" })
    return nil
}

A skipped job satisfies Needs, so dependent jobs can still run. If skipping preparation must also skip its dependents, apply the predicate to those jobs or to a JobGroup containing them.

An approval's SkipIf runs in a separate evaluation job before requesting approval, holding no capacity. A true predicate marks the approval skipped and satisfies its dependencies. Approvals cannot declare concurrency groups. Put the group on the job after the approval so that job takes its slot on its executing host. The predicate can read its declared Needs, secrets and pipeline references on both run-host and controller-owned runs.

To gate the whole pipeline instead of one job, use a guards: block (see below).

Runner labels (runner-label)Section anchor link

The linter flags blank runner labels on Requires and Prefers. The host trims outer whitespace, drops empty labels and keeps the first copy of each label in declaration order. Both SDKs preserve the declarations for host processing. Alternatives within a selector term keep their existing OR semantics. An Inline() job executes on the dispatcher's host, where Requires and Prefers select no runner.

Avoid blank labels and labels on inline jobs:

sparkwing.Job(plan, "build", func(ctx context.Context) error { return nil }).Requires("")

sparkwing.Job(plan, "setup", func(ctx context.Context) error { return nil }).Inline().Requires("linux")

Do label the job that needs a runner, and leave the inline job to the dispatcher:

sparkwing.Job(plan, "build", func(ctx context.Context) error { return nil }).Requires("linux")
sparkwing.Job(plan, "setup", func(ctx context.Context) error { return nil }).Inline()

Discarded Ref results (unused-ref)Section anchor link

A Ref is the typed handle a downstream job reads an upstream job's output through. Creating one with RefTo and discarding it -- into _ or as a bare statement -- is dead code: either wire it into a job or drop the producing edge.

Don't throw the Ref away:

build := sparkwing.Job(plan, "build", &Build{})
_ = sparkwing.RefTo[BuildOut](build) // nothing reads this Ref

Do wire it into the job that consumes the output:

build := sparkwing.Job(plan, "build", &Build{})
out := sparkwing.RefTo[BuildOut](build)
sparkwing.Job(plan, "deploy", &Deploy{Build: out}).Needs(build)

Shared cache across a group (group-cache-shared)Section anchor link

JobGroup.Memo applies one key function to every member. A constant key makes every member share one result. Give members doing different work distinct keys.

Don't cache the group:

sparkwing.JobFanOut(plan, "matrix", goVersions, func(v string) (string, any) {
    return v, &Test{GoVersion: v}
}).Memo(func(ctx context.Context) (sparkwing.MemoValue, error) {
    return sparkwing.MemoKey("tests"), nil // one key for every Go version
})

Do key each member:

matrix := sparkwing.JobFanOut(plan, "matrix", goVersions, func(v string) (string, any) {
    return v, &Test{GoVersion: v}
})
for _, member := range matrix.Members() {
    version := member.ID()
    member.Memo(func(ctx context.Context) (sparkwing.MemoValue, error) {
        return sparkwing.MemoKey("tests", version), nil
    })
}

A key callback returns (MemoValue, error). Return errors when inputs cannot be read; return sparkwing.NoMemo, nil to bypass memoization. The executing host hashes the returned JSON value. Errors, panics, empty callback values, and resolution deadlines fail before the body runs. Use .Cache() to restore dependency directories before executing a job.

Configuring generated jobsSection anchor link

source.Generate receives a plan for jobs created after the source completes. Apply placement, retry and memo declarations to each generated job inside that callback. NeedsGenerated waits for the generator's settlement barrier, including an empty expansion.

source := sparkwing.Job(plan, "discover", &Discover{})
shards := sparkwing.RefTo[[]Shard](source)
generated := source.Generate(func(ctx context.Context, expansion *sparkwing.Plan) error {
    for _, shard := range shards.Get(ctx) {
        sparkwing.Job(expansion, shard.Name, &Bench{Shard: shard}).
            Requires("gpu").Retry(2)
    }
    return nil
})
sparkwing.Job(plan, "finish", func(context.Context) error { return nil }).
    NeedsGenerated(generated)

The host validates the complete expansion before accepting it. Each generator can declare at most 256 jobs. A failed source settles the barrier without executing the callback. See Generated jobs.

Declarative trigger filters and run guardsSection anchor link

The branches, paths, and actions fields under on.push and on.pull_request record intent. The controller dispatches the pipeline named by the webhook URL without reading those fields.

Use a pipeline guard when the policy depends on the branch Sparkwing checks out. when: [git:branch=main] blocks a run from any other checked-out branch before a step starts. The literal name matches the head branch; it does not match a pull request's base branch. git:branch=default matches the repository's default branch: a GitHub App trigger records it, and any other run reads origin's HEAD from its checkout, asking origin when the clone did not record it. A run whose default branch cannot be resolved is refused rather than judged without it. These guards do not implement path filters, custom pull-request actions, or pull-request base-branch matching; Triggers describes that boundary.

Unsatisfiable guards (guard-misuse)Section anchor link

A pipeline's guards: block gates dispatch on where the run is dispatched, args, and git branch -- where:local / where:controller, arg:FLAG=VALUE, and git:branch=NAME / git:branch=default. when blocks the run when not every token matches; unless blocks it when any token matches. A token in both lists or a when naming two mutually exclusive execution locations prevents dispatch. A duplicate token is redundant. The linter reports both kinds of defect. The default token matches only when the dispatch supplies default-branch metadata; use a literal branch for controller webhook and local trigger claims.

Don't write guards that can never all hold:

# .sparkwing/sparkwing.yaml
pipelines:
  - name: deploy
    entrypoint: Deploy
    guards:
      when: [where:local, where:controller] # mutually exclusive
      unless:  [where:controller]                # also rejected -> contradiction

Do pick tokens that can be satisfied together:

# .sparkwing/sparkwing.yaml
pipelines:
  - name: deploy
    entrypoint: Deploy
    guards:
      when: [where:controller]  # run only on a controller
      unless:  [git:branch=main]     # never from the checked-out main branch

Running the linterSection anchor link

sparkwing pipeline lint            # every pipeline in the repo
sparkwing pipeline lint --pipeline deploy    # one pipeline by name
sparkwing pipeline lint --rules          # print the rule charters

Add -o json for machine-readable findings. Point --dir at a source tree other than the convention (.sparkwing/jobs). A non-zero exit on any finding makes the command a drop-in gate for a pre-push hook or a CI job.