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:
- Update imports first. package-relocations
moves several packages between
pkg/andinternal/. Run an import-rewrite pass and recompile to see which symbols are gone or moved. - Apply the type renames. node-job-rename and
spawn-types rename author-visible Go types. Search
and replace
*Node→*JobNode, etc. - Update method signatures. typed-dep-interfaces, cacheoptions-rename, risk-labels, and sdk-surface-cleanup change call sites in pipeline definitions.
- Replumb runtime-mutator calls. runtime-plumbing if you had any (you almost certainly didn't; pipeline authors don't call this).
- 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/--prettyoutput shortcuts, or theSPARKWING_NO_CACHEenv var. - Update YAML files. pipelines-yaml-group
for
group:removal. The new declarative target/runner YAML files are additive -- existing pipelines without them keep working. - Last-mile cleanup. trigger-values, logrecord-fields, store-maintenance, cipher-interface cover specific surfaces.
node-job-rename
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: RunsOn → Requires,
RunsOnLabels → RequiresLabels.
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).
RunsOn → Requires 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.Nodefield is nowLogRecord.JobIDin Go, but its JSON tag is stillnode. If you decodeLogRecordfrom 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-interfaces
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
*JobGroupdynamic-membership special case in*JobNode.Needsis preserved -- passing a dynamic group still resolves membership at dispatch, not at call time. Only the entry-point type changed. - The reflection-based "embedded
*WorkStepvia 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.gois removed (the typo path no longer exists at the API). The CLI-flag--sw-start-at/--sw-stop-attypo-detection ininternal/sparkwingruntime/plan_validate.gois unaffected -- operator input is still a string at that boundary.
cacheoptions-rename
CacheOptions.Key → Namespace. CacheOptions.CacheKey →
ContentHash. 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.NoCacheis now available as a typed sentinel return forContentHashwhen an invocation should explicitly opt out of memoization. ReturningNoCacheis distinct from returning the zeroCacheKey: operators see "explicit opt-out" in logs vs a "missing key" warning. New code should preferNoCacheoverreturn sparkwing.CacheKey("")for opt-out paths.- The
rejectTypoShapeplan-time guard now readsNamespace-- a literal withMax/OnLimit/ContentHashset butNamespaceempty panics at plan time as before, just with the new field name in the message.
spawn-types
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
*SpawnHandleor*SpawnGroupmust update to*SpawnSpec/*SpawnGenSpec. Function signatures and struct fields holding these types update the same way. Spec()callers can drop the call entirely.
risk-labels
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.yamlauto_allowfield 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
BlastRadiusGo type and its constants (BlastRadiusDestructive,BlastRadiusAffectsProduction,BlastRadiusCostsMoney) are removed along with theIsValid()/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 toRisk(...)instead. TheBlastRadiusBlockedErrortyped error is replaced byRiskBlockedErrorwith the same shape.
sdk-surface-cleanup
Renames and removals across the sparkwing package:
Renames:
JobNode.OnTargetList()→OnTargets()WorkStep.OnTargetList()→OnTargets()
Removals (each call site needs to be deleted or replaced):
| Removed | Replacement |
|---|---|
JobNode.OnFailureNodeID() | OnFailureNode() with a nil check |
JobNode.Dynamic() / IsDynamic() | Plan.IsDynamicNode(id) (auto-detects ExpandFrom sources) |
sparkwing.ToKebabCase | Inline your own (the helper had no callers) |
sparkwing.LookupInstance | No callers; remove the call site |
sparkwing.Runtime() alias | sparkwing.CurrentRuntime() |
sparkwing.WithJob / JobFromContext / JobStackFromContext | No 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.JobandLogRecord.JobStackGo 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.SetDebugwon't compile. Move them to a_test.gofile inside the sparkwing module if you genuinely need to flip the flag mid-run; otherwise setSPARKWING_DEBUG=1at process start.
runtime-plumbing
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 tointernal/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-relocations
Package layout reorganized to clarify the public/private boundary:
| Old path | New path | Notes |
|---|---|---|
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/InProcessDispatcher | internal/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.gotemplate now importspkg/runner. Newly scaffolded user repos pick up the new path. Existing user repos can update their.sparkwing/main.goto swaporchestrator.Main()→runner.Main()-- the change is one line.- Sibling repos that imported the demoted packages directly
(
logutil,bincache, etc.) will fail on nextgo.modbump. 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/intopkg/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-maintenance
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-interface
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
secretspackage is nowinternal/secrets/, so external adopters can no longer importsecrets.Cipherdirectly from another module. Any code that declared*secrets.Cipherby name in its own signatures (e.g.,func MyHelper(c *secrets.Cipher)) won't compile; the import path is unreachable. Implementpkg/controller.Cipheryourself, OR vendor the cipher code frominternal/secrets/if you need sparkwing's exact AEAD implementation. - The
secrets.NewCipherconstructor 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-renames
Five flag renames + one consolidation:
| Old | New | Notes |
|---|---|---|
--sw-change-directory | --sw-cd | -C short form unchanged |
--sw-for | --sw-target | Job.OnTarget("...") author API unchanged |
--sw-on | --sw-profile | Argument NAME → PROFILE |
--sw-from | --sw-ref | Env-var bridge SPARKWING_FROM → SPARKWING_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_allowfield 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-flags
Several flags removed outright. Each has a replacement path in the pipeline or via a different command:
| Removed | Path forward |
|---|---|
--sw-retry-of / --sw-full | sparkwing runs retry RUN_ID [--failed | --all] |
--sw-job / --sw-prefer | Declare in the pipeline via Job.Requires / Job.Prefers |
--sw-backends-env | Fix match: rules in backends.yaml or DetectEnvironment logic |
--sw-config | Preset 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
wingCLI binary is also retired (see CHANGELOG).sparkwing runis 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-aliases
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/--prettywill 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>ispretty,json, orplain(per command; some verbs accept a subset). sparkwing repo listpreviously only accepted--json; it now takes-o jsonlike every other verb.
no-cache-env-rename
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=1set 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_CACHEexpecting the old semantics.
pipelines-yaml-group
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 YAMLgroup:field is gone. - If you want a "category" or "namespace" for pipelines in
pipeline list, usetags:instead -- it's free-form and the CLI's filters understand it.
trigger-values
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.SourceandTriggerInfo.Userremain. 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 inpipelines.yaml. The resolution order is: triggervalues:block → pipelinevalues:block →targets[<active>]overlay (each layer overwrites earlier ones).
logrecord-fields
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
.jobor.job_stackwill see the keys as missing rather than empty. Most JSON libraries treat missing-key reads as zero values, so practical impact is small. - The
nodeJSON tag (mapped from the GoJobIDfield) is unchanged. Log streams continue to identify the producing node via thenodefield.
info-docs-json
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
clifield is unchanged (it's a command string, not a URL). - Scripts using
jq '.docs.web'or similar must update tojq '.docs.web_url'. There's no alias; the old keys are gone. - The plain (non-JSON)
sparkwing inforendering 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-reshape
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.Pathto later open the source file viaos.Open, you can't do that anymore -- the embed makes the "file path" concept moot. Usepkg/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 byslug. In Go code, prefer.Version; in JSON consumption, either works.- JSON tags now exist on both types. Pre-v0.4.0, encoding
Entryto 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.