From 3e686a8b591733ee4689b8c90b903f46e8c455c1 Mon Sep 17 00:00:00 2001 From: Nicolas Beck Date: Fri, 9 Oct 2026 10:06:48 +0000 Subject: [PATCH 01/12] cmd/docker: Add guidance for unavailable offload commands Show installation guidance and a documentation link when the offload plugin is missing or invalid. Handle this before command execution so plugin-specific flags and --help also receive the guidance. Signed-off-by: Nicolas Beck --- cmd/docker/docker.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/cmd/docker/docker.go b/cmd/docker/docker.go index 47c720e941..92dfce8cdd 100644 --- a/cmd/docker/docker.go +++ b/cmd/docker/docker.go @@ -571,11 +571,15 @@ func runDocker(ctx context.Context, dockerCli *command.DockerCli) error { return nil } if !errdefs.IsNotFound(err) { - // For plugin not found we fall through to - // cmd.Execute() which deals with reporting - // "command not found" in a consistent way. return err } + if args[0] == "offload" { + // Report this before cmd.ExecuteContext(): plugin-specific flags such as + // --cpus otherwise fail parsing before the root command's RunE. + return errors.New("docker: to use 'docker offload', install the plugin.\nSee https://docs.docker.com/go/cloud-flag/") + } + // Other missing or invalid plugins fall through to cmd.ExecuteContext(), + // which reports the standard unknown-command error. } } From 23c66ccd6139e7695c3cce60f2ee1ac5751dc1e1 Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Fri, 9 Oct 2026 10:47:07 +0200 Subject: [PATCH 02/12] Bump version to 29.9.1 Signed-off-by: Sebastiaan van Stijn --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 4552f1e80f..7012061eec 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -29.9.0 +29.9.1 From 067d069f0c6742a98dbb6cc8ede5ac0cd55c886a Mon Sep 17 00:00:00 2001 From: Tanvir Tian Date: Sat, 10 Oct 2026 18:14:12 +0600 Subject: [PATCH 03/12] fix: return remote context download errors Signed-off-by: Tanvir Tian --- cli/command/image/build.go | 7 +++++-- cli/command/image/build_test.go | 31 +++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/cli/command/image/build.go b/cli/command/image/build.go index 26a8739854..f57a0c2aa6 100644 --- a/cli/command/image/build.go +++ b/cli/command/image/build.go @@ -260,8 +260,11 @@ func runBuild(ctx context.Context, dockerCli command.Cli, options buildOptions) contextDir = tempDir case build.ContextTypeRemote: buildCtx, relDockerfile, err = build.GetContextFromURL(progBuff, options.context, options.dockerfileName) - if err != nil && options.quiet { - _, _ = fmt.Fprintln(dockerCli.Err(), progBuff) + if err != nil { + if options.quiet { + _, _ = fmt.Fprintln(dockerCli.Err(), progBuff) + } + return err } default: return fmt.Errorf("unable to prepare context: path %q not found", options.context) diff --git a/cli/command/image/build_test.go b/cli/command/image/build_test.go index 1d61d05bbe..4cb6448297 100644 --- a/cli/command/image/build_test.go +++ b/cli/command/image/build_test.go @@ -6,6 +6,8 @@ import ( "compress/gzip" "context" "io" + "net/http" + "net/http/httptest" "os" "path/filepath" "slices" @@ -172,6 +174,35 @@ RUN echo hello world assert.DeepEqual(t, fakeBuild.filenames(t), []string{"Dockerfile"}) } +// TestRunBuildRemoteContextDownloadError verifies that remote context +// download errors are propagated. +// +// regression test for https://github.com/docker/cli/issues/7372 +func TestRunBuildRemoteContextDownloadError(t *testing.T) { + t.Setenv("DOCKER_BUILDKIT", "0") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "not found", http.StatusNotFound) + })) + defer server.Close() + + imageBuildCalled := false + cli := test.NewFakeCli(&fakeClient{ + imageBuildFunc: func(ctx context.Context, buildContext io.Reader, options client.ImageBuildOptions) (client.ImageBuildResult, error) { + imageBuildCalled = true + return client.ImageBuildResult{Body: io.NopCloser(bytes.NewReader(nil))}, nil + }, + }) + + options := newBuildOptions() + options.context = server.URL + + err := runBuild(context.Background(), cli, options) + + assert.ErrorContains(t, err, "unable to download remote context") + assert.Assert(t, !imageBuildCalled, "ImageBuild should not be called when downloading the remote context fails") +} + type fakeBuild struct { context *tar.Reader options client.ImageBuildOptions From 8da8b005554195db6000451d3e29cba62eab139d Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Sat, 10 Oct 2026 15:28:26 +0200 Subject: [PATCH 04/12] vendor: github.com/Microsoft/go-winio v0.6.3 - improve interoperability with stdlib sentinel errors and deprecate aliases full diff: https://github.com/Microsoft/go-winio/compare/f19d9717deb0...v0.6.3 Signed-off-by: Sebastiaan van Stijn --- vendor.mod | 2 +- vendor.sum | 4 +-- vendor/github.com/Microsoft/go-winio/file.go | 32 +++++++++++++++---- .../github.com/Microsoft/go-winio/hvsock.go | 2 +- vendor/github.com/Microsoft/go-winio/pipe.go | 23 +++++++------ vendor/modules.txt | 2 +- 6 files changed, 41 insertions(+), 24 deletions(-) diff --git a/vendor.mod b/vendor.mod index d6ca6bedd3..9b14b2e61e 100644 --- a/vendor.mod +++ b/vendor.mod @@ -71,7 +71,7 @@ require ( require ( github.com/Azure/go-ansiterm v0.0.0-20261006220739-8c912ac31dd4 // indirect - github.com/Microsoft/go-winio v0.6.3-0.20260930231756-f19d9717deb0 // indirect; see https://github.com/microsoft/hcsshim/pull/2545 + github.com/Microsoft/go-winio v0.6.3 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect diff --git a/vendor.sum b/vendor.sum index 80f9952157..aa0e5e45f9 100644 --- a/vendor.sum +++ b/vendor.sum @@ -4,8 +4,8 @@ github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8af github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/go-ansiterm v0.0.0-20261006220739-8c912ac31dd4 h1:m2xcjRABtG288vnlV3U/PKh/HnPURxZp50RKNUwBxM8= github.com/Azure/go-ansiterm v0.0.0-20261006220739-8c912ac31dd4/go.mod h1:MTUE7ZuZO89FAtAwozGg6AWD83pcdefnEq5gF82MB8s= -github.com/Microsoft/go-winio v0.6.3-0.20260930231756-f19d9717deb0 h1:71VU8kW/LCxJkx41+YYys1EgQO+AUnPa1TrxTSyYUCU= -github.com/Microsoft/go-winio v0.6.3-0.20260930231756-f19d9717deb0/go.mod h1:ma0QpxizD4fyyzJ1C7MIblymTD2Nxz5Mho/3CepwIZg= +github.com/Microsoft/go-winio v0.6.3 h1:M/52tsoyTfwaxajLnXZm7PqkbMGRH8iSWP+8fcg9MQ4= +github.com/Microsoft/go-winio v0.6.3/go.mod h1:ma0QpxizD4fyyzJ1C7MIblymTD2Nxz5Mho/3CepwIZg= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= diff --git a/vendor/github.com/Microsoft/go-winio/file.go b/vendor/github.com/Microsoft/go-winio/file.go index 19c5f8940e..449bb3bd1b 100644 --- a/vendor/github.com/Microsoft/go-winio/file.go +++ b/vendor/github.com/Microsoft/go-winio/file.go @@ -3,8 +3,9 @@ package winio import ( - "errors" + "context" "io" + "os" "runtime" "sync" "sync/atomic" @@ -20,16 +21,33 @@ import ( //sys setFileCompletionNotificationModes(h windows.Handle, flags uint8) (err error) = SetFileCompletionNotificationModes //sys wsaGetOverlappedResult(h windows.Handle, o *windows.Overlapped, bytes *uint32, wait bool, flags *uint32) (err error) = ws2_32.WSAGetOverlappedResult -var ( - ErrFileClosed = errors.New("file has already been closed") - ErrTimeout = &timeoutError{} -) +// ErrFileClosed is returned when an operation is performed on a closed file. +// +// Deprecated: use [os.ErrClosed] instead. +var ErrFileClosed = os.ErrClosed +// ErrTimeout is returned when an I/O operation exceeds its deadline. +// +// It matches both [context.DeadlineExceeded] and [os.ErrDeadlineExceeded] +// with [errors.Is]. +var ErrTimeout error = &timeoutError{} + +// timeoutError preserves the historical "i/o timeout" error while matching +// both context.DeadlineExceeded and os.ErrDeadlineExceeded through errors.Is. +// This mirrors the behavior of net.errTimeout: +// https://github.com/golang/go/blob/go1.27.1/src/net/net.go +// +// TODO: consider replacing ErrTimeout with os.ErrDeadlineExceeded if os.ErrDeadlineExceeded is changed to match context.DeadlineExceeded. type timeoutError struct{} func (*timeoutError) Error() string { return "i/o timeout" } func (*timeoutError) Timeout() bool { return true } func (*timeoutError) Temporary() bool { return true } +func (*timeoutError) Unwrap() error { return os.ErrDeadlineExceeded } + +func (*timeoutError) Is(err error) bool { + return err == context.DeadlineExceeded +} type timeoutChan chan struct{} @@ -143,7 +161,7 @@ func (f *win32File) prepareIO() (*ioOperation, error) { f.wgLock.RLock() if f.closing.Load() { f.wgLock.RUnlock() - return nil, ErrFileClosed + return nil, os.ErrClosed } f.wg.Add(1) f.wgLock.RUnlock() @@ -192,7 +210,7 @@ func (f *win32File) asyncIO(c *ioOperation, d *deadlineHandler, bytes uint32, er err = r.err if err == windows.ERROR_OPERATION_ABORTED { //nolint:errorlint // err is Errno if f.closing.Load() { - err = ErrFileClosed + err = os.ErrClosed } } else if err != nil && f.socket { // err is from Win32. Query the overlapped structure to get the winsock error. diff --git a/vendor/github.com/Microsoft/go-winio/hvsock.go b/vendor/github.com/Microsoft/go-winio/hvsock.go index d4960f5b0a..df2008f6f6 100644 --- a/vendor/github.com/Microsoft/go-winio/hvsock.go +++ b/vendor/github.com/Microsoft/go-winio/hvsock.go @@ -441,7 +441,7 @@ func canRedial(err error) bool { func (conn *HvsockConn) opErr(op string, err error) error { // translate from "file closed" to "socket closed" - if errors.Is(err, ErrFileClosed) { + if errors.Is(err, os.ErrClosed) { err = socket.ErrSocketClosed } return &net.OpError{Op: op, Net: "hvsock", Source: &conn.local, Addr: &conn.remote, Err: err} diff --git a/vendor/github.com/Microsoft/go-winio/pipe.go b/vendor/github.com/Microsoft/go-winio/pipe.go index 16a42584fc..850fe3e53a 100644 --- a/vendor/github.com/Microsoft/go-winio/pipe.go +++ b/vendor/github.com/Microsoft/go-winio/pipe.go @@ -102,12 +102,10 @@ func (status ntStatus) Err() error { return rtlNtStatusToDosError(status) } -var ( - // ErrPipeListenerClosed is returned for pipe operations on listeners that have been closed. - ErrPipeListenerClosed = net.ErrClosed - - errPipeWriteClosed = errors.New("pipe has been closed for write") -) +// ErrPipeListenerClosed is returned for pipe operations on listeners that have been closed. +// +// Deprecated: use [net.ErrClosed] instead. +var ErrPipeListenerClosed = net.ErrClosed type win32Pipe struct { *win32File @@ -144,9 +142,10 @@ func (f *win32Pipe) Disconnect() error { } // CloseWrite closes the write side of a message pipe in byte mode. +// It returns [io.ErrClosedPipe] if the write side is already closed. func (f *win32MessageBytePipe) CloseWrite() error { if f.writeClosed { - return errPipeWriteClosed + return io.ErrClosedPipe } err := f.win32File.Flush() if err != nil { @@ -161,10 +160,10 @@ func (f *win32MessageBytePipe) CloseWrite() error { } // Write writes bytes to a message pipe in byte mode. Zero-byte writes are ignored, since -// they are used to implement CloseWrite(). +// they are used to implement CloseWrite. It returns [io.ErrClosedPipe] if the write side is closed. func (f *win32MessageBytePipe) Write(b []byte) (int, error) { if f.writeClosed { - return 0, errPipeWriteClosed + return 0, io.ErrClosedPipe } if len(b) == 0 { return 0, nil @@ -454,7 +453,7 @@ func (l *win32PipeListener) makeConnectedServerPipe() (*win32File, error) { _ = p.Close() p = nil <-ch - err = ErrPipeListenerClosed + err = net.ErrClosed } return p, err } @@ -479,7 +478,7 @@ func (l *win32PipeListener) listenerRoutine() { } } responseCh <- acceptResponse{p, err} - closed = err == ErrPipeListenerClosed //nolint:errorlint // err is Errno + closed = err == net.ErrClosed //nolint:errorlint // err is Errno } } windows.Close(l.firstHandle) @@ -571,7 +570,7 @@ func (l *win32PipeListener) Accept() (net.Conn, error) { } return &win32Pipe{win32File: response.f, path: l.path}, nil case <-l.doneCh: - return nil, ErrPipeListenerClosed + return nil, net.ErrClosed } } diff --git a/vendor/modules.txt b/vendor/modules.txt index c9f32360ea..b1024ee973 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -5,7 +5,7 @@ dario.cat/mergo ## explicit; go 1.17 github.com/Azure/go-ansiterm github.com/Azure/go-ansiterm/winterm -# github.com/Microsoft/go-winio v0.6.3-0.20260930231756-f19d9717deb0 +# github.com/Microsoft/go-winio v0.6.3 ## explicit; go 1.26.0 github.com/Microsoft/go-winio github.com/Microsoft/go-winio/internal/fs From de8f4032adab4a18658a6309a1e676b5f82afec3 Mon Sep 17 00:00:00 2001 From: Nicolas Beck Date: Fri, 9 Oct 2026 15:50:53 +0000 Subject: [PATCH 05/12] cli-plugins: Add generic hook messages Allow plugins to return informational messages without the next-steps heading or indentation. Invoke each hook once and render generic messages before the shared next-steps block. Centralize grouping and formatting in PrintMessages, with a callback for template evaluation and error handling. This avoids the need for an intermediate EvaluatedMessage type and simplifies hook collection to return the original responses. Skip messages containing only whitespace while preserving empty lines within non-empty messages. Keep PrintNextSteps as a deprecated wrapper to preserve existing callers and output. Update the printer tests and remove the obsolete appendMessages helper and its tests. Co-authored-by: Nicolas Beck Signed-off-by: Nicolas Beck Signed-off-by: Sebastiaan van Stijn --- cli-plugins/hooks/hook_types.go | 4 + cli-plugins/hooks/printer.go | 45 +++++++-- cli-plugins/hooks/printer_test.go | 85 ++++++++++++++++- cli-plugins/hooks/template.go | 12 ++- cli-plugins/hooks/template_test.go | 12 ++- cli-plugins/manager/hooks.go | 70 +++++--------- cli-plugins/manager/hooks_test.go | 143 ++++++++++++++++++++++------- 7 files changed, 276 insertions(+), 95 deletions(-) diff --git a/cli-plugins/hooks/hook_types.go b/cli-plugins/hooks/hook_types.go index 03ee729493..9cda993e18 100644 --- a/cli-plugins/hooks/hook_types.go +++ b/cli-plugins/hooks/hook_types.go @@ -30,7 +30,11 @@ package hooks type ResponseType int const ( + // NextSteps renders suggestions under a shared "What's next:" header. NextSteps ResponseType = 0 + // GenericMessage renders before NextSteps, without a header or indentation. + // CLI versions that do not recognize this type ignore the response. + GenericMessage ResponseType = 1 ) // Request is the type representing the information diff --git a/cli-plugins/hooks/printer.go b/cli-plugins/hooks/printer.go index c9b0bd9bcd..ff19762690 100644 --- a/cli-plugins/hooks/printer.go +++ b/cli-plugins/hooks/printer.go @@ -7,17 +7,42 @@ const ( indent = " " ) -// PrintNextSteps renders list of [NextSteps] messages and writes them -// to out. It is a no-op if messages is empty. -func PrintNextSteps(out io.Writer, messages []string) { - if len(messages) == 0 { - return +// PrintMessages groups messages by type and writes them to out. Generic messages +// appear first, without a header or indentation, followed by next steps under a +// shared "What's next:" header. Order within each type is preserved. +// Messages with no lines or an unknown type produce no output. +func PrintMessages(out io.Writer, messages []Response, evaluate func(Response) []string) { + grouped := make(map[ResponseType][]string) + for _, message := range messages { + lines := evaluate(message) + grouped[message.Type] = append(grouped[message.Type], lines...) } + for _, responseType := range []ResponseType{GenericMessage, NextSteps} { + lines := grouped[responseType] + if len(lines) == 0 { + continue + } - _, _ = io.WriteString(out, whatsNext) - for _, msg := range messages { - _, _ = io.WriteString(out, indent) - _, _ = io.WriteString(out, msg) - _, _ = io.WriteString(out, "\n") + header, prefix := "\n", "" + if responseType == NextSteps { + header, prefix = whatsNext, indent + } + + _, _ = io.WriteString(out, header) + for _, line := range lines { + _, _ = io.WriteString(out, prefix) + _, _ = io.WriteString(out, line) + _, _ = io.WriteString(out, "\n") + } } } + +// PrintNextSteps renders list of [NextSteps] messages and writes them +// to out. It is a no-op if messages is empty. +// +// Deprecated: use [PrintMessages] instead. +func PrintNextSteps(out io.Writer, messages []string) { + PrintMessages(out, []Response{{Type: NextSteps}}, func(Response) []string { + return messages + }) +} diff --git a/cli-plugins/hooks/printer_test.go b/cli-plugins/hooks/printer_test.go index 8b1ca9234e..5a1d33c331 100644 --- a/cli-plugins/hooks/printer_test.go +++ b/cli-plugins/hooks/printer_test.go @@ -8,7 +8,85 @@ import ( "gotest.tools/v3/assert" ) -func TestPrintHookMessages(t *testing.T) { +func TestPrintMessages(t *testing.T) { + const header = "\n\x1b[1mWhat's next:\x1b[0m\n" + + for _, tc := range []struct { + doc string + messages []hooks.Response + expected string + }{ + {doc: "no messages"}, + { + doc: "no lines", + messages: []hooks.Response{ + {Type: hooks.GenericMessage}, + {Type: hooks.NextSteps}, + }, + }, + { + doc: "generic message", + messages: []hooks.Response{ + {Type: hooks.GenericMessage, Template: "Session active"}, + }, + expected: "\nSession active\n", + }, + { + doc: "preserve message formatting", + messages: []hooks.Response{ + {Type: hooks.GenericMessage, Template: "Session active\n\n Details"}, + }, + expected: "\nSession active\n\n Details\n", + }, + { + doc: "next steps", + messages: []hooks.Response{ + {Type: hooks.NextSteps, Template: "Try another command"}, + }, + expected: header + " Try another command\n", + }, + { + doc: "group interleaved types and preserve order within each type", + messages: []hooks.Response{ + {Type: hooks.NextSteps, Template: "First suggestion"}, + {Type: hooks.GenericMessage, Template: "Session active"}, + {Type: hooks.NextSteps, Template: "Second suggestion\nThird suggestion"}, + {Type: hooks.GenericMessage, Template: "Another status"}, + }, + expected: "\nSession active\nAnother status\n" + header + " First suggestion\n Second suggestion\n Third suggestion\n", + }, + { + doc: "unknown type", + messages: []hooks.Response{ + {Type: hooks.ResponseType(99), Template: "Ignored"}, + }, + }, + { + doc: "unknown type does not affect known messages", + messages: []hooks.Response{ + {Type: hooks.ResponseType(99), Template: "Ignored"}, + {Type: hooks.GenericMessage, Template: "Session active"}, + {Type: hooks.NextSteps, Template: "Try another command"}, + }, + expected: "\nSession active\n" + header + " Try another command\n", + }, + } { + t.Run(tc.doc, func(t *testing.T) { + var w strings.Builder + hooks.PrintMessages(&w, tc.messages, func(message hooks.Response) []string { + if message.Template == "" { + return nil + } + lines, err := hooks.ParseTemplate(message.Template, nil) + assert.NilError(t, err) + return lines + }) + assert.Equal(t, w.String(), tc.expected) + }) + } +} + +func TestPrintNextSteps(t *testing.T) { const header = "\n\x1b[1mWhat's next:\x1b[0m\n" tests := []struct { @@ -34,6 +112,11 @@ func TestPrintHookMessages(t *testing.T) { " Foo\n" + " bar\n", }, + { + doc: "preserve empty and indented lines", + messages: []string{"", " Details", ""}, + expectedOutput: header + " \n Details\n \n", + }, } for _, tc := range tests { t.Run(tc.doc, func(t *testing.T) { diff --git a/cli-plugins/hooks/template.go b/cli-plugins/hooks/template.go index 99abb9acf8..d0057da56b 100644 --- a/cli-plugins/hooks/template.go +++ b/cli-plugins/hooks/template.go @@ -7,6 +7,7 @@ import ( "bytes" "errors" "fmt" + "slices" "strings" "text/template" @@ -43,7 +44,16 @@ func ParseTemplate(hookTemplate string, cmd *cobra.Command) ([]string, error) { if n := strings.Count(out, "\n"); n > maxMessages { return nil, fmt.Errorf("hook template contains too many messages (%d): maximum is %d", n, maxMessages) } - return strings.SplitN(out, "\n", maxMessages), nil + lines := strings.SplitN(out, "\n", maxMessages) + + // Skip messages containing only whitespace, but preserve empty lines + // in messages that have content (e.g., for paragraph spacing). + if !slices.ContainsFunc(lines, func(line string) bool { + return strings.TrimSpace(line) != "" + }) { + return nil, nil + } + return lines, nil } var ErrHookTemplateParse = errors.New("failed to parse hook template") diff --git a/cli-plugins/hooks/template_test.go b/cli-plugins/hooks/template_test.go index 28b290696f..81d6c9175c 100644 --- a/cli-plugins/hooks/template_test.go +++ b/cli-plugins/hooks/template_test.go @@ -33,7 +33,17 @@ func TestParseTemplate(t *testing.T) { { doc: "empty template", template: "", - expectedOutput: []string{""}, + expectedOutput: nil, + }, + { + doc: "only empty lines", + template: "\n\n", + expectedOutput: nil, + }, + { + doc: "whitespace-only lines", + template: " \n\t\n ", + expectedOutput: nil, }, { doc: "plain message", diff --git a/cli-plugins/manager/hooks.go b/cli-plugins/manager/hooks.go index 5a3d3ebe91..ffa70374e4 100644 --- a/cli-plugins/manager/hooks.go +++ b/cli-plugins/manager/hooks.go @@ -49,11 +49,18 @@ func RunPluginHooks(ctx context.Context, dockerCLI config.Provider, rootCmd, sub } func runHooks(ctx context.Context, cfg *configfile.ConfigFile, rootCmd, subCommand *cobra.Command, invokedCommand string, flags map[string]string, cmdErrorMessage string) { - nextSteps := invokeAndCollectHooks(ctx, cfg, rootCmd, subCommand, invokedCommand, flags, cmdErrorMessage) - hooks.PrintNextSteps(subCommand.ErrOrStderr(), nextSteps) + messages := invokeAndCollectHooks(ctx, cfg, rootCmd, invokedCommand, flags, cmdErrorMessage) + hooks.PrintMessages(subCommand.ErrOrStderr(), messages, func(message hooks.Response) []string { + lines, err := hooks.ParseTemplate(message.Template, subCommand) + if err != nil { + logrus.WithError(err).Debug("Failed to evaluate hook template") + return nil + } + return lines + }) } -func invokeAndCollectHooks(ctx context.Context, cfg *configfile.ConfigFile, rootCmd, subCmd *cobra.Command, subCmdStr string, flags map[string]string, cmdErrorMessage string) []string { +func invokeAndCollectHooks(ctx context.Context, cfg *configfile.ConfigFile, rootCmd *cobra.Command, subCmdStr string, flags map[string]string, cmdErrorMessage string) []hooks.Response { if ctx.Err() != nil { return nil } @@ -64,17 +71,17 @@ func invokeAndCollectHooks(ctx context.Context, cfg *configfile.ConfigFile, root } pluginDirs := getPluginDirs(cfg) - nextSteps := make([]string, 0, len(pluginsCfg)) + var collected []hooks.Response - tryInvokeHook := func(pluginName string, pluginCfg map[string]string) (messages []string, ok bool, err error) { + tryInvokeHook := func(pluginName string, pluginCfg map[string]string) (hooks.Response, error) { match, matched := pluginMatch(pluginCfg, subCmdStr, cmdErrorMessage) if !matched { - return nil, false, nil + return hooks.Response{}, nil } p, err := getPlugin(pluginName, pluginDirs, rootCmd) if err != nil { - return nil, false, err + return hooks.Response{}, err } resp, err := p.RunHook(ctx, hooks.Request{ @@ -83,29 +90,23 @@ func invokeAndCollectHooks(ctx context.Context, cfg *configfile.ConfigFile, root CommandError: cmdErrorMessage, }) if err != nil { - return nil, false, err + return hooks.Response{}, err } var message hooks.Response if err := json.Unmarshal(resp, &message); err != nil { - return nil, false, fmt.Errorf("failed to unmarshal hook response (%q): %w", string(resp), err) + return hooks.Response{}, fmt.Errorf("failed to unmarshal hook response (%q): %w", string(resp), err) } - // currently the only hook type - if message.Type != hooks.NextSteps { - return nil, false, errors.New("unexpected hook response type: " + strconv.Itoa(int(message.Type))) + if message.Type != hooks.NextSteps && message.Type != hooks.GenericMessage { + return hooks.Response{}, errors.New("unexpected hook response type: " + strconv.Itoa(int(message.Type))) } - messages, err = hooks.ParseTemplate(message.Template, subCmd) - if err != nil { - return nil, false, err - } - - return messages, true, nil + return message, nil } for pluginName, pluginCfg := range pluginsCfg { - messages, ok, err := tryInvokeHook(pluginName, pluginCfg) + message, err := tryInvokeHook(pluginName, pluginCfg) if err != nil { // skip misbehaving plugins, but don't halt execution logrus.WithFields(logrus.Fields{ @@ -114,38 +115,13 @@ func invokeAndCollectHooks(ctx context.Context, cfg *configfile.ConfigFile, root }).Debug("Plugin hook invocation failed") continue } - if !ok { + if message.Template == "" { continue } - var appended bool - nextSteps, appended = appendNextSteps(nextSteps, messages) - if !appended { - logrus.WithFields(logrus.Fields{ - "plugin": pluginName, - }).Debug("Plugin responded with an empty hook message; ignoring") - } + collected = append(collected, message) } - return nextSteps -} - -// appendNextSteps appends the processed hook output to the nextSteps slice. -// If the processed hook output is empty, it is not appended. -// Empty lines are not stripped if there's at least one non-empty line. -func appendNextSteps(nextSteps []string, processed []string) ([]string, bool) { - empty := true - for _, l := range processed { - if strings.TrimSpace(l) != "" { - empty = false - break - } - } - - if empty { - return nextSteps, false - } - - return append(nextSteps, processed...), true + return collected } // pluginMatch takes a plugin configuration and a string representing the diff --git a/cli-plugins/manager/hooks_test.go b/cli-plugins/manager/hooks_test.go index e39a138311..6f2f376e23 100644 --- a/cli-plugins/manager/hooks_test.go +++ b/cli-plugins/manager/hooks_test.go @@ -2,6 +2,11 @@ package manager import ( "context" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" "testing" "github.com/docker/cli/cli/config/configfile" @@ -258,38 +263,6 @@ func TestMatchHookConfig(t *testing.T) { } } -func TestAppendNextSteps(t *testing.T) { - testCases := []struct { - processed []string - expectedOut []string - }{ - { - processed: []string{}, - expectedOut: []string{}, - }, - { - processed: []string{"", ""}, - expectedOut: []string{}, - }, - { - processed: []string{"Some hint", "", "Some other hint"}, - expectedOut: []string{"Some hint", "", "Some other hint"}, - }, - { - processed: []string{"Hint 1", "Hint 2"}, - expectedOut: []string{"Hint 1", "Hint 2"}, - }, - } - - for _, tc := range testCases { - t.Run("", func(t *testing.T) { - got, appended := appendNextSteps([]string{}, tc.processed) - assert.Check(t, is.DeepEqual(got, tc.expectedOut)) - assert.Check(t, is.Equal(appended, len(got) > 0)) - }) - } -} - func TestRunPluginHooksPassesErrorMessage(t *testing.T) { cfg := configfile.New("") cfg.Plugins = map[string]map[string]string{ @@ -336,7 +309,7 @@ func TestInvokeAndCollectHooksErrorHooksSkippedOnSuccess(t *testing.T) { // On success, error-hooks should not match, so the plugin // binary is never looked up and no results are returned. result := invokeAndCollectHooks( - context.Background(), cfg, root, sub, + context.Background(), cfg, root, "build", map[string]string{}, "", ) assert.Check(t, is.Len(result, 0)) @@ -349,7 +322,7 @@ func TestInvokeAndCollectHooksNoPlugins(t *testing.T) { root.AddCommand(sub) result := invokeAndCollectHooks( - context.Background(), cfg, root, sub, + context.Background(), cfg, root, "build", map[string]string{}, "some error", ) assert.Check(t, is.Len(result, 0)) @@ -368,8 +341,108 @@ func TestInvokeAndCollectHooksCancelledContext(t *testing.T) { cancel() // cancel immediately result := invokeAndCollectHooks( - ctx, cfg, root, sub, + ctx, cfg, root, "build", map[string]string{}, "exit status 1", ) assert.Check(t, is.Nil(result)) } + +func TestRunHooksMessages(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test plugins require a POSIX shell") + } + + const header = "\n\x1b[1mWhat's next:\x1b[0m\n" + for _, tc := range []struct { + doc string + responses map[string]string + expected string + }{ + { + doc: "generic message preserves formatting and expands templates", + responses: map[string]string{ + "status": `{"Type":1,"Template":"Status for {{command}}\n\n Details"}`, + }, + expected: "\nStatus for build\n\n Details\n", + }, + { + doc: "omitted type defaults to next steps", + responses: map[string]string{ + "suggestion": `{"Template":"Try another command"}`, + }, + expected: header + " Try another command\n", + }, + { + doc: "generic messages precede shared next steps header", + responses: map[string]string{ + "statusone": `{"Type":1,"Template":"Session active"}`, + "statustwo": `{"Type":1,"Template":"Session active"}`, + "suggestionone": `{"Type":0,"Template":"Try another command"}`, + "suggestiontwo": `{"Type":0,"Template":"Try another command"}`, + }, + expected: "\nSession active\nSession active\n" + header + " Try another command\n Try another command\n", + }, + { + doc: "empty responses produce no output", + responses: map[string]string{ + "status": `{"Type":1,"Template":" \n\t"}`, + "suggestion": `{"Type":0,"Template":""}`, + }, + }, + { + doc: "empty generic message does not affect next steps", + responses: map[string]string{ + "status": `{"Type":1,"Template":""}`, + "suggestion": `{"Type":0,"Template":"Try another command"}`, + }, + expected: header + " Try another command\n", + }, + { + doc: "invalid responses do not affect other plugins", + responses: map[string]string{ + "unknown": `{"Type":99,"Template":"Ignored"}`, + "invalid": `{"Type":1,"Template":"{{"}`, + "status": `{"Type":1,"Template":"Session active"}`, + "suggestion": `{"Type":0,"Template":"Try another command"}`, + }, + expected: "\nSession active\n" + header + " Try another command\n", + }, + } { + t.Run(tc.doc, func(t *testing.T) { + pluginDir := t.TempDir() + cfg := configfile.New("") + cfg.CLIPluginsExtraDirs = []string{pluginDir} + cfg.Plugins = make(map[string]map[string]string) + for name, response := range tc.responses { + plugin := fmt.Sprintf(`#!/bin/sh +case "$1" in +docker-cli-plugin-metadata) + printf '%%s\n' '{"SchemaVersion":"0.1.0","Vendor":"test"}' + ;; +*) + test "$2" = docker-cli-plugin-hooks || exit 1 + printf 'called\n' >> "$0.calls" + printf '%%s\n' '%s' + ;; +esac +`, response) + assert.NilError(t, os.WriteFile(filepath.Join(pluginDir, "docker-"+name), []byte(plugin), 0o755)) + cfg.Plugins[name] = map[string]string{"hooks": "build"} + } + + root := &cobra.Command{Use: "docker"} + sub := &cobra.Command{Use: "build"} + root.AddCommand(sub) + var output strings.Builder + sub.SetErr(&output) + + runHooks(context.Background(), cfg, root, sub, "build", nil, "") + assert.Equal(t, output.String(), tc.expected) + for name := range tc.responses { + calls, err := os.ReadFile(filepath.Join(pluginDir, "docker-"+name+".calls")) + assert.NilError(t, err) + assert.Equal(t, string(calls), "called\n", "each plugin hook must run exactly once") + } + }) + } +} From 433b13a24981e5e2dc8bff7a382c2ea13b48d763 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gronowski?= Date: Thu, 27 Aug 2026 19:14:43 +0200 Subject: [PATCH 06/12] Add Renovate config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Paweł Gronowski --- .github/renovate.json5 | 59 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 .github/renovate.json5 diff --git a/.github/renovate.json5 b/.github/renovate.json5 new file mode 100644 index 0000000000..41f3d1eca0 --- /dev/null +++ b/.github/renovate.json5 @@ -0,0 +1,59 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended", + // https://docs.renovatebot.com/modules/manager/github-actions/ + // https://docs.renovatebot.com/presets-helpers/ + "helpers:pinGitHubActionDigestsToSemver", + ":gitSignOff" + ], + "gomod": { + "managerFilePatterns": ["/(^|/)vendor\\.mod$/"] + }, + "postUpdateOptions": ["gomodTidy"], + "minimumReleaseAge": "3 days", + "internalChecksFilter": "strict", + "packageRules": [ + { + "matchManagers": ["gomod"], + "matchFileNames": ["vendor.mod"], + "semanticCommits": "disabled", + "commitMessagePrefix": "vendor:" + }, + { + "matchManagers": ["gomod"], + "matchFileNames": ["cmd/docker-trust/go.mod"], + // The trust module does not vendor dependencies; vendor/ belongs to the root module. + "postUpdateOptions": ["gomodSkipVendor"], + "semanticCommits": "disabled", + "commitMessagePrefix": "cmd/docker-trust:" + }, + { + "matchManagers": ["gomod"], + "matchFileNames": ["vendor.mod"], + "groupName": "golang.org/x packages", + "groupSlug": "golang-x", + "matchDepNames": ["/^golang\\.org\\/x\\//"] + }, + { + "matchManagers": ["github-actions"], + "minimumReleaseAge": "7 days", + "addLabels": ["area/ci", "status/2-code-review"] + }, + { + "matchManagers": ["github-actions"], + "groupName": "docker GitHub Actions", + "groupSlug": "docker-actions", + "matchDepNames": ["/^docker\\//"] + }, + { + // Keep all the google.golang.org/genproto modules at the same version. + "matchManagers": ["gomod"], + "matchFileNames": ["vendor.mod"], + "groupName": "google.golang.org/genproto packages", + "groupSlug": "google-genproto", + "matchDepNames": ["/^google\\.golang\\.org\\/genproto(\\/|$)/"], + "schedule": ["every month"] + } + ] +} From 37013cb262ada8d808007bfdfa7feb06fa521fa3 Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Wed, 14 Oct 2026 14:34:11 +0000 Subject: [PATCH 07/12] chore(deps): update actions/upload-artifact action to v7.0.2 Signed-off-by: Mend Renovate --- .github/workflows/build.yml | 2 +- .github/workflows/pr-review-trigger.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4f992f3eb4..982e1e597a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -91,7 +91,7 @@ jobs: PLATFORM: ${{ matrix.platform }} - name: Upload artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ${{ env.ARTIFACT_NAME }} path: /tmp/out/* diff --git a/.github/workflows/pr-review-trigger.yml b/.github/workflows/pr-review-trigger.yml index 1ecc03b098..5c28d63071 100644 --- a/.github/workflows/pr-review-trigger.yml +++ b/.github/workflows/pr-review-trigger.yml @@ -35,7 +35,7 @@ jobs: fi - name: Upload context - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: pr-review-context path: context/ From 1822903c1dc0d728eeef7d9be729f594f00cced9 Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Wed, 14 Oct 2026 14:34:17 +0000 Subject: [PATCH 08/12] chore(deps): update docker/setup-buildx-action action to v4.4.1 Signed-off-by: Mend Renovate --- .github/workflows/build.yml | 4 ++-- .github/workflows/e2e.yml | 2 +- .github/workflows/test.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4f992f3eb4..812290a431 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -65,7 +65,7 @@ jobs: steps: - name: Set up Docker Buildx - uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Build uses: docker/bake-action@018cb6412ab401ebaa809aa5f85966b74628600f # v7.4.0 @@ -156,7 +156,7 @@ jobs: steps: - name: Set up Docker Buildx - uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Build uses: docker/bake-action@018cb6412ab401ebaa809aa5f85966b74628600f # v7.4.0 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index b7b46b5e71..5773ef4bc7 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -66,7 +66,7 @@ jobs: docker info - name: Set up Docker Buildx - uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Run ${{ matrix.target }} run: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6b6a5ea564..70cf762f6f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,7 +30,7 @@ jobs: steps: - name: Set up Docker Buildx - uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Test uses: docker/bake-action@018cb6412ab401ebaa809aa5f85966b74628600f # v7.4.0 From 529f66d2f9aa8cf2762a1aea04450133b31cf8de Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Wed, 14 Oct 2026 16:02:28 +0000 Subject: [PATCH 09/12] chore(deps): update docker/buildx-bin docker tag to v0.38.0 Signed-off-by: Mend Renovate --- Dockerfile | 2 +- dockerfiles/Dockerfile.dev | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 59f519dcbe..5524471aed 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,7 +25,7 @@ ARG GOTESTSUM_VERSION=v1.13.0 # BUILDX_VERSION sets the version of buildx to use for the e2e tests. # It must be a tag in the docker.io/docker/buildx-bin image repository # on Docker Hub. -ARG BUILDX_VERSION=0.34.1 +ARG BUILDX_VERSION=0.38.0 # COMPOSE_VERSION is the version of compose to install in the dev container. # It must be a tag in the docker.io/docker/compose-bin image repository diff --git a/dockerfiles/Dockerfile.dev b/dockerfiles/Dockerfile.dev index c1a7d04c27..5c310953f7 100644 --- a/dockerfiles/Dockerfile.dev +++ b/dockerfiles/Dockerfile.dev @@ -10,7 +10,7 @@ ARG ALPINE_VERSION=3.23 # BUILDX_VERSION sets the version of buildx to install in the dev container. # It must be a valid tag in the docker.io/docker/buildx-bin image repository # on Docker Hub. -ARG BUILDX_VERSION=0.34.1 +ARG BUILDX_VERSION=0.38.0 FROM docker/buildx-bin:${BUILDX_VERSION} AS buildx FROM golang:${GO_VERSION}-alpine${ALPINE_VERSION} AS golang From cb8870a5370ee4e887f234cf3cfddff0abc366bf Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Wed, 14 Oct 2026 16:02:33 +0000 Subject: [PATCH 10/12] chore(deps): update docker/compose-bin docker tag to v5.6.0 Signed-off-by: Mend Renovate --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 59f519dcbe..e928c7dabb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,7 +30,7 @@ ARG BUILDX_VERSION=0.34.1 # COMPOSE_VERSION is the version of compose to install in the dev container. # It must be a tag in the docker.io/docker/compose-bin image repository # on Docker Hub. -ARG COMPOSE_VERSION=v5.1.3 +ARG COMPOSE_VERSION=v5.6.0 FROM --platform=$BUILDPLATFORM tonistiigi/xx:${XX_VERSION} AS xx From 66f6797582b9735eb072fc4c8422e3ddd96eb64e Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Wed, 14 Oct 2026 16:02:38 +0000 Subject: [PATCH 11/12] chore(deps): update tonistiigi/xx docker tag to v1.10.0 Signed-off-by: Mend Renovate --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 59f519dcbe..7a12b6972a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ ARG GO_VERSION=1.26.9 # XX_VERSION specifies the version of the xx utility to use. # It must be a valid tag in the docker.io/tonistiigi/xx image repository. -ARG XX_VERSION=1.9.0 +ARG XX_VERSION=1.10.0 # GOVERSIONINFO_VERSION is the version of GoVersionInfo to install. # It must be a valid tag from https://github.com/josephspurrier/goversioninfo From a3a2bb6118c9f04edce1adc34b17140b5984ea1b Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Wed, 14 Oct 2026 18:03:30 +0000 Subject: [PATCH 12/12] chore(deps): update dependency go to v1.27.1 Signed-off-by: Mend Renovate --- .github/workflows/codeql.yml | 2 +- .github/workflows/test.yml | 2 +- .github/workflows/validate.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index ead4a4f0f2..73435198ca 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -64,7 +64,7 @@ jobs: name: Update Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: "1.26.9" + go-version: "1.27.1" cache: false - name: Initialize CodeQL diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 70cf762f6f..0714b6345c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -68,7 +68,7 @@ jobs: name: Set up Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: "1.26.9" + go-version: "1.27.1" cache: false - name: Test diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 1396c8d7b0..3f693dd853 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -101,7 +101,7 @@ jobs: name: Set up Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: "1.26.9" + go-version: "1.27.1" cache: false - name: Run gocompat check