Migrating to v0.4.0

Big release. The SDK's public surface is reshaped (renames, typed interfaces, removed methods) and several packages move between pkg/ and internal/. Plan on hitting many compile errors in one pass.

The good news: every change is mechanical or close to it. Most adopters can migrate via a sequence of grep+replace passes plus a few recompile-fix cycles. There are no semantic behavior changes that require code-flow rethinking -- everything that used to work still works, just under a different name or with a different signature.

Suggested order for the migration:

  1. Update imports first. package-relocations moves several packages between pkg/ and internal/. Run an import-rewrite pass and recompile to see which symbols are gone or moved.
  2. Apply the type renames. node-job-rename and spawn-types rename author-visible Go types. Search and replace *Node*JobNode, etc.
  3. Update method signatures. typed-dep-interfaces, cacheoptions-rename, risk-labels, and sdk-surface-cleanup change call sites in pipeline definitions.
  4. Replumb runtime-mutator calls. runtime-plumbing if you had any (you almost certainly didn't; pipeline authors don't call this).
  5. Update CLI usage. cli-flag-renames, cli-retired-flags, cli-output-aliases, and no-cache-env-rename cover scripts, CI, and shell aliases that pinned old flag names, the --json / --pretty output shortcuts, or the SPARKWING_NO_CACHE env var.
  6. Update YAML files. pipelines-yaml-group for group: removal. The new declarative target/runner YAML files are additive -- existing pipelines without them keep working.
  7. Last-mile cleanup. trigger-values, logrecord-fields, store-maintenance, cipher-interface cover specific surfaces.

node-job-renameSection anchor link

The SDK's DAG vertex types renamed from *Node/*NodeGroup to *JobNode/*JobGroup. The package-level constructors sparkwing.Job and sparkwing.JobGroup keep their names -- the Go function Job needed a non-clashing type name, hence *JobNode. Runner-selection methods got renamed at the same time: RunsOnRequires, RunsOnLabelsRequiresLabels.

Before:

build := sparkwing.Job(plan, "build", &Build{}).
    RunsOn("docker").
    OnTarget("prod")

group := sparkwing.GroupJobs(plan, "tests", testA, testB).
    RunsOn("linux", "fast")

func (b *Build) Work(w *sparkwing.Work) (*sparkwing.WorkStep, error) {
    return sparkwing.Step(w, "compile", b.run), nil
}

After:

build := sparkwing.Job(plan, "build", &Build{}).
    Requires("docker").
    OnTarget("prod")

group := sparkwing.GroupJobs(plan, "tests", testA, testB).
    Requires("linux", "fast")

func (b *Build) Work(w *sparkwing.Work) (*sparkwing.WorkStep, error) {
    return sparkwing.Step(w, "compile", b.run), nil
}

Why: The DAG vertex isn't really a "Node" -- it's a Job (in pipeline terminology) that gets dispatched. *JobNode aligns the type name with the constructor (sparkwing.Job(...) returns a *JobNode). RunsOnRequires makes the runner-selection language explicit: the job requires a runner with these labels.

Edge cases:

  • JSON wire tags (node, node_id, runs_on, node_start, ...) are preserved on the wire -- this is a Go-type rename, not a wire-format change. Persisted logs and snapshots from prior versions read back correctly.
  • LogRecord.Node field is now LogRecord.JobID in Go, but its JSON tag is still node. If you decode LogRecord from JSON, the JSON field name didn't change.
  • Internal renames in internal/orchestrator/ ride along, but those aren't part of the public surface so adopters don't see them.

typed-dep-interfacesSection anchor link

Needs(...) and NeedsOptional(...) on every dep-accepting type switched from ...any to typed interfaces. Plan-layer methods take ...Dep; Work-layer methods take ...WorkDep. The two interfaces are disjoint (closed via unexported marker methods), so a Work-layer step can't accidentally be passed to a Plan-layer Needs call (or vice versa) -- those are compile-time errors now.

By-name string references (n.Needs("step-id")) are no longer accepted at all. The interfaces only admit live handles.

Before:

fetch := sparkwing.Step(w, "fetch", b.fetch)
sparkwing.Step(w, "compile", b.compile).Needs("fetch")  // string-by-name

// Or with a slice of handles:
parts := []*sparkwing.WorkStep{a, b, c}
combined := sparkwing.Step(w, "combine", b.combine).Needs(parts)

// Or mixed types via any:
sparkwing.Step(w, "deploy", b.deploy).Needs([]any{a, b, "static-id"}...)

After:

fetch := sparkwing.Step(w, "fetch", b.fetch)
sparkwing.Step(w, "compile", b.compile).Needs(fetch)  // typed handle

// Slice splat:
parts := []*sparkwing.WorkStep{a, b, c}
combined := sparkwing.Step(w, "combine", b.combine).Needs(parts...)

// Mixed types: pass them directly:
sparkwing.Step(w, "deploy", b.deploy).Needs(a, b)
// The "static-id" string is gone -- there's no by-name option.

Why: The old ...any signature accepted anything that compiled, then validated at runtime via a type switch. Passing 42 or a typo'd field reference would compile and fail with a runtime panic. The typed interface forces the compiler to catch the bug at the call site, which matters more in pre-1.0 when the SDK is still moving.

By-name references were removed because every use case has a handle-based alternative -- you create the step, you have its handle. Patterns that built deps from yaml or other dynamic sources need a two-pass construction (create all steps, store handles in a map, then wire deps using the handles).

Edge cases:

  • Needs([]*JobNode{...}) as a single-arg slice no longer compiles; splat the slice: Needs(slice...).
  • Needs(42) no longer compiles (previously a runtime panic).
  • The *JobGroup dynamic-membership special case in *JobNode.Needs is preserved -- passing a dynamic group still resolves membership at dispatch, not at call time. Only the entry-point type changed.
  • The reflection-based "embedded *WorkStep via reflection" unwrap path on the Work layer is removed. No code in the repo used it; the typed interface makes the embed-and-unwrap pattern moot.
  • The typo-detection validator at sparkwing/plan_validate.go is removed (the typo path no longer exists at the API). The CLI-flag --sw-start-at / --sw-stop-at typo-detection in internal/sparkwingruntime/plan_validate.go is unaffected -- operator input is still a string at that boundary.

cacheoptions-renameSection anchor link

CacheOptions.KeyNamespace. CacheOptions.CacheKeyContentHash. HasKey()HasNamespace(). Hard cut -- old names are deleted, not aliased.

Before:

sparkwing.Job(plan, "build", &Build{}).Cache(sparkwing.CacheOptions{
    Key:      "build-coordination",
    CacheKey: func(ctx context.Context) sparkwing.CacheKey {
        return sparkwing.Key("v1", repoSHA(ctx))
    },
    Max:     1,
    OnLimit: sparkwing.Coalesce,
})

if got.HasKey() { ... }

After:

sparkwing.Job(plan, "build", &Build{}).Cache(sparkwing.CacheOptions{
    Namespace:   "build-coordination",
    ContentHash: func(ctx context.Context) sparkwing.CacheKey {
        return sparkwing.Key("v1", repoSHA(ctx))
    },
    Max:     1,
    OnLimit: sparkwing.Coalesce,
})

if got.HasNamespace() { ... }

Why: The old Key field name was overloaded with CacheKey, the content-hash function. Two unrelated nodes that happened to return the same empty CacheKey() from a missing-input branch would collapse into one cache entry -- a real bug we hit. The new names separate the two concepts: Namespace is the coordination scope (a string identifying which cache to use); ContentHash is the function that hashes the inputs.

Edge cases:

  • sparkwing.NoCache is now available as a typed sentinel return for ContentHash when an invocation should explicitly opt out of memoization. Returning NoCache is distinct from returning the zero CacheKey: operators see "explicit opt-out" in logs vs a "missing key" warning. New code should prefer NoCache over return sparkwing.CacheKey("") for opt-out paths.
  • The rejectTypoShape plan-time guard now reads Namespace -- a literal with Max/OnLimit/ContentHash set but Namespace empty panics at plan time as before, just with the new field name in the message.

spawn-typesSection anchor link

JobSpawn(...) now returns *SpawnSpec (was *SpawnHandle). JobSpawnEach(...) now returns *SpawnGenSpec (was *SpawnGroup). Chainable methods (Needs, SkipIf) live on the spec types directly. The Spec() accessors on the old handle types are gone -- the handles were thin wrappers around the specs and have been collapsed away.

Before:

build := sparkwing.JobSpawn(w, "build", &Build{}).Needs(fetch).SkipIf(skipFn)
spec := build.Spec()  // peel out the underlying SpawnSpec
log.Printf("spawn id: %s", spec.ID)

each := sparkwing.JobSpawnEach(w, "shards", shards, mkBuild).Needs(setup)
genSpec := each.Spec()

After:

build := sparkwing.JobSpawn(w, "build", &Build{}).Needs(fetch).SkipIf(skipFn)
// build is already the *SpawnSpec; no Spec() needed
log.Printf("spawn id: %s", build.ID)

each := sparkwing.JobSpawnEach(w, "shards", shards, mkBuild).Needs(setup)
// each is the *SpawnGenSpec directly

Why: The handle/spec split was a holdover from an earlier design. The handles never added behavior beyond delegating to the spec; the indirection was overhead with no payoff.

Edge cases:

  • Code that chains JobSpawn(...).Needs(...).SkipIf(...) is unchanged -- both methods are now directly on the spec.
  • Variables typed *SpawnHandle or *SpawnGroup must update to *SpawnSpec / *SpawnGenSpec. Function signatures and struct fields holding these types update the same way.
  • Spec() callers can drop the call entirely.

risk-labelsSection anchor link

WorkStep.Destructive() / .AffectsProduction() / .CostsMoney() replaced by a single .Risk(label) method. Labels are now author-defined: any kebab-case string works.

Before:

sparkwing.Step(w, "deploy", b.deploy).Destructive().AffectsProduction()
sparkwing.Step(w, "rotate", b.rotate).CostsMoney()

After:

sparkwing.Step(w, "deploy", b.deploy).Risk("destructive", "prod")
sparkwing.Step(w, "rotate", b.rotate).Risk("money", "rotates-key")

CLI flag changes (--sw-allow-destructive--sw-allow destructive) covered separately under cli-flag-renames.

Why: Three special-case methods didn't extend -- adding a fourth risk dimension required code in sparkwing. Author-defined kebab-case labels mean pipelines can declare any risk they care about (rotates-key, large-cost, affects-prod-db, ...) without an upstream change.

Edge cases:

  • profiles.yaml auto_allow field is now a list of labels: auto_allow: [destructive, prod]. The old per-marker booleans (auto_allow_destructive: true) are gone -- update profile files.
  • WorkStep.Risks() returns the declared labels, sorted; useful for custom CLI gates.
  • The BlastRadius Go type and its constants (BlastRadiusDestructive, BlastRadiusAffectsProduction, BlastRadiusCostsMoney) are removed along with the IsValid() / AllBlastRadii() helpers. The label space is author-defined now, so there is no replacement type -- pipeline code that referenced these symbols passes plain string labels to Risk(...) instead. The BlastRadiusBlockedError typed error is replaced by RiskBlockedError with the same shape.

sdk-surface-cleanupSection anchor link

Renames and removals across the sparkwing package:

Renames:

  • JobNode.OnTargetList()OnTargets()
  • WorkStep.OnTargetList()OnTargets()

Removals (each call site needs to be deleted or replaced):

RemovedReplacement
JobNode.OnFailureNodeID()OnFailureNode() with a nil check
JobNode.Dynamic() / IsDynamic()Plan.IsDynamicNode(id) (auto-detects ExpandFrom sources)
sparkwing.ToKebabCaseInline your own (the helper had no callers)
sparkwing.LookupInstanceNo callers; remove the call site
sparkwing.Runtime() aliassparkwing.CurrentRuntime()
sparkwing.WithJob / JobFromContext / JobStackFromContextNo replacement; nested-job breadcrumbs were superseded by Ref / RunAndAwait / SpawnNode
sparkwing.SetDebug (was exported)SPARKWING_DEBUG=1 env var at process start

Why: All of these had either zero production callers (the package's own tests were the only consumer) or a clean alternative already shipping. The aliases and helpers were keeping the surface noisy.

Edge cases:

  • LogRecord.Job and LogRecord.JobStack Go fields are removed. Their always-empty JSON tags (job, job_stack) disappear from the wire shape too -- see logrecord-fields.
  • Tests outside the sparkwing module that called sparkwing.SetDebug won't compile. Move them to a _test.go file inside the sparkwing module if you genuinely need to flip the flag mid-run; otherwise set SPARKWING_DEBUG=1 at process start.

runtime-plumbingSection anchor link

Roughly 30 plumbing symbols that were always orchestrator-only have moved out of the sparkwing package into internal/sparkwingruntime. Pipeline authors never called these -- they were always for code rebuilding the orchestrator. If you're a pipeline author, this section doesn't affect you.

If you're an orchestrator-implementer or have test code that poked at runtime internals, the symbols moved are:

  • Plan-layer plumbing: GuardPlanTime, IsPlanTime, ValidateStepRange, SuggestClosest, PreviewPlan
  • Pipeline-registration plumbing: WithPipelineResolver, WithPipelineAwaiter, DescribeAll, DescribePipelineByName
  • Deep plumbing: WithInputs, WithPipelineSecrets, DecodePipelineConfig, ResolvePipelineSecrets
  • Context plumbing: WithLogger, WithNode
  • Six sw:"..." struct-tag reflection helpers extracted to internal/swtags: parseSWTags, coerceAssign, toString, toBool, toInt64, toFloat64

Runtime-mutator methods (previously on *Plan, *JobGroup, *WorkStep, *SpawnSpec) are also no longer methods on those types; they live behind a sparkwing.RuntimePlumbing.Fns bridge:

Before:

plan.InsertChild(child)
group.Finalize(members)
step.Fn()(ctx)
step.MarkDone(out)
spec.SetResolvedID("x")
spec.MarkDone(out)

After:

sparkwing.RuntimePlumbing.Fns.PlanInsertChild(plan, child)
sparkwing.RuntimePlumbing.Fns.JobGroupFinalize(group, members)
sparkwing.RuntimePlumbing.Fns.WorkStepFn(step)(ctx)
sparkwing.RuntimePlumbing.Fns.WorkStepMarkDone(step, out)
sparkwing.RuntimePlumbing.Fns.SpawnSpecSetResolvedID(spec, "x")
sparkwing.RuntimePlumbing.Fns.SpawnSpecMarkDone(spec, out)

Why: These methods polluted IDE autocomplete on author-facing types. Moving them behind a bridge keeps them callable from internal/orchestrator/ (the legitimate consumer) without tempting pipeline authors to call them. The RuntimePlumbing value gains a {Keys, Fns} shape -- existing RuntimePlumbing.<Key> accessors move to RuntimePlumbing.Keys.<Key>.

Symbols that remain in sparkwing for external orchestrator implementers: WithPipelineConfig, WithSecretResolver, ResolvePipelineConfig, LoggerFromContext, NodeFromContext. These are the platform-extensibility primitives that an alternative orchestrator legitimately needs.


package-relocationsSection anchor link

Package layout reorganized to clarify the public/private boundary:

Old pathNew pathNotes
orchestrator/internal/orchestrator/Migrate to pkg/runner.Main().
secrets/internal/secrets/Implement pkg/controller.Cipher instead. See cipher-interface.
logs/pkg/logs/Promoted: now part of the public surface.
controller/client/pkg/controller/client/Promoted.
logutil/internal/logutil/Demoted: implementation detail.
bincache/internal/bincache/Demoted.
otelutil/internal/otelutil/Demoted.
profile/internal/profile/Demoted.
repos/internal/repos/Demoted.
internal/local/(collapsed into pkg/controller/)Mode is now determined by functional options (AttachPool for cluster; WithArtifactStore + WithReconcileHook for laptop).
pkg/controller/InProcessDispatcherinternal/inprocdispatch/Demoted. External consumers use pkg/controller.NoopDispatcher or supply their own pkg/controller.Dispatcher.

orchestrator/store/ was promoted to pkg/store/ in v0.3.0; if your adoption already uses pkg/store, no change.

Before:

import (
    "github.com/sparkwing-dev/sparkwing/orchestrator"
    "github.com/sparkwing-dev/sparkwing/logs"
    "github.com/sparkwing-dev/sparkwing/secrets"
    "github.com/sparkwing-dev/sparkwing/controller/client"
)

func main() {
    orchestrator.Main()
}

After:

import (
    "github.com/sparkwing-dev/sparkwing/pkg/runner"
    "github.com/sparkwing-dev/sparkwing/pkg/logs"
    // secrets/ is gone; implement pkg/controller.Cipher (Seal+Open)
    "github.com/sparkwing-dev/sparkwing/pkg/controller/client"
)

func main() {
    runner.Main()
}

Why: The Go-enforced internal/ boundary turns "what's covered by the stability promise" from a doc claim into a compiler-enforced fact. Anything under internal/ cannot be imported from another repo, so adopters can't accidentally depend on it.

Edge cases:

  • .sparkwing/main.go template now imports pkg/runner. Newly scaffolded user repos pick up the new path. Existing user repos can update their .sparkwing/main.go to swap orchestrator.Main()runner.Main() -- the change is one line.
  • Sibling repos that imported the demoted packages directly (logutil, bincache, etc.) will fail on next go.mod bump. Inline the small subset of those packages you actually use, or vendor a copy. None of them were intended to be public.
  • The collapse of internal/local/ into pkg/controller/ eliminates ~30 duplicated files. The same control-plane code now serves both modes; the runtime mode is determined by which functional options the consumer sets at server-construction time.

store-maintenanceSection anchor link

The 9 reaper / sweep methods on pkg/store.Store (ReapExpiredTriggers, FailNodesInRun, FailStaleQueuedNodes, FailExpiredNodeClaims, ReapStaleConcurrencyHolders, ReapStaleConcurrencyWaiters, SweepExpiredConcurrencyCache, SweepLRUConcurrencyCache, ReconcileConcurrencyKeys) are no longer on the public Store API. Call them via store.Maintenance.<Name>(s, ctx, ...).

Before:

_, err := s.ReapExpiredTriggers(ctx)
_, err := s.SweepLRUConcurrencyCache(ctx, 1000)

After:

_, err := store.Maintenance.ReapExpiredTriggers(s, ctx)
_, err := store.Maintenance.SweepLRUConcurrencyCache(s, ctx, 1000)

Why: These are crash-recovery and TTL sweeps the controller runs on a schedule. External adopters had no use case for them -- exposing them on the main Store API was polluting IDE autocomplete for the 99% of callers that read and write rows.

Edge cases:

  • The bridge functions take the store as their first explicit argument: store.Maintenance.<Name>(s, ctx, ...).
  • All 9 methods are still implemented -- only the access path changed.

cipher-interfaceSection anchor link

pkg/controller.Server.WithSecretsCipher now takes a pkg/controller.Cipher interface instead of a concrete *secrets.Cipher. The secrets/ package itself moved to internal/secrets/.

Before:

import "github.com/sparkwing-dev/sparkwing/secrets"

cipher, _ := secrets.NewCipher(key)
server, _ := controller.New(opts.WithSecretsCipher(cipher))

After:

// Option A: implement pkg/controller.Cipher with your own crypto:

type myCipher struct { ... }
func (c *myCipher) Seal(plaintext []byte) ([]byte, error) { ... }
func (c *myCipher) Open(ciphertext []byte) ([]byte, error) { ... }

server, _ := controller.New(opts.WithSecretsCipher(&myCipher{}))

// Option B: vendor the cipher from the (now-internal) secrets package
// if you specifically need sparkwing's AEAD implementation.

Why: The signature change decouples the controller from sparkwing's internal AEAD implementation. External consumers can plug in their own KMS-backed cipher, HSM, or whatever fits their deployment.

Edge cases:

  • The old secrets package is now internal/secrets/, so external adopters can no longer import secrets.Cipher directly from another module. Any code that declared *secrets.Cipher by name in its own signatures (e.g., func MyHelper(c *secrets.Cipher)) won't compile; the import path is unreachable. Implement pkg/controller.Cipher yourself, OR vendor the cipher code from internal/secrets/ if you need sparkwing's exact AEAD implementation.
  • The secrets.NewCipher constructor isn't exported anymore. Same options as above: implement your own, or vendor.
  • If you'd rather sparkwing publish its AEAD cipher as a public package so vendoring isn't required, file an issue -- happy to consider it if there's demand.

cli-flag-renamesSection anchor link

Five flag renames + one consolidation:

OldNewNotes
--sw-change-directory--sw-cd-C short form unchanged
--sw-for--sw-targetJob.OnTarget("...") author API unchanged
--sw-on--sw-profileArgument NAMEPROFILE
--sw-from--sw-refEnv-var bridge SPARKWING_FROMSPARKWING_REF
--sw-allow-destructive / --sw-allow-prod / --sw-allow-money--sw-allow LABEL[,LABEL...]Repeatable; comma-separated allowed

Before:

sparkwing run deploy --sw-change-directory /work --sw-for prod \
    --sw-on staging --sw-from main \
    --sw-allow-destructive --sw-allow-prod

After:

sparkwing run deploy --sw-cd /work --sw-target prod \
    --sw-profile staging --sw-ref main \
    --sw-allow destructive,prod

Why: The new flag names are shorter, the verbs are clearer (--sw-target says "which target am I running against?" more directly than --sw-for), and --sw-allow aligns with the author-defined risk labels from risk-labels.

Edge cases:

  • CI scripts, shell aliases, and tab-completion configs need updating. The old flag names are removed -- they error rather than silently falling through.
  • Profile auto_allow field switches from per-marker booleans (auto_allow_destructive: true) to a list of labels (auto_allow: [destructive, prod]). Update profile YAML files.
  • Job.OnTarget("...") in pipeline code is unchanged -- only the CLI flag renamed.

cli-retired-flagsSection anchor link

Several flags removed outright. Each has a replacement path in the pipeline or via a different command:

RemovedPath forward
--sw-retry-of / --sw-fullsparkwing runs retry RUN_ID [--failed | --all]
--sw-job / --sw-preferDeclare in the pipeline via Job.Requires / Job.Prefers
--sw-backends-envFix match: rules in backends.yaml or DetectEnvironment logic
--sw-configPreset feature removed; pass values explicitly or use a profile
--help-all--help now shows everything (no tiered help)

The flag-group section headers in --help and tab-completion are also dropped -- everything renders as one flat list now.

Why: Each removed flag was either an alternative path for something that has a cleaner home (runs retry for the rerun verbs, plan-layer methods for runner selection) or a defunct preset feature that didn't earn the surface area.

Edge cases:

  • The wing CLI binary is also retired (see CHANGELOG). sparkwing run is the only entry point.
  • Tab-completion scripts will no longer surface the retired flags. Regenerate completion: sparkwing completion bash > ... (and zsh/fish equivalents).

cli-output-aliasesSection anchor link

The shorthand --json and --pretty flags are removed from every command that previously accepted them. Use the canonical --output form.

Before:

sparkwing runs list --json
sparkwing pipeline describe --name X --pretty
sparkwing version --json
sparkwing runs logs --run run-... --pretty | less -R

After:

sparkwing runs list -o json     # or --output json
sparkwing pipeline describe --name X -o pretty
sparkwing version -o json
sparkwing runs logs --run run-... -o pretty | less -R

Why: Two flag forms for the same output selection (--json vs -o json) is a soft-deprecation shape -- there's no benefit beyond keystrokes saved, and it bloats --help, tab-completion, and agent-discovery surfaces (sparkwing info, sparkwing info --for-agent, sparkwing commands). One canonical form is clearer for adopters and easier to document.

Edge cases:

  • CI scripts and shell aliases pinned on --json / --pretty will fail with "unknown flag" rather than silently falling through.
  • Tab-completion configs regenerate with the new surface: sparkwing completion bash > ... (and zsh/fish equivalents).
  • The output-format convention is documented in one place now: -o <format> or --output <format>, where <format> is pretty, json, or plain (per command; some verbs accept a subset).
  • sparkwing repo list previously only accepted --json; it now takes -o json like every other verb.

no-cache-env-renameSection anchor link

SPARKWING_NO_CACHE env var renamed to SPARKWING_NO_BINCACHE. A new SPARKWING_NO_CACHE env var now gates the per-node result cache (paired with the new --sw-no-cache CLI flag).

Before:

SPARKWING_NO_CACHE=1 sparkwing run build   # bypassed bincache

After:

SPARKWING_NO_BINCACHE=1 sparkwing run build  # same behavior, new name

Why: Two unrelated caches both named "no cache" produced a genuine footgun. The per-node result cache (what --sw-no-cache controls) is what most operators mean when they want a "fresh run"; the bincache (compiled-pipeline-binary cache) is build-system tooling that's mostly invisible to day-to-day operators. The new naming matches the CLI flag (--sw-no-cache <-> SPARKWING_NO_CACHE).

Edge cases:

  • SPARKWING_NO_CACHE=1 set in an old shell config now silently bypasses the per-node result cache instead of the bincache. Behaviorally surprising if the operator expected the old meaning. Audit your environment configs.
  • Compose scripts, CI configs, k8s ConfigMaps all need updating if they set SPARKWING_NO_CACHE expecting the old semantics.

pipelines-yaml-groupSection anchor link

The group: field on pipelines.yaml entries and the matching --group flag on sparkwing pipeline new are removed. The field had no backing on the pipelines.Pipeline struct, so strict YAML parsing rejected any file that used it -- breaking sparkwing pipeline list and sparkwing run tab-completion silently.

Before:

pipelines:
  - name: build
    description: Build the app
    group: build-pipelines    # rejected by strict YAML parser

After:

pipelines:
  - name: build
    description: Build the app

Why: A field with no Go-struct backing produces a parser error; removal restores pipeline list and tab-completion against files that had been silently broken.

Edge cases:

  • Plan-DAG grouping (sw.GroupJobs, sw.GroupSteps) is a separate feature for declaring named bundles in the DAG. It's unaffected; only the YAML group: field is gone.
  • If you want a "category" or "namespace" for pipelines in pipeline list, use tags: instead -- it's free-form and the CLI's filters understand it.

trigger-valuesSection anchor link

TriggerInfo.Env removed. Trigger-supplied values now flow through the pipeline's typed Config struct via the trigger's values: block in pipelines.yaml and are read with sparkwing.PipelineConfig[T](ctx).

Before:

func (b *Build) Plan(ctx context.Context, plan *sparkwing.Plan, in BuildInputs, rc sparkwing.RunContext) error {
    env := rc.Trigger.Env["DEPLOY_TARGET"]
    // ... use env
    return nil
}
# pipelines.yaml
on:
  push:
    branches: [main]
    env:
      DEPLOY_TARGET: prod

After:

type BuildConfig struct {
    DeployTarget string `sw:"deploy_target"`
}

func (b *Build) Config() any { return &BuildConfig{} }

func (b *Build) Plan(ctx context.Context, plan *sparkwing.Plan, in BuildInputs, rc sparkwing.RunContext) error {
    cfg := sparkwing.PipelineConfig[BuildConfig](ctx)
    // ... use cfg.DeployTarget
    return nil
}
# pipelines.yaml
on:
  push:
    branches: [main]
    values:
      deploy_target: prod

Why: Typed Config is checked at parse time -- typos in YAML field names fail loud at run-start, not as a missing-map-key surprise inside a step. The sw:"..." tag bridges the YAML key to the Go field, so the YAML and the struct can use different cases / naming conventions if needed.

Edge cases:

  • TriggerInfo.Source and TriggerInfo.User remain. The trigger metadata that doesn't need a typed contract stays on the struct.
  • Trigger values can be overridden per-target via the targets[<name>] block in pipelines.yaml. The resolution order is: trigger values: block → pipeline values: block → targets[<active>] overlay (each layer overwrites earlier ones).

logrecord-fieldsSection anchor link

LogRecord JSON shape loses the job and job_stack fields. The Go struct's Job and JobStack fields are also removed.

Before:

type LogRecord struct {
    TS        time.Time   `json:"ts"`
    Level     string      `json:"level,omitempty"`
    JobID     string      `json:"node,omitempty"`
    Job       string      `json:"job,omitempty"`        // always empty
    JobStack  []string    `json:"job_stack,omitempty"`  // always empty
    // ...
}
{"ts": "2026-05-20T...", "level": "info", "node": "build", "job": "", "job_stack": []}

After:

type LogRecord struct {
    TS    time.Time `json:"ts"`
    Level string    `json:"level,omitempty"`
    JobID string    `json:"node,omitempty"`
    // (no Job / JobStack fields)
}
{"ts": "2026-05-20T...", "level": "info", "node": "build"}

Why: The WithJob / JobFromContext / JobStackFromContext APIs that would have populated these fields were placeholders for a nested-job breadcrumb design that never shipped. The fields were always empty in practice. The nested-job use case is now handled by Ref / RunAndAwait / SpawnNode, which use different mechanisms.

Edge cases:

  • JSON consumers that explicitly read .job or .job_stack will see the keys as missing rather than empty. Most JSON libraries treat missing-key reads as zero values, so practical impact is small.
  • The node JSON tag (mapped from the Go JobID field) is unchanged. Log streams continue to identify the producing node via the node field.

info-docs-jsonSection anchor link

sparkwing info -o json returns a docs sub-object whose field names were normalized for consistency. Every URL field now has the _url suffix, and four new agent-facing URLs join the object.

Before:

{
  "docs": {
    "cli": "sparkwing docs read --topic getting-started",
    "web": "https://sparkwing.dev/docs/",
    "llms_full": "https://sparkwing.dev/llms-full.txt",
    "llms_txt": "https://sparkwing.dev/llms.txt"
  }
}

After:

{
  "docs": {
    "cli": "sparkwing docs read --topic getting-started",
    "web_url": "https://sparkwing.dev/docs/",
    "llms_full_url": "https://sparkwing.dev/llms-full.txt",
    "llms_txt_url": "https://sparkwing.dev/llms.txt",
    "docs_index_url": "https://sparkwing.dev/docs/index.json",
    "migration_guides_url": "https://sparkwing.dev/docs/migration-guide/",
    "migration_guides_agent_url": "https://sparkwing.dev/migrations-full.txt",
    "migration_guides_index_url": "https://sparkwing.dev/migrations/index.json"
  }
}

Why: The pre-rename field names were inconsistent -- some implied URL by value but didn't say so by name -- and agent-discovery surfaces benefit from a uniform predictable shape. The four new fields cover migration guides + the structured docs index that the new web + CLI cross-version surfaces need for discovery.

Edge cases:

  • The cli field is unchanged (it's a command string, not a URL).
  • Scripts using jq '.docs.web' or similar must update to jq '.docs.web_url'. There's no alias; the old keys are gone.
  • The plain (non-JSON) sparkwing info rendering was rewritten in the same pass; line-by-line scrapers of plain output should expect cosmetic drift even though the URL values are stable.

pkg-docs-entry-reshapeSection anchor link

pkg/docs.Entry and pkg/docs.MigrationEntry reshaped to match the JSON schemas the web emits at /docs/index.json and /migrations/index.json. External consumers using pkg/docs.List(), pkg/docs.MigrationsList(), or constructing these types by hand need to update field references.

Before:

type Entry struct {
    Slug    string
    Path    string  // relative path to the source .md file
    Title   string
    Summary string
    Bytes   int
}

After:

type Entry struct {
    Slug    string `json:"slug"`
    Title   string `json:"title"`
    Summary string `json:"summary"`
    Bytes   int    `json:"bytes"`
}

type MigrationEntry struct {
    Version string `json:"version"`
    Slug    string `json:"slug"`     // == Version, for schema parity with the web
    Title   string `json:"title"`
    Date    string `json:"date"`
    Summary string `json:"summary"`
    Bytes   int    `json:"bytes"`
}

Why: Two reasons. First, the Path field on Entry was an implementation leak -- the relative path was used internally by Read(), never by external callers. Second, the field set now matches the web's /docs/index.json and /migrations/index.json schemas exactly (minus url / raw_url, which are web-deployment artifacts). An agent that learned the shape from one source can consume the other without re-parsing.

MigrationEntry is new in v0.4.0 (alongside MigrationsList / MigrationsRead / MigrationsBetween); the schema-parity decision was made at its first appearance, so there's no field rename to deal with -- only the field set + JSON tags.

Edge cases:

  • If your code held an Entry.Path to later open the source file via os.Open, you can't do that anymore -- the embed makes the "file path" concept moot. Use pkg/docs.Read(slug) to get the body; same shape, loadable from anywhere.
  • MigrationEntry.Slug == MigrationEntry.Version (string-equal) for every entry. The duplicate field exists so the JSON schema matches /migrations/index.json, where downstream agents may key by slug. In Go code, prefer .Version; in JSON consumption, either works.
  • JSON tags now exist on both types. Pre-v0.4.0, encoding Entry to JSON produced PascalCase keys; post-rename, keys are snake_case per the tags. Any code that re-serialized these structs to JSON will see different output.