diff --git a/build/build.go b/build/build.go
index f0badc95982d..c8048fe1b004 100644
--- a/build/build.go
+++ b/build/build.go
@@ -433,9 +433,15 @@ func toRepoOnly(in string) (string, error) {
}
type (
+ ExecutionMode string
+ Execution struct {
+ Mode ExecutionMode
+ Parallel int
+ }
EvaluateFunc func(ctx context.Context, name string, c gateway.Client, res *gateway.Result, opt Options) error
Handler struct {
- Evaluate EvaluateFunc
+ Evaluate EvaluateFunc
+ Execution Execution
}
linkedTargetState struct {
results *waitmap.Map
@@ -444,6 +450,22 @@ type (
parents map[string][]string
children map[string][]string
}
+ linkedTargetHooks struct {
+ preEvaluate func() error
+ evaluate func() error
+ postEvaluate func() error
+ }
+ syncTargetState struct {
+ targets []string
+ results *waitmap.Map
+ evaluated *waitmap.Map
+ }
+)
+
+const (
+ ExecutionModeFailFast ExecutionMode = "fail-fast"
+ ExecutionModeSyncOutput ExecutionMode = "sync-output"
+ ExecutionModeDeferError ExecutionMode = "defer-error"
)
func newLinkedTargetState(parents, children map[string][]string) *linkedTargetState {
@@ -460,37 +482,108 @@ func (s *linkedTargetState) isLinked(key string) bool {
return len(s.parents[key]) > 0 || len(s.children[key]) > 0
}
-func (s *linkedTargetState) run(ctx context.Context, key string, result any, evaluate func() error) error {
+func (s *linkedTargetState) hasLinks() bool {
+ return len(s.parents) > 0 || len(s.children) > 0
+}
+
+func (s *linkedTargetState) fail(key string, err error) {
+ s.results.Set(key, err)
+ s.evaluated.Set(key, err)
+ s.completed.Set(key, err)
+}
+
+func (s *linkedTargetState) run(ctx context.Context, key string, result any, hooks linkedTargetHooks) error {
// Registration flows from parents to children. Waiting for every direct child
// here preserves external-cache lookup before evaluation begins.
s.results.Set(key, result)
children := s.children[key]
- if _, err := s.results.Get(ctx, children...); err != nil {
+ if res, err := s.results.Get(ctx, children...); err != nil {
+ return err
+ } else if err := resultError(res); err != nil {
return err
}
+ if hooks.preEvaluate != nil {
+ if err := hooks.preEvaluate(); err != nil {
+ return err
+ }
+ }
// Evaluation follows dependency order so the target's own session is attached
// to shared solver vertices before a dependent can evaluate them.
- if _, err := s.evaluated.Get(ctx, s.parents[key]...); err != nil {
+ if res, err := s.evaluated.Get(ctx, s.parents[key]...); err != nil {
+ return err
+ } else if err := resultError(res); err != nil {
return err
}
- if err := evaluate(); err != nil {
+ if err := hooks.evaluate(); err != nil {
+ s.evaluated.Set(key, err)
+ s.completed.Set(key, err)
return err
}
s.evaluated.Set(key, struct{}{})
+ if hooks.postEvaluate != nil {
+ if err := hooks.postEvaluate(); err != nil {
+ s.completed.Set(key, err)
+ return err
+ }
+ }
// Completion flows back from children to parents, retaining each parent job
// and its session until every dependent has finished evaluating.
- if _, err := s.completed.Get(ctx, children...); err != nil {
+ if res, err := s.completed.Get(ctx, children...); err != nil {
+ return err
+ } else if err := resultError(res); err != nil {
return err
}
s.completed.Set(key, struct{}{})
return nil
}
-func Build(ctx context.Context, nodes []builder.Node, opts map[string]Options, docker *dockerutil.Client, cfg *confutil.Config, w progress.Writer) (resp map[string]*client.SolveResponse, err error) {
- return BuildWithResultHandler(ctx, nodes, opts, docker, cfg, w, nil)
+func resultError(results map[string]any) error {
+ for _, result := range results {
+ if err, ok := result.(error); ok {
+ return err
+ }
+ }
+ return nil
}
-func BuildWithResultHandler(ctx context.Context, nodes []builder.Node, opts map[string]Options, docker *dockerutil.Client, cfg *confutil.Config, w progress.Writer, bh *Handler) (resp map[string]*client.SolveResponse, err error) {
+func newSyncTargetState(opts map[string]Options, drivers map[string][]*noderesolver.ResolvedNode) *syncTargetState {
+ targets := make([]string, 0, len(opts))
+ for k := range opts {
+ for _, dp := range drivers[k] {
+ targets = append(targets, resultKey(dp, k))
+ }
+ }
+ return &syncTargetState{
+ targets: targets,
+ results: waitmap.New(),
+ evaluated: waitmap.New(),
+ }
+}
+
+func (s *syncTargetState) fail(key string, err error) {
+ s.results.Set(key, err)
+ s.evaluated.Set(key, err)
+}
+
+func (s *syncTargetState) waitResult(ctx context.Context, key string, result any) error {
+ s.results.Set(key, result)
+ results, err := s.results.Get(ctx, s.targets...)
+ if err != nil {
+ return err
+ }
+ return resultError(results)
+}
+
+func (s *syncTargetState) waitEvaluated(ctx context.Context, key string, result any) error {
+ s.evaluated.Set(key, result)
+ results, err := s.evaluated.Get(ctx, s.targets...)
+ if err != nil {
+ return err
+ }
+ return resultError(results)
+}
+
+func Build(ctx context.Context, nodes []builder.Node, opts map[string]Options, docker *dockerutil.Client, cfg *confutil.Config, w progress.Writer, bh *Handler) (resp map[string]*client.SolveResponse, err error) {
if len(nodes) == 0 {
return nil, errors.Errorf("driver required for build")
}
@@ -508,7 +601,12 @@ func BuildWithResultHandler(ctx context.Context, nodes []builder.Node, opts map[
return nil, err
}
- eg, ctx := errgroup.WithContext(ctx)
+ var eg *errgroup.Group
+ if bh != nil && bh.Execution.Mode == ExecutionModeDeferError {
+ eg = &errgroup.Group{}
+ } else {
+ eg, ctx = errgroup.WithContext(ctx)
+ }
reqForNodes, release, err := newBuildRequests(ctx, docker, cfg, drivers, w, opts)
if err != nil {
return nil, err
@@ -538,7 +636,31 @@ func BuildWithResultHandler(ctx context.Context, nodes []builder.Node, opts map[
// loop below, before any goroutines are spawned; no mutex needed.
linkedClients := make(map[string]*client.Client)
- for k, opt := range opts {
+ var syncState *syncTargetState
+ if bh != nil && bh.Execution.Mode == ExecutionModeSyncOutput {
+ if bh.Execution.Parallel > 0 && bh.Execution.Parallel < len(opts) {
+ return nil, errors.Errorf("sync-output execution requires parallelism to be unlimited or at least the number of targets")
+ }
+ // Sync waits for every solve result before any ref evaluation starts and
+ // every ref evaluation before exporters can run, so output is only written
+ // after all targets have reached the output boundary successfully.
+ syncState = newSyncTargetState(opts, drivers)
+ }
+
+ var targetLimit chan struct{}
+ if bh != nil && bh.Execution.Parallel > 0 {
+ if bh.Execution.Parallel < len(opts) && linkedTargets.hasLinks() {
+ return nil, errors.Errorf("limited parallelism is not supported with linked targets")
+ }
+ targetLimit = make(chan struct{}, bh.Execution.Parallel)
+ }
+
+ targets := slices.Collect(maps.Keys(opts))
+ if targetLimit != nil {
+ slices.Sort(targets)
+ }
+ for _, k := range targets {
+ opt := opts[k]
err := func(k string) (err error) {
dps := drivers[k]
multiDriver := len(drivers[k]) > 1
@@ -558,6 +680,20 @@ func BuildWithResultHandler(ctx context.Context, nodes []builder.Node, opts map[
res := make([]*client.SolveResponse, len(dps))
eg2, ctx := errgroup.WithContext(ctx)
+ var releaseTarget func()
+ if targetLimit != nil {
+ select {
+ case targetLimit <- struct{}{}:
+ releaseTarget = func() { <-targetLimit }
+ defer func() {
+ if err != nil {
+ releaseTarget()
+ }
+ }()
+ case <-ctx.Done():
+ return context.Cause(ctx)
+ }
+ }
var pushNames string
var insecurePush bool
@@ -638,7 +774,15 @@ func BuildWithResultHandler(ctx context.Context, nodes []builder.Node, opts map[
done = wg.Done
}
- eg2.Go(func() error {
+ eg2.Go(func() (err error) {
+ defer func() {
+ if err != nil {
+ if syncState != nil {
+ syncState.fail(rKey, err)
+ }
+ linkedTargets.fail(rKey, err)
+ }
+ }()
if done != nil {
defer done()
}
@@ -674,7 +818,7 @@ func BuildWithResultHandler(ctx context.Context, nodes []builder.Node, opts map[
callRes map[string][]byte
frontendErr error
)
- buildFunc := func(ctx context.Context, c gateway.Client) (_ *gateway.Result, retErr error) {
+ buildFunc := func(solveCtx context.Context, c gateway.Client) (_ *gateway.Result, retErr error) {
// Capture the error from this build function.
defer catchFrontendError(&retErr, &frontendErr)
@@ -687,7 +831,7 @@ func BuildWithResultHandler(ctx context.Context, nodes []builder.Node, opts map[
req.FrontendOpt["requestid"] = "frontend." + opt.CallFunc.Name
}
- res, err := solve(ctx, c, req)
+ res, err := solve(solveCtx, c, req)
if err != nil {
return nil, err
}
@@ -696,17 +840,31 @@ func BuildWithResultHandler(ctx context.Context, nodes []builder.Node, opts map[
callRes = res.Metadata
}
- if err := linkedTargets.run(ctx, rKey, res, func() error {
- // invoke custom evaluate handler if it is present
- if bh != nil && bh.Evaluate != nil {
- return bh.Evaluate(ctx, k, c, res, opt)
+ var preEvaluate, postEvaluate func() error
+ if syncState != nil {
+ preEvaluate = func() error {
+ return syncState.waitResult(ctx, rKey, res)
}
- if linkedTargets.isLinked(rKey) {
- return eachRefParallel(ctx, res, func(ctx context.Context, ref gateway.Reference) error {
- return ref.Evaluate(ctx)
- })
+ postEvaluate = func() error {
+ return syncState.waitEvaluated(ctx, rKey, struct{}{})
}
- return nil
+ }
+
+ if err := linkedTargets.run(ctx, rKey, res, linkedTargetHooks{
+ preEvaluate: preEvaluate,
+ evaluate: func() error {
+ // invoke custom evaluate handler if it is present
+ if bh != nil && bh.Evaluate != nil {
+ return bh.Evaluate(solveCtx, k, c, res, opt)
+ }
+ if syncState != nil || linkedTargets.isLinked(rKey) {
+ return eachRefParallel(solveCtx, res, func(ctx context.Context, ref gateway.Reference) error {
+ return ref.Evaluate(ctx)
+ })
+ }
+ return nil
+ },
+ postEvaluate: postEvaluate,
}); err != nil {
return nil, err
}
@@ -799,6 +957,9 @@ func BuildWithResultHandler(ctx context.Context, nodes []builder.Node, opts map[
eg.Go(func() (err error) {
ctx := baseCtx
+ if releaseTarget != nil {
+ defer releaseTarget()
+ }
defer func() {
if span != nil {
tracing.FinishWithError(span, err)
@@ -1243,9 +1404,12 @@ func waitContextDeps(ctx context.Context, node *noderesolver.ResolvedNode, resul
if !ok {
continue
}
+ if err, ok := r.(error); ok {
+ return err
+ }
rr, ok := r.(*gateway.Result)
if !ok {
- return errors.Errorf("invalid result type %T", rr)
+ return errors.Errorf("invalid result type %T", r)
}
if so.FrontendAttrs == nil {
so.FrontendAttrs = map[string]string{}
diff --git a/build/linked_targets_test.go b/build/linked_targets_test.go
index 40460a09fb3e..910c6f9ec0c4 100644
--- a/build/linked_targets_test.go
+++ b/build/linked_targets_test.go
@@ -5,6 +5,7 @@ import (
"testing"
"time"
+ "github.com/docker/buildx/util/waitmap"
"github.com/pkg/errors"
"github.com/stretchr/testify/require"
)
@@ -39,16 +40,22 @@ func TestLinkedTargetStateChainRetainsParents(t *testing.T) {
}
go func() {
- done["root"] <- state.run(t.Context(), "root", struct{}{}, func() error { return nil })
+ done["root"] <- state.run(t.Context(), "root", struct{}{}, linkedTargetHooks{
+ evaluate: func() error { return nil },
+ })
}()
go func() {
- done["middle"] <- state.run(t.Context(), "middle", struct{}{}, func() error { return nil })
+ done["middle"] <- state.run(t.Context(), "middle", struct{}{}, linkedTargetHooks{
+ evaluate: func() error { return nil },
+ })
}()
go func() {
- done["leaf"] <- state.run(t.Context(), "leaf", struct{}{}, func() error {
- close(leafStarted)
- <-releaseLeaf
- return nil
+ done["leaf"] <- state.run(t.Context(), "leaf", struct{}{}, linkedTargetHooks{
+ evaluate: func() error {
+ close(leafStarted)
+ <-releaseLeaf
+ return nil
+ },
})
}()
@@ -83,7 +90,9 @@ func TestLinkedTargetStateDiamondEvaluatesBranchesInParallel(t *testing.T) {
done := make(chan error, 4)
run := func(key string, evaluate func() error) {
go func() {
- done <- state.run(t.Context(), key, struct{}{}, evaluate)
+ done <- state.run(t.Context(), key, struct{}{}, linkedTargetHooks{
+ evaluate: evaluate,
+ })
}()
}
run("root", func() error { return nil })
@@ -126,12 +135,158 @@ func TestLinkedTargetStateCancellation(t *testing.T) {
cause := errors.New("target failed")
done := make(chan error, 1)
go func() {
- done <- state.run(ctx, "child", struct{}{}, func() error { return nil })
+ done <- state.run(ctx, "child", struct{}{}, linkedTargetHooks{
+ evaluate: func() error { return nil },
+ })
+ }()
+ cancel(cause)
+ require.ErrorIs(t, <-done, cause)
+}
+
+func TestLinkedTargetStatePropagatesDependencyErrors(t *testing.T) {
+ state := newLinkedTargetState(
+ map[string][]string{"child": {"parent"}},
+ map[string][]string{"parent": {"child"}},
+ )
+ cause := errors.New("parent failed")
+ state.fail("parent", cause)
+
+ err := state.run(t.Context(), "child", struct{}{}, linkedTargetHooks{
+ evaluate: func() error { return nil },
+ })
+ require.ErrorIs(t, err, cause)
+}
+
+func TestSyncEvaluateWaitsForAllTargets(t *testing.T) {
+ targets := []string{"foo", "bar"}
+ results := waitmap.New()
+
+ fooStarted := make(chan struct{})
+ done := map[string]chan error{
+ "foo": make(chan error, 1),
+ "bar": make(chan error, 1),
+ }
+
+ go func() {
+ results.Set("foo", struct{}{})
+ if _, err := results.Get(t.Context(), targets...); err != nil {
+ done["foo"] <- err
+ return
+ }
+ close(fooStarted)
+ done["foo"] <- nil
+ }()
+
+ assertNotSignaled(t, fooStarted)
+ assertNotCompleted(t, done["foo"])
+
+ go func() {
+ results.Set("bar", struct{}{})
+ if _, err := results.Get(t.Context(), targets...); err != nil {
+ done["bar"] <- err
+ return
+ }
+ done["bar"] <- nil
+ }()
+
+ require.NoError(t, <-done["foo"])
+ require.NoError(t, <-done["bar"])
+}
+
+func TestSyncEvaluateCancellation(t *testing.T) {
+ results := waitmap.New()
+ ctx, cancel := context.WithCancelCause(t.Context())
+ cause := errors.New("target failed")
+
+ done := make(chan error, 1)
+ go func() {
+ results.Set("foo", struct{}{})
+ _, err := results.Get(ctx, "foo", "bar")
+ done <- err
}()
+
cancel(cause)
require.ErrorIs(t, <-done, cause)
}
+func TestSyncEvaluateDoesNotDeadlockLinkedTargets(t *testing.T) {
+ linked := newLinkedTargetState(
+ map[string][]string{"child": {"parent"}},
+ map[string][]string{"parent": {"child"}},
+ )
+ results := waitmap.New()
+ evaluated := waitmap.New()
+
+ done := map[string]chan error{
+ "parent": make(chan error, 1),
+ "child": make(chan error, 1),
+ }
+
+ for _, key := range []string{"parent", "child"} {
+ go func() {
+ done[key] <- linked.run(t.Context(), key, struct{}{}, linkedTargetHooks{
+ preEvaluate: func() error {
+ results.Set(key, struct{}{})
+ _, err := results.Get(t.Context(), "parent", "child")
+ return err
+ },
+ evaluate: func() error {
+ return nil
+ },
+ postEvaluate: func() error {
+ evaluated.Set(key, struct{}{})
+ _, err := evaluated.Get(t.Context(), "parent", "child")
+ return err
+ },
+ })
+ }()
+ }
+
+ require.NoError(t, <-done["parent"])
+ require.NoError(t, <-done["child"])
+}
+
+func TestSyncEvaluatePropagatesEvaluationErrors(t *testing.T) {
+ linked := newLinkedTargetState(map[string][]string{}, map[string][]string{})
+ evaluated := waitmap.New()
+ cause := errors.New("target failed")
+ done := map[string]chan error{
+ "success": make(chan error, 1),
+ "failure": make(chan error, 1),
+ }
+
+ go func() {
+ done["success"] <- linked.run(t.Context(), "success", struct{}{}, linkedTargetHooks{
+ evaluate: func() error {
+ return nil
+ },
+ postEvaluate: func() error {
+ evaluated.Set("success", struct{}{})
+ results, err := evaluated.Get(t.Context(), "success", "failure")
+ if err != nil {
+ return err
+ }
+ return resultError(results)
+ },
+ })
+ }()
+
+ assertNotCompleted(t, done["success"])
+
+ go func() {
+ err := linked.run(t.Context(), "failure", struct{}{}, linkedTargetHooks{
+ evaluate: func() error {
+ return cause
+ },
+ })
+ evaluated.Set("failure", err)
+ done["failure"] <- err
+ }()
+
+ require.ErrorIs(t, <-done["success"], cause)
+ require.ErrorIs(t, <-done["failure"], cause)
+}
+
func assertNotCompleted(t *testing.T, ch <-chan error) {
t.Helper()
select {
diff --git a/commands/bake.go b/commands/bake.go
index 2924177f24d0..2d2ed933c19e 100644
--- a/commands/bake.go
+++ b/commands/bake.go
@@ -68,6 +68,7 @@ type bakeOptions struct {
exportPush bool
exportLoad bool
callFunc string
+ execution string
print bool
list string
@@ -360,8 +361,19 @@ func runBake(ctx context.Context, dockerCli command.Cli, targets []string, in ba
return err
}
+ execution, err := parseExecution(in.execution)
+ if err != nil {
+ return err
+ }
+
done := timeBuildCommand(mp, attributes)
- resp, retErr := build.Build(ctx, nodes, bo, dockerutil.NewClient(dockerCli), confutil.NewConfig(dockerCli), printer)
+ var bh *build.Handler
+ if execution.Mode != build.ExecutionModeFailFast || execution.Parallel > 0 {
+ bh = &build.Handler{
+ Execution: execution,
+ }
+ }
+ resp, retErr := build.Build(ctx, nodes, bo, dockerutil.NewClient(dockerCli), confutil.NewConfig(dockerCli), printer, bh)
if err := printer.Wait(); retErr == nil {
retErr = err
}
@@ -561,6 +573,7 @@ func bakeCmd(dockerCli command.Cli, rootOpts *rootOptions) *cobra.Command {
flags.StringArrayVar(&options.vars, "var", nil, `Set a variable value (e.g., "name=value")`)
flags.StringVar(&options.callFunc, "call", "build", `Set method for evaluating build ("check", "outline", "targets")`)
flags.StringArrayVar(&options.allow, "allow", nil, "Allow build to access specified resources")
+ flags.StringVar(&options.execution, "execution", "fail-fast", `Set target execution behavior (format: "mode[,parallel=N]")`)
flags.VarPF(callAlias(&options.callFunc, "check"), "check", "", `Shorthand for "--call=check"`)
flags.Lookup("check").NoOptDefVal = "true"
@@ -748,6 +761,52 @@ type listEntry struct {
Format string
}
+func parseExecution(input string) (build.Execution, error) {
+ res := build.Execution{Mode: build.ExecutionModeFailFast}
+ if strings.TrimSpace(input) == "" {
+ return res, nil
+ }
+ fields, err := csvvalue.Fields(input, nil)
+ if err != nil {
+ return res, err
+ }
+ var modeSet bool
+ for _, field := range fields {
+ field = strings.TrimSpace(field)
+ if field == "" {
+ continue
+ }
+ key, value, ok := strings.Cut(field, "=")
+ if !ok {
+ if modeSet {
+ return res, errors.Errorf("multiple execution modes specified")
+ }
+ mode := build.ExecutionMode(field)
+ switch mode {
+ case build.ExecutionModeFailFast, build.ExecutionModeSyncOutput, build.ExecutionModeDeferError:
+ res.Mode = mode
+ modeSet = true
+ default:
+ return res, errors.Errorf("invalid execution mode %q", field)
+ }
+ continue
+ }
+ key = strings.TrimSpace(strings.ToLower(key))
+ value = strings.TrimSpace(value)
+ switch key {
+ case "parallel":
+ parallel, err := strconv.Atoi(value)
+ if err != nil || parallel < 0 {
+ return res, errors.Errorf("invalid execution parallelism %q", value)
+ }
+ res.Parallel = parallel
+ default:
+ return res, errors.Errorf("unexpected key %q in execution option %q", key, field)
+ }
+ }
+ return res, nil
+}
+
func parseList(input string) (listEntry, error) {
res := listEntry{}
diff --git a/commands/bake_test.go b/commands/bake_test.go
new file mode 100644
index 000000000000..28f054af992f
--- /dev/null
+++ b/commands/bake_test.go
@@ -0,0 +1,73 @@
+package commands
+
+import (
+ "testing"
+
+ "github.com/docker/buildx/build"
+ "github.com/stretchr/testify/require"
+)
+
+func TestParseExecution(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected build.Execution
+ }{
+ {
+ name: "default",
+ input: "",
+ expected: build.Execution{
+ Mode: build.ExecutionModeFailFast,
+ },
+ },
+ {
+ name: "sync output",
+ input: "sync-output",
+ expected: build.Execution{
+ Mode: build.ExecutionModeSyncOutput,
+ },
+ },
+ {
+ name: "defer error with parallelism",
+ input: "defer-error,parallel=2",
+ expected: build.Execution{
+ Mode: build.ExecutionModeDeferError,
+ Parallel: 2,
+ },
+ },
+ {
+ name: "parallelism with default mode",
+ input: "parallel=1",
+ expected: build.Execution{
+ Mode: build.ExecutionModeFailFast,
+ Parallel: 1,
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ actual, err := parseExecution(tt.input)
+ require.NoError(t, err)
+ require.Equal(t, tt.expected, actual)
+ })
+ }
+}
+
+func TestParseExecutionInvalid(t *testing.T) {
+ tests := []string{
+ "unknown",
+ "mode=sync-output",
+ "parallel=-1",
+ "parallel=nope",
+ "unexpected=value",
+ "fail-fast,defer-error",
+ }
+
+ for _, tt := range tests {
+ t.Run(tt, func(t *testing.T) {
+ _, err := parseExecution(tt)
+ require.Error(t, err)
+ })
+ }
+}
diff --git a/commands/build.go b/commands/build.go
index 5f267704052a..6b059f467fed 100644
--- a/commands/build.go
+++ b/commands/build.go
@@ -1203,7 +1203,7 @@ func RunBuild(ctx context.Context, dockerCli command.Cli, in *BuildOptions, inSt
var inputs *build.Inputs
buildOptions := map[string]build.Options{defaultTargetName: opts}
- resp, err := build.BuildWithResultHandler(ctx, nodes, buildOptions, dockerutil.NewClient(dockerCli), confutil.NewConfig(dockerCli), progress, bh)
+ resp, err := build.Build(ctx, nodes, buildOptions, dockerutil.NewClient(dockerCli), confutil.NewConfig(dockerCli), progress, bh)
err = wrapBuildError(err, false)
if err != nil {
return nil, nil, err
diff --git a/docs/reference/buildx_bake.md b/docs/reference/buildx_bake.md
index ca038d1bd6f0..c4278b647050 100644
--- a/docs/reference/buildx_bake.md
+++ b/docs/reference/buildx_bake.md
@@ -13,27 +13,28 @@ Build from a file
### Options
-| Name | Type | Default | Description |
-|:------------------------------------|:--------------|:--------|:----------------------------------------------------------------------------------------------------------------------|
-| [`--allow`](#allow) | `stringArray` | | Allow build to access specified resources |
-| [`--builder`](#builder) | `string` | | Override the configured builder instance |
-| [`--call`](#call) | `string` | `build` | Set method for evaluating build (`check`, `outline`, `targets`) |
-| [`--check`](#check) | `bool` | | Shorthand for `--call=check` |
-| `-D`, `--debug` | `bool` | | Enable debug logging |
-| [`-f`](#file), [`--file`](#file) | `stringArray` | | Build definition file |
-| [`--list`](#list) | `string` | | List targets or variables |
-| [`--load`](#load) | `bool` | | Shorthand for `--set=*.output=type=docker`. Conditional. |
-| [`--metadata-file`](#metadata-file) | `string` | | Write build result metadata to a file |
-| [`--no-cache`](#no-cache) | `bool` | | Do not use cache when building the image |
-| `--policy` | `stringArray` | | Global policy evaluation options (format: `[disabled=true\|false][,strict=true\|false][,log-level=level]`) |
-| [`--print`](#print) | `bool` | | Print the options without building |
-| [`--progress`](#progress) | `string` | `auto` | Set type of progress output (`auto`, `none`, `plain`, `quiet`, `rawjson`, `tty`). Use plain to show container output |
-| [`--provenance`](#provenance) | `string` | | Shorthand for `--set=*.attest=type=provenance` |
-| [`--pull`](#pull) | `bool` | | Always attempt to pull all referenced images |
-| [`--push`](#push) | `bool` | | Shorthand for `--set=*.output=type=registry`. Conditional. |
-| [`--sbom`](#sbom) | `string` | | Shorthand for `--set=*.attest=type=sbom` |
-| [`--set`](#set) | `stringArray` | | Override target value (e.g., `targetpattern.key=value`) |
-| `--var` | `stringArray` | | Set a variable value (e.g., `name=value`) |
+| Name | Type | Default | Description |
+|:------------------------------------|:--------------|:------------|:----------------------------------------------------------------------------------------------------------------------|
+| [`--allow`](#allow) | `stringArray` | | Allow build to access specified resources |
+| [`--builder`](#builder) | `string` | | Override the configured builder instance |
+| [`--call`](#call) | `string` | `build` | Set method for evaluating build (`check`, `outline`, `targets`) |
+| [`--check`](#check) | `bool` | | Shorthand for `--call=check` |
+| `-D`, `--debug` | `bool` | | Enable debug logging |
+| [`--execution`](#execution) | `string` | `fail-fast` | Set target execution behavior (format: `mode[,parallel=N]`) |
+| [`-f`](#file), [`--file`](#file) | `stringArray` | | Build definition file |
+| [`--list`](#list) | `string` | | List targets or variables |
+| [`--load`](#load) | `bool` | | Shorthand for `--set=*.output=type=docker`. Conditional. |
+| [`--metadata-file`](#metadata-file) | `string` | | Write build result metadata to a file |
+| [`--no-cache`](#no-cache) | `bool` | | Do not use cache when building the image |
+| `--policy` | `stringArray` | | Global policy evaluation options (format: `[disabled=true\|false][,strict=true\|false][,log-level=level]`) |
+| [`--print`](#print) | `bool` | | Print the options without building |
+| [`--progress`](#progress) | `string` | `auto` | Set type of progress output (`auto`, `none`, `plain`, `quiet`, `rawjson`, `tty`). Use plain to show container output |
+| [`--provenance`](#provenance) | `string` | | Shorthand for `--set=*.attest=type=provenance` |
+| [`--pull`](#pull) | `bool` | | Always attempt to pull all referenced images |
+| [`--push`](#push) | `bool` | | Shorthand for `--set=*.output=type=registry`. Conditional. |
+| [`--sbom`](#sbom) | `string` | | Shorthand for `--set=*.attest=type=sbom` |
+| [`--set`](#set) | `stringArray` | | Override target value (e.g., `targetpattern.key=value`) |
+| `--var` | `stringArray` | | Set a variable value (e.g., `name=value`) |
@@ -142,6 +143,40 @@ Same as [`build --call`](buildx_build.md#call).
Same as [`build --check`](buildx_build.md#check).
+### Configure target execution behavior (--execution)
+
+```text
+--execution=MODE[,parallel=N]
+```
+
+The `--execution` flag controls how Bake schedules targets and handles target
+failures. The default mode is `fail-fast`, which stops the build when a target
+fails and cancels targets that are still running.
+
+The `sync-output` mode waits until every selected target has reached the output
+boundary successfully before any target writes output. This is useful when
+multiple targets export local outputs and you want to avoid writing partial
+results if another target fails.
+
+The `defer-error` mode allows independent targets to keep running after another
+target fails. Bake still returns an error after all possible targets complete,
+but successful targets can finish and write their outputs.
+
+Use `parallel=N` to limit how many Bake targets run at the same time. This
+limits target scheduling only; it doesn't change BuildKit's internal
+parallelism for build steps inside a target. For example, the following command
+runs at most two Bake targets at a time and reports any target errors after the
+remaining independent targets complete:
+
+```console
+$ docker buildx bake --execution=defer-error,parallel=2
+```
+
+When `parallel` is omitted or set to `0`, Bake doesn't apply a target
+parallelism limit. The `sync-output` mode requires all selected targets to be
+able to reach the output boundary together, so it can't be combined with a
+parallel limit that is smaller than the number of selected targets.
+
### Specify a build definition file (-f, --file)
Use the `-f` / `--file` option to specify the build definition file to use.
diff --git a/tests/bake.go b/tests/bake.go
index 279bc3c1143e..f38ea915f963 100644
--- a/tests/bake.go
+++ b/tests/bake.go
@@ -46,6 +46,10 @@ var bakeTests = []func(t *testing.T, sb integration.Sandbox){
testBakePrintRemoteContextSubdir,
testBakeLocal,
testBakeLocalMulti,
+ testBakeSyncOutput,
+ testBakeFailFast,
+ testBakeDeferError,
+ testBakeParallel,
testBakeFileRelativePaths,
testBakeLocalExportDeleteMode,
testBakeRemote,
@@ -724,6 +728,81 @@ services:
require.FileExists(t, filepath.Join(dirDest2, "foo"))
}
+func testBakeSyncOutput(t *testing.T, sb integration.Sandbox) {
+ dir := bakeExecutionFailureDir(t, []byte(`
+FROM scratch
+COPY foo /foo
+`))
+
+ out, err := bakeCmd(sb, withDir(dir), withArgs("--execution=sync-output"))
+ require.Error(t, err, out)
+ require.NoFileExists(t, filepath.Join(dir, "out", "foo"))
+}
+
+func testBakeFailFast(t *testing.T, sb integration.Sandbox) {
+ dir := bakeExecutionFailureDir(t, []byte(`
+FROM busybox
+RUN sleep 2
+COPY foo /foo
+`))
+
+ out, err := bakeCmd(sb, withDir(dir), withArgs("--execution=fail-fast"))
+ require.Error(t, err, out)
+ require.NoFileExists(t, filepath.Join(dir, "out", "foo"))
+}
+
+func testBakeDeferError(t *testing.T, sb integration.Sandbox) {
+ dir := bakeExecutionFailureDir(t, []byte(`
+FROM busybox
+RUN sleep 2
+COPY foo /foo
+`))
+
+ out, err := bakeCmd(sb, withDir(dir), withArgs("--execution=defer-error"))
+ require.Error(t, err, out)
+ require.FileExists(t, filepath.Join(dir, "out", "foo"))
+}
+
+func testBakeParallel(t *testing.T, sb integration.Sandbox) {
+ dir := bakeExecutionFailureDir(t, []byte(`
+FROM scratch
+COPY foo /foo
+`))
+
+ out, err := bakeCmd(sb, withDir(dir), withArgs("--execution=defer-error,parallel=1"))
+ require.Error(t, err, out)
+ require.FileExists(t, filepath.Join(dir, "out", "foo"))
+}
+
+func bakeExecutionFailureDir(t *testing.T, dockerfile []byte) string {
+ failureDockerfile := []byte(`
+FROM scratch
+COPY missing /missing
+`)
+ bakefile := []byte(`
+group "default" {
+ targets = ["a-success", "b-failure"]
+}
+
+target "a-success" {
+ dockerfile = "Dockerfile"
+ output = ["type=local,dest=out"]
+}
+
+target "b-failure" {
+ dockerfile = "failure.Dockerfile"
+ output = ["type=cacheonly"]
+}
+`)
+ return tmpdir(
+ t,
+ fstest.CreateFile("docker-bake.hcl", bakefile, 0600),
+ fstest.CreateFile("Dockerfile", dockerfile, 0600),
+ fstest.CreateFile("failure.Dockerfile", failureDockerfile, 0600),
+ fstest.CreateFile("foo", []byte("foo"), 0600),
+ )
+}
+
func testBakeFileRelativePaths(t *testing.T, sb integration.Sandbox) {
t.Run("compose context", func(t *testing.T) {
dockerfile := []byte(`