Repository: docker/buildx

Issue:
Finalize records before exporting
follow-up https://github.com/docker/actions-toolkit/pull/652#discussion_r2055080629

We should make sure records are finalized before exporting them similar to `history trace`: https://github.com/docker/buildx/blob/9ba3f77219b15cff4cea37262d270083dec334ad/commands/history/trace.go#L57-L70

@tonistiigi I wonder if we should have a `Finalize` attribute in https://github.com/docker/buildx/blob/9ba3f77219b15cff4cea37262d270083dec334ad/commands/history/utils.go#L116-L119

To share logic for both `export` and `trace` in `queryRecords`?

Discussion:

Solution patch:
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index cf3d1996..1a7bb86e 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -36,8 +36,8 @@ env:
   TEST_CACHE_SCOPE: "test"
   TESTFLAGS: "-v --parallel=6 --timeout=30m"
   GOTESTSUM_FORMAT: "standard-verbose"
-  GO_VERSION: "1.23"
-  GOTESTSUM_VERSION: "v1.9.0"  # same as one in Dockerfile
+  GO_VERSION: "1.24"
+  GOTESTSUM_VERSION: "v1.12.0"  # same as one in Dockerfile
 
 jobs:
   test-integration:
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 5da47e2e..4e58b499 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -17,7 +17,7 @@ on:
   pull_request:
 
 env:
-  GO_VERSION: "1.23"
+  GO_VERSION: "1.24"
 
 jobs:
   codeql:
diff --git a/Dockerfile b/Dockerfile
index 71b726c5..e8878dd7 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,6 +1,6 @@
 # syntax=docker/dockerfile:1
 
-ARG GO_VERSION=1.23
+ARG GO_VERSION=1.24
 ARG ALPINE_VERSION=3.21
 ARG XX_VERSION=1.6.1
 
diff --git a/bake/bake.go b/bake/bake.go
index fd7f901b..ad626ee6 100644
--- a/bake/bake.go
+++ b/bake/bake.go
@@ -483,8 +483,7 @@ func (c Config) expandTargets(pattern string) ([]string, error) {
 func (c Config) loadLinks(name string, t *Target, m map[string]*Target, o map[string]map[string]Override, visited []string, ent *EntitlementConf) error {
 	visited = append(visited, name)
 	for _, v := range t.Contexts {
-		if strings.HasPrefix(v, "target:") {
-			target := strings.TrimPrefix(v, "target:")
+		if target, ok := strings.CutPrefix(v, "target:"); ok {
 			if target == name {
 				return errors.Errorf("target %s cannot link to itself", target)
 			}
@@ -1275,8 +1274,8 @@ func collectLocalPaths(t build.Inputs) []string {
 		if v, ok := isLocalPath(t.DockerfilePath); ok {
 			out = append(out, v)
 		}
-	} else if strings.HasPrefix(t.ContextPath, "cwd://") {
-		out = append(out, strings.TrimPrefix(t.ContextPath, "cwd://"))
+	} else if v, ok := strings.CutPrefix(t.ContextPath, "cwd://"); ok {
+		out = append(out, v)
 	}
 	for _, v := range t.NamedContexts {
 		if v.State != nil {
@@ -1328,11 +1327,11 @@ func toBuildOpt(t *Target, inp *Input) (*build.Options, error) {
 		bi.DockerfileInline = *t.DockerfileInline
 	}
 	updateContext(&bi, inp)
-	if strings.HasPrefix(bi.DockerfilePath, "cwd://") {
+	if v, ok := strings.CutPrefix(bi.DockerfilePath, "cwd://"); ok {
 		// If Dockerfile is local for a remote invocation, we first check if
 		// it's not outside the working directory and then resolve it to an
 		// absolute path.
-		bi.DockerfilePath = path.Clean(strings.TrimPrefix(bi.DockerfilePath, "cwd://"))
+		bi.DockerfilePath = path.Clean(v)
 		var err error
 		bi.DockerfilePath, err = filepath.Abs(bi.DockerfilePath)
 		if err != nil {
@@ -1357,15 +1356,15 @@ func toBuildOpt(t *Target, inp *Input) (*build.Options, error) {
 			return nil, errors.Errorf("reading a dockerfile for a remote build invocation is currently not supported")
 		}
 	}
-	if strings.HasPrefix(bi.ContextPath, "cwd://") {
-		bi.ContextPath = path.Clean(strings.TrimPrefix(bi.ContextPath, "cwd://"))
+	if v, ok := strings.CutPrefix(bi.ContextPath, "cwd://"); ok {
+		bi.ContextPath = path.Clean(v)
 	}
 	if !build.IsRemoteURL(bi.ContextPath) && bi.ContextState == nil && !filepath.IsAbs(bi.DockerfilePath) {
 		bi.DockerfilePath = filepath.Join(bi.ContextPath, bi.DockerfilePath)
 	}
 	for k, v := range bi.NamedContexts {
-		if strings.HasPrefix(v.Path, "cwd://") {
-			bi.NamedContexts[k] = build.NamedContext{Path: path.Clean(strings.TrimPrefix(v.Path, "cwd://"))}
+		if v, ok := strings.CutPrefix(v.Path, "cwd://"); ok {
+			bi.NamedContexts[k] = build.NamedContext{Path: path.Clean(v)}
 		}
 	}
 
diff --git a/bake/compose.go b/bake/compose.go
index 931fec36..1ca046d0 100644
--- a/bake/compose.go
+++ b/bake/compose.go
@@ -62,7 +62,6 @@ func ParseCompose(cfgs []composetypes.ConfigFile, envs map[string]string) (*Conf
 		g := &Group{Name: "default"}
 
 		for _, s := range cfg.Services {
-			s := s
 			if s.Build == nil {
 				continue
 			}
@@ -144,7 +143,6 @@ func ParseCompose(cfgs []composetypes.ConfigFile, envs map[string]string) (*Conf
 			// compose does not support nil values for labels
 			labels := map[string]*string{}
 			for k, v := range s.Build.Labels {
-				v := v
 				labels[k] = &v
 			}
 
diff --git a/bake/hclparser/hclparser.go b/bake/hclparser/hclparser.go
index 24e3b7c1..d132e966 100644
--- a/bake/hclparser/hclparser.go
+++ b/bake/hclparser/hclparser.go
@@ -781,7 +781,6 @@ func Parse(b hcl.Body, opt Opt, val any) (*ParseMeta, hcl.Diagnostics) {
 	}
 
 	for _, a := range content.Attributes {
-		a := a
 		return nil, hcl.Diagnostics{
 			&hcl.Diagnostic{
 				Severity: hcl.DiagError,
@@ -834,7 +833,6 @@ func Parse(b hcl.Body, opt Opt, val any) (*ParseMeta, hcl.Diagnostics) {
 				context = subject
 			} else {
 				for _, block := range blocks.Blocks {
-					block := block
 					if block.Type == "function" && len(block.Labels) == 1 && block.Labels[0] == k {
 						subject = block.LabelRanges[0].Ptr()
 						context = block.DefRange.Ptr()
@@ -903,7 +901,6 @@ func Parse(b hcl.Body, opt Opt, val any) (*ParseMeta, hcl.Diagnostics) {
 
 	diags = hcl.Diagnostics{}
 	for _, b := range content.Blocks {
-		b := b
 		v := reflect.ValueOf(val)
 
 		err := p.resolveBlock(b, nil)
diff --git a/build/build.go b/build/build.go
index a857fba4..0f8fca6d 100644
--- a/build/build.go
+++ b/build/build.go
@@ -383,7 +383,6 @@ func BuildWithResultHandler(ctx context.Context, nodes []builder.Node, opts map[
 						wg.Add(1)
 						sharedSessionsWG[node.Name] = wg
 						for _, s := range sessions {
-							s := s
 							eg.Go(func() error {
 								return s.Run(baseCtx, c.Dialer())
 							})
diff --git a/build/opt.go b/build/opt.go
index ade77966..a1014022 100644
--- a/build/opt.go
+++ b/build/opt.go
@@ -503,8 +503,7 @@ func loadInputs(ctx context.Context, d *driver.DriverHandle, inp *Inputs, pw pro
 		}
 
 		// handle OCI layout
-		if strings.HasPrefix(v.Path, "oci-layout://") {
-			localPath := strings.TrimPrefix(v.Path, "oci-layout://")
+		if localPath, ok := strings.CutPrefix(v.Path, "oci-layout://"); ok {
 			localPath, dig, hasDigest := strings.Cut(localPath, "@")
 			localPath, tag, hasTag := strings.Cut(localPath, ":")
 			if !hasTag {
diff --git a/build/provenance.go b/build/provenance.go
index b6cca56f..b355f8e5 100644
--- a/build/provenance.go
+++ b/build/provenance.go
@@ -92,7 +92,6 @@ func fetchProvenance(ctx context.Context, c *client.Client, ref string, mode con
 			})
 		} else if ev.Record.Results != nil {
 			for platform, res := range ev.Record.Results {
-				platform := platform
 				desc := lookupProvenance(res)
 				if desc == nil {
 					continue
diff --git a/commands/bake.go b/commands/bake.go
index e34dbb84..e9636e2f 100644
--- a/commands/bake.go
+++ b/commands/bake.go
@@ -559,8 +559,7 @@ func readBakeFiles(ctx context.Context, nodes []builder.Node, url string, names
 	var rnames []string // remote
 	var anames []string // both
 	for _, v := range names {
-		if strings.HasPrefix(v, "cwd://") {
-			tname := strings.TrimPrefix(v, "cwd://")
+		if tname, ok := strings.CutPrefix(v, "cwd://"); ok {
 			lnames = append(lnames, tname)
 			anames = append(anames, tname)
 		} else {
diff --git a/commands/history/export.go b/commands/history/export.go
index 5ccf25f3..af8149a7 100644
--- a/commands/history/export.go
+++ b/commands/history/export.go
@@ -20,10 +20,11 @@ import (
 )
 
 type exportOptions struct {
-	builder string
-	refs    []string
-	output  string
-	all     bool
+	builder  string
+	refs     []string
+	output   string
+	all      bool
+	finalize bool
 }
 
 func runExport(ctx context.Context, dockerCli command.Cli, opts exportOptions) error {
@@ -62,6 +63,26 @@ func runExport(ctx context.Context, dockerCli command.Cli, opts exportOptions) e
 			return errors.Errorf("no record found for ref %q", ref)
 		}
 
+		if opts.finalize {
+			var finalized bool
+			for _, rec := range recs {
+				if rec.Trace == nil {
+					finalized = true
+					if err := finalizeRecord(ctx, rec.Ref, nodes); err != nil {
+						return err
+					}
+				}
+			}
+			if finalized {
+				recs, err = queryRecords(ctx, ref, nodes, &queryOptions{
+					CompletedOnly: true,
+				})
+				if err != nil {
+					return err
+				}
+			}
+		}
+
 		if ref == "" {
 			slices.SortFunc(recs, func(a, b historyRecord) int {
 				return b.CreatedAt.AsTime().Compare(a.CreatedAt.AsTime())
@@ -154,7 +175,8 @@ func exportCmd(dockerCli command.Cli, rootOpts RootOptions) *cobra.Command {
 
 	flags := cmd.Flags()
 	flags.StringVarP(&options.output, "output", "o", "", "Output file path")
-	flags.BoolVar(&options.all, "all", false, "Export all records for the builder")
+	flags.BoolVar(&options.all, "all", false, "Export all build records for the builder")
+	flags.BoolVar(&options.finalize, "finalize", false, "Ensure build records are finalized before exporting")
 
 	return cmd
 }
diff --git a/commands/history/inspect.go b/commands/history/inspect.go
index 772530bc..6fd2a252 100644
--- a/commands/history/inspect.go
+++ b/commands/history/inspect.go
@@ -869,9 +869,9 @@ func printTable(w io.Writer, kvs []keyValueOutput, title string) {
 func readKeyValues(attrs map[string]string, prefix string) []keyValueOutput {
 	var out []keyValueOutput
 	for k, v := range attrs {
-		if strings.HasPrefix(k, prefix) {
+		if name, ok := strings.CutPrefix(k, prefix); ok {
 			out = append(out, keyValueOutput{
-				Name:  strings.TrimPrefix(k, prefix),
+				Name:  name,
 				Value: v,
 			})
 		}
diff --git a/commands/history/rm.go b/commands/history/rm.go
index 596fd567..4f5338d9 100644
--- a/commands/history/rm.go
+++ b/commands/history/rm.go
@@ -43,7 +43,6 @@ func runRm(ctx context.Context, dockerCli command.Cli, opts rmOptions) error {
 
 	eg, ctx := errgroup.WithContext(ctx)
 	for i, node := range nodes {
-		node := node
 		eg.Go(func() error {
 			if node.Driver == nil {
 				return nil
diff --git a/commands/history/trace.go b/commands/history/trace.go
index 1e2b0f1b..b841ce35 100644
--- a/commands/history/trace.go
+++ b/commands/history/trace.go
@@ -17,7 +17,6 @@ import (
 	"github.com/docker/buildx/util/otelutil"
 	"github.com/docker/buildx/util/otelutil/jaeger"
 	"github.com/docker/cli/cli/command"
-	controlapi "github.com/moby/buildkit/api/services/control"
 	"github.com/opencontainers/go-digest"
 	ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
 	"github.com/pkg/browser"
@@ -57,14 +56,7 @@ func loadTrace(ctx context.Context, ref string, nodes []builder.Node) (string, [
 		// build is complete but no trace yet. try to finalize the trace
 		time.Sleep(1 * time.Second) // give some extra time for last parts of trace to be written
 
-		c, err := rec.node.Driver.Client(ctx)
-		if err != nil {
-			return "", nil, err
-		}
-		_, err = c.ControlClient().UpdateBuildHistory(ctx, &controlapi.UpdateBuildHistoryRequest{
-			Ref:      rec.Ref,
-			Finalize: true,
-		})
+		err := finalizeRecord(ctx, rec.Ref, []builder.Node{*rec.node})
 		if err != nil {
 			return "", nil, err
 		}
diff --git a/commands/history/utils.go b/commands/history/utils.go
index 95c8702e..825ca5e2 100644
--- a/commands/history/utils.go
+++ b/commands/history/utils.go
@@ -139,7 +139,6 @@ func queryRecords(ctx context.Context, ref string, nodes []builder.Node, opts *q
 
 	eg, ctx := errgroup.WithContext(ctx)
 	for _, node := range nodes {
-		node := node
 		eg.Go(func() error {
 			if node.Driver == nil {
 				return nil
@@ -248,6 +247,28 @@ func queryRecords(ctx context.Context, ref string, nodes []builder.Node, opts *q
 	return out, nil
 }
 
+func finalizeRecord(ctx context.Context, ref string, nodes []builder.Node) error {
+	eg, ctx := errgroup.WithContext(ctx)
+	for _, node := range nodes {
+		node := node
+		eg.Go(func() error {
+			if node.Driver == nil {
+				return nil
+			}
+			c, err := node.Driver.Client(ctx)
+			if err != nil {
+				return err
+			}
+			_, err = c.ControlClient().UpdateBuildHistory(ctx, &controlapi.UpdateBuildHistoryRequest{
+				Ref:      ref,
+				Finalize: true,
+			})
+			return err
+		})
+	}
+	return eg.Wait()
+}
+
 func formatDuration(d time.Duration) string {
 	if d < time.Minute {
 		return fmt.Sprintf("%.1fs", d.Seconds())
diff --git a/commands/imagetools/create.go b/commands/imagetools/create.go
index 8f676845..fc2ad066 100644
--- a/commands/imagetools/create.go
+++ b/commands/imagetools/create.go
@@ -184,7 +184,6 @@ func runCreate(ctx context.Context, dockerCli command.Cli, in createOptions, arg
 	pw := progress.WithPrefix(printer, "internal", true)
 
 	for _, t := range tags {
-		t := t
 		eg.Go(func() error {
 			return progress.Wrap(fmt.Sprintf("pushing %s", t.String()), pw.Write, func(sub progress.SubLogger) error {
 				eg2, _ := errgroup.WithContext(ctx)
diff --git a/controller/build/options.go b/controller/build/options.go
index dba752b9..817f4836 100644
--- a/controller/build/options.go
+++ b/controller/build/options.go
@@ -70,9 +70,8 @@ func ResolveOptionPaths(options *Options) (_ *Options, err error) {
 	for k, v := range options.NamedContexts {
 		if isRemoteURL(v) || strings.HasPrefix(v, "docker-image://") {
 			// url prefix, this is a remote path
-		} else if strings.HasPrefix(v, "oci-layout://") {
+		} else if p, ok := strings.CutPrefix(v, "oci-layout://"); ok {
 			// oci layout prefix, this is a local path
-			p := strings.TrimPrefix(v, "oci-layout://")
 			p, err = filepath.Abs(p)
 			if err != nil {
 				return nil, err
diff --git a/docs/reference/buildx_history_export.md b/docs/reference/buildx_history_export.md
index ae394077..edb99ee8 100644
--- a/docs/reference/buildx_history_export.md
+++ b/docs/reference/buildx_history_export.md
@@ -5,12 +5,13 @@ Export build records into Docker Desktop bundle
 
 ### Options
 
-| Name                                   | Type     | Default | Description                              |
-|:---------------------------------------|:---------|:--------|:-----------------------------------------|
-| [`--all`](#all)                        | `bool`   |         | Export all records for the builder       |
-| [`--builder`](#builder)                | `string` |         | Override the configured builder instance |
-| [`-D`](#debug), [`--debug`](#debug)    | `bool`   |         | Enable debug logging                     |
-| [`-o`](#output), [`--output`](#output) | `string` |         | Output file path                         |
+| Name                                   | Type     | Default | Description                                         |
+|:---------------------------------------|:---------|:--------|:----------------------------------------------------|
+| [`--all`](#all)                        | `bool`   |         | Export all build records for the builder            |
+| [`--builder`](#builder)                | `string` |         | Override the configured builder instance            |
+| [`-D`](#debug), [`--debug`](#debug)    | `bool`   |         | Enable debug logging                                |
+| [`--finalize`](#finalize)              | `bool`   |         | Ensure build records are finalized before exporting |
+| [`-o`](#output), [`--output`](#output) | `string` |         | Output file path                                    |
 
 
 <!---MARKER_GEN_END-->
@@ -49,6 +50,16 @@ docker buildx history export --builder builder0 ^1 -o builder0-build.dockerbuild
 docker buildx history export --debug qu2gsuo8ejqrwdfii23xkkckt -o debug-build.dockerbuild
 ```
 
+### <a name="finalize"></a> Ensure build records are finalized before exporting (--finalize)
+
+Clients can report their own traces concurrently, and not all traces may be
+saved yet by the time of the export. Use the `--finalize` flag to ensure all
+traces are finalized before exporting.
+
+```console
+docker buildx history export --finalize qu2gsuo8ejqrwdfii23xkkckt -o finalized-build.dockerbuild
+```
+
 ### <a name="output"></a> Export a single build to a custom file (--output)
 
 ```console
diff --git a/hack/dockerfiles/docs.Dockerfile b/hack/dockerfiles/docs.Dockerfile
index 2c18b12c..2e3400ec 100644
--- a/hack/dockerfiles/docs.Dockerfile
+++ b/hack/dockerfiles/docs.Dockerfile
@@ -1,6 +1,6 @@
 # syntax=docker/dockerfile:1
 
-ARG GO_VERSION=1.23
+ARG GO_VERSION=1.24
 ARG ALPINE_VERSION=3.21
 
 ARG FORMATS=md,yaml
diff --git a/hack/dockerfiles/generated-files.Dockerfile b/hack/dockerfiles/generated-files.Dockerfile
index a7b406c1..99149bff 100644
--- a/hack/dockerfiles/generated-files.Dockerfile
+++ b/hack/dockerfiles/generated-files.Dockerfile
@@ -5,7 +5,7 @@
 # Copyright The Buildx Authors.
 # Licensed under the Apache License, Version 2.0
 
-ARG GO_VERSION=1.23
+ARG GO_VERSION=1.24
 ARG PROTOC_VERSION=3.11.4
 ARG PROTOC_GOOGLEAPIS_VERSION=2af421884dd468d565137215c946ebe4e245ae26
 
diff --git a/hack/dockerfiles/govulncheck.Dockerfile b/hack/dockerfiles/govulncheck.Dockerfile
index 3b955b10..1f1cd84e 100644
--- a/hack/dockerfiles/govulncheck.Dockerfile
+++ b/hack/dockerfiles/govulncheck.Dockerfile
@@ -1,6 +1,6 @@
 # syntax=docker/dockerfile:1
 
-ARG GO_VERSION=1.23
+ARG GO_VERSION=1.24
 ARG ALPINE_VERSION=3.21
 
 ARG GOVULNCHECK_VERSION=v1.1.3
diff --git a/hack/dockerfiles/lint.Dockerfile b/hack/dockerfiles/lint.Dockerfile
index 14e8a5bc..1ce48dff 100644
--- a/hack/dockerfiles/lint.Dockerfile
+++ b/hack/dockerfiles/lint.Dockerfile
@@ -1,15 +1,14 @@
 # syntax=docker/dockerfile:1
 
-ARG GO_VERSION=1.23
+ARG GO_VERSION=1.24
 ARG ALPINE_VERSION=3.21
 ARG XX_VERSION=1.6.1
 
 ARG GOLANGCI_LINT_VERSION=v2.1.5
 ARG GOLANGCI_FROM_SOURCE=false
-# v0.31 requires go1.24
-ARG GOPLS_VERSION=v0.30.0
-# disabled: deprecated unusedvariable simplifyrange
-ARG GOPLS_ANALYZERS="embeddirective fillreturns hostport infertypeargs modernize nonewvars noresultvalues simplifycompositelit simplifyslice unusedparams yield"
+ARG GOPLS_VERSION=v0.33.0
+# GOPLS_ANALYZERS defines gopls analyzers to be run. disabled by default: deprecated simplifyrange unusedfunc unusedvariable
+ARG GOPLS_ANALYZERS="embeddirective fillreturns infertypeargs maprange modernize nonewvars noresultvalues simplifycompositelit simplifyslice unusedparams yield"
 
 FROM --platform=$BUILDPLATFORM tonistiigi/xx:${XX_VERSION} AS xx
 
diff --git a/hack/dockerfiles/vendor.Dockerfile b/hack/dockerfiles/vendor.Dockerfile
index 1dac1c37..341f64ac 100644
--- a/hack/dockerfiles/vendor.Dockerfile
+++ b/hack/dockerfiles/vendor.Dockerfile
@@ -1,6 +1,6 @@
 # syntax=docker/dockerfile:1
 
-ARG GO_VERSION=1.23
+ARG GO_VERSION=1.24
 ARG ALPINE_VERSION=3.21
 
 ARG MODOUTDATED_VERSION=v0.9.0
diff --git a/util/dockerutil/progress.go b/util/dockerutil/progress.go
index 608c9c2a..10f20f7c 100644
--- a/util/dockerutil/progress.go
+++ b/util/dockerutil/progress.go
@@ -17,7 +17,6 @@ func fromReader(l progress.SubLogger, rc io.ReadCloser) error {
 
 	defer func() {
 		for _, st := range started {
-			st := st
 			if st.Completed == nil {
 				now := time.Now()
 				st.Completed = &now
diff --git a/util/imagetools/loader.go b/util/imagetools/loader.go
index d7753b56..3650bcaf 100644
--- a/util/imagetools/loader.go
+++ b/util/imagetools/loader.go
@@ -225,7 +225,6 @@ func (l *loader) fetch(ctx context.Context, fetcher remotes.Fetcher, desc ocispe
 
 		eg, ctx := errgroup.WithContext(ctx)
 		for _, d := range idx.Manifests {
-			d := d
 			eg.Go(func() error {
 				return l.fetch(ctx, fetcher, d, r)
 			})



Test patch:
diff --git a/bake/bake_test.go b/bake/bake_test.go
index 40ff01bd..482b83ad 100644
--- a/bake/bake_test.go
+++ b/bake/bake_test.go
@@ -1381,7 +1381,6 @@ target "d" {
 		},
 	}
 	for _, tt := range cases {
-		tt := tt
 		t.Run(tt.name, func(t *testing.T) {
 			m, g, err := ReadTargets(ctx, []File{f}, []string{"d"}, tt.overrides, nil, &EntitlementConf{})
 			require.NoError(t, err)
@@ -1454,7 +1453,6 @@ group "default" {
 		},
 	}
 	for _, tt := range cases {
-		tt := tt
 		t.Run(tt.name, func(t *testing.T) {
 			m, g, err := ReadTargets(ctx, []File{f}, []string{"default"}, tt.overrides, nil, &EntitlementConf{})
 			require.NoError(t, err)
@@ -1509,7 +1507,6 @@ func TestTargetName(t *testing.T) {
 		},
 	}
 	for _, tt := range cases {
-		tt := tt
 		t.Run(tt.target, func(t *testing.T) {
 			_, _, err := ReadTargets(ctx, []File{{
 				Name: "docker-bake.hcl",
@@ -1600,7 +1597,6 @@ target "f" {
 		},
 	}
 	for _, tt := range cases {
-		tt := tt
 		t.Run(strings.Join(tt.names, "+"), func(t *testing.T) {
 			m, g, err := ReadTargets(ctx, []File{f}, tt.names, nil, nil, &EntitlementConf{})
 			require.NoError(t, err)
diff --git a/bake/compose_test.go b/bake/compose_test.go
index 7619fc7a..e19ff138 100644
--- a/bake/compose_test.go
+++ b/bake/compose_test.go
@@ -518,7 +518,6 @@ func TestServiceName(t *testing.T) {
 		},
 	}
 	for _, tt := range cases {
-		tt := tt
 		t.Run(tt.svc, func(t *testing.T) {
 			_, err := ParseCompose([]composetypes.ConfigFile{{Content: []byte(`
 services:
@@ -589,7 +588,6 @@ services:
 		},
 	}
 	for _, tt := range cases {
-		tt := tt
 		t.Run(tt.name, func(t *testing.T) {
 			_, err := ParseCompose([]composetypes.ConfigFile{{Content: tt.dt}}, nil)
 			if tt.wantErr {
@@ -665,7 +663,6 @@ target "default" {
 		},
 	}
 	for _, tt := range cases {
-		tt := tt
 		t.Run(tt.name, func(t *testing.T) {
 			isCompose, err := validateComposeFile(tt.dt, tt.fn)
 			assert.Equal(t, tt.isCompose, isCompose)
diff --git a/build/git_test.go b/build/git_test.go
index 9a9668bf..5b33049d 100644
--- a/build/git_test.go
+++ b/build/git_test.go
@@ -109,7 +109,6 @@ func TestGetGitAttributes(t *testing.T) {
 		},
 	}
 	for _, tt := range cases {
-		tt := tt
 		t.Run(tt.name, func(t *testing.T) {
 			setupTest(t)
 			if tt.envGitLabels != "" {
diff --git a/build/utils_test.go b/build/utils_test.go
index f12eda3a..02b597b9 100644
--- a/build/utils_test.go
+++ b/build/utils_test.go
@@ -130,7 +130,6 @@ func TestToBuildkitExtraHosts(t *testing.T) {
 	}
 
 	for _, tc := range tests {
-		tc := tc
 		if tc.expectedOut == "" {
 			tc.expectedOut = strings.Join(tc.input, ",")
 		}
diff --git a/builder/builder_test.go b/builder/builder_test.go
index 59ab5da8..6c308068 100644
--- a/builder/builder_test.go
+++ b/builder/builder_test.go
@@ -190,7 +190,6 @@ foo = "bar"
 		},
 	}
 	for _, tt := range testCases {
-		tt := tt
 		t.Run(tt.name, func(t *testing.T) {
 			flags, err := parseBuildkitdFlags(tt.flags, tt.driver, tt.driverOpts, tt.buildkitdConfigFile)
 			if tt.wantErr {
diff --git a/commands/ls_test.go b/commands/ls_test.go
index a428213a..cdfee843 100644
--- a/commands/ls_test.go
+++ b/commands/ls_test.go
@@ -164,7 +164,6 @@ func TestTruncPlatforms(t *testing.T) {
 		},
 	}
 	for _, tt := range tests {
-		tt := tt
 		t.Run(tt.name, func(t *testing.T) {
 			tpfs := truncPlatforms(tt.platforms, tt.max)
 			assert.Equal(t, tt.expectedList, tpfs.List())
diff --git a/controller/build/options_test.go b/controller/build/options_test.go
index 548351d0..aa814e78 100644
--- a/controller/build/options_test.go
+++ b/controller/build/options_test.go
@@ -240,7 +240,6 @@ func TestResolvePaths(t *testing.T) {
 		},
 	}
 	for _, tt := range tests {
-		tt := tt
 		t.Run(tt.name, func(t *testing.T) {
 			got, err := ResolveOptionPaths(tt.options)
 			require.NoError(t, err)
diff --git a/driver/docker/version_test.go b/driver/docker/version_test.go
index 03b2c6a6..c342ba86 100644
--- a/driver/docker/version_test.go
+++ b/driver/docker/version_test.go
@@ -9,7 +9,6 @@ import (
 
 func TestConstraint(t *testing.T) {
 	for _, tt := range mobyBuildkitVersions {
-		tt := tt
 		t.Run(tt.MobyVersionConstraint, func(t *testing.T) {
 			_, err := semver.NewConstraint(tt.MobyVersionConstraint)
 			require.NoError(t, err)
@@ -121,7 +120,6 @@ func TestResolveBuildKitVersion(t *testing.T) {
 		},
 	}
 	for _, tt := range cases {
-		tt := tt
 		t.Run(tt.mobyVersion, func(t *testing.T) {
 			bkVersion, err := resolveBuildKitVersion(tt.mobyVersion)
 			if tt.err {
diff --git a/tests/history.go b/tests/history.go
new file mode 100644
index 00000000..6ced64cf
--- /dev/null
+++ b/tests/history.go
@@ -0,0 +1,141 @@
+package tests
+
+import (
+	"encoding/json"
+	"os"
+	"path"
+	"path/filepath"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/moby/buildkit/util/testutil/integration"
+	"github.com/stretchr/testify/require"
+)
+
+var historyTests = []func(t *testing.T, sb integration.Sandbox){
+	testHistoryExport,
+	testHistoryExportFinalize,
+	testHistoryInspect,
+	testHistoryLs,
+	testHistoryRm,
+}
+
+func testHistoryExport(t *testing.T, sb integration.Sandbox) {
+	ref := buildTestProject(t, sb)
+	require.NotEmpty(t, ref.Ref)
+
+	outFile := path.Join(t.TempDir(), "export.dockerbuild")
+	cmd := buildxCmd(sb, withArgs("history", "export", ref.Ref, "--output", outFile))
+	out, err := cmd.Output()
+	require.NoError(t, err, string(out))
+	require.FileExists(t, outFile)
+}
+
+func testHistoryExportFinalize(t *testing.T, sb integration.Sandbox) {
+	ref := buildTestProject(t, sb)
+	require.NotEmpty(t, ref.Ref)
+
+	outFile := path.Join(t.TempDir(), "export.dockerbuild")
+	cmd := buildxCmd(sb, withArgs("history", "export", ref.Ref, "--finalize", "--output", outFile))
+	out, err := cmd.Output()
+	require.NoError(t, err, string(out))
+	require.FileExists(t, outFile)
+}
+
+func testHistoryInspect(t *testing.T, sb integration.Sandbox) {
+	ref := buildTestProject(t, sb)
+	require.NotEmpty(t, ref.Ref)
+
+	cmd := buildxCmd(sb, withArgs("history", "inspect", ref.Ref, "--format=json"))
+	out, err := cmd.Output()
+	require.NoError(t, err, string(out))
+
+	type recT struct {
+		Name              string
+		Ref               string
+		Context           string
+		Dockerfile        string
+		StartedAt         *time.Time
+		CompletedAt       *time.Time
+		Duration          time.Duration
+		Status            string
+		NumCompletedSteps int32
+		NumTotalSteps     int32
+		NumCachedSteps    int32
+	}
+	var rec recT
+	err = json.Unmarshal(out, &rec)
+	require.NoError(t, err)
+	require.Equal(t, ref.Ref, rec.Ref)
+	require.NotEmpty(t, rec.Name)
+}
+
+func testHistoryLs(t *testing.T, sb integration.Sandbox) {
+	ref := buildTestProject(t, sb)
+	require.NotEmpty(t, ref.Ref)
+
+	cmd := buildxCmd(sb, withArgs("history", "ls", "--filter=ref="+ref.Ref, "--format=json"))
+	out, err := cmd.Output()
+	require.NoError(t, err, string(out))
+
+	type recT struct {
+		Ref            string     `json:"ref"`
+		Name           string     `json:"name"`
+		Status         string     `json:"status"`
+		CreatedAt      *time.Time `json:"created_at"`
+		CompletedAt    *time.Time `json:"completed_at"`
+		TotalSteps     int32      `json:"total_steps"`
+		CompletedSteps int32      `json:"completed_steps"`
+		CachedSteps    int32      `json:"cached_steps"`
+	}
+	var rec recT
+	err = json.Unmarshal(out, &rec)
+	require.NoError(t, err)
+	require.Equal(t, ref.String(), rec.Ref)
+	require.NotEmpty(t, rec.Name)
+}
+
+func testHistoryRm(t *testing.T, sb integration.Sandbox) {
+	ref := buildTestProject(t, sb)
+	require.NotEmpty(t, ref.Ref)
+
+	cmd := buildxCmd(sb, withArgs("history", "rm", ref.Ref))
+	out, err := cmd.Output()
+	require.NoError(t, err, string(out))
+}
+
+type buildRef struct {
+	Builder string
+	Node    string
+	Ref     string
+}
+
+func (b buildRef) String() string {
+	return b.Builder + "/" + b.Node + "/" + b.Ref
+}
+
+func buildTestProject(t *testing.T, sb integration.Sandbox) buildRef {
+	dir := createTestProject(t)
+	out, err := buildCmd(sb, withArgs("--metadata-file", filepath.Join(dir, "md.json"), dir))
+	require.NoError(t, err, string(out))
+
+	dt, err := os.ReadFile(filepath.Join(dir, "md.json"))
+	require.NoError(t, err)
+
+	type mdT struct {
+		BuildRef string `json:"buildx.build.ref"`
+	}
+	var md mdT
+	err = json.Unmarshal(dt, &md)
+	require.NoError(t, err)
+
+	refParts := strings.Split(md.BuildRef, "/")
+	require.Len(t, refParts, 3)
+
+	return buildRef{
+		Builder: refParts[0],
+		Node:    refParts[1],
+		Ref:     refParts[2],
+	}
+}
diff --git a/tests/integration_test.go b/tests/integration_test.go
index 9cd76c7c..3884ca68 100644
--- a/tests/integration_test.go
+++ b/tests/integration_test.go
@@ -24,6 +24,7 @@ func TestIntegration(t *testing.T) {
 	tests = append(tests, commonTests...)
 	tests = append(tests, buildTests...)
 	tests = append(tests, bakeTests...)
+	tests = append(tests, historyTests...)
 	tests = append(tests, inspectTests...)
 	tests = append(tests, lsTests...)
 	tests = append(tests, imagetoolsTests...)
diff --git a/tests/ls.go b/tests/ls.go
index 23ad60d1..5898c7fb 100644
--- a/tests/ls.go
+++ b/tests/ls.go
@@ -36,7 +36,6 @@ func testLs(t *testing.T, sb integration.Sandbox) {
 
 	sbDriver, _, _ := driverName(sb.Name())
 	for _, tt := range tests {
-		tt := tt
 		t.Run(tt.name, func(t *testing.T) {
 			out, err := lsCmd(sb, withArgs(tt.args...))
 			require.NoError(t, err, out)
diff --git a/util/confutil/config_unix_test.go b/util/confutil/config_unix_test.go
index 2d217b4f..32bf3649 100644
--- a/util/confutil/config_unix_test.go
+++ b/util/confutil/config_unix_test.go
@@ -49,7 +49,6 @@ func TestIsSubPath(t *testing.T) {
 		},
 	}
 	for _, tt := range tests {
-		tt := tt
 		t.Run(tt.name, func(t *testing.T) {
 			ok, err := isSubPath(tt.basePath, tt.subPath)
 			require.NoError(t, err)
diff --git a/util/gitutil/credentials_test.go b/util/gitutil/credentials_test.go
index a2b7f529..7225f8dc 100644
--- a/util/gitutil/credentials_test.go
+++ b/util/gitutil/credentials_test.go
@@ -35,7 +35,6 @@ func TestStripCredentials(t *testing.T) {
 		},
 	}
 	for _, tt := range cases {
-		tt := tt
 		t.Run(tt.name, func(t *testing.T) {
 			if g, w := stripCredentials(tt.url), tt.want; g != w {
 				t.Fatalf("got: %q\nwant: %q", g, w)
diff --git a/util/gitutil/gitutil_test.go b/util/gitutil/gitutil_test.go
index cb3936fe..4eb09ba5 100644
--- a/util/gitutil/gitutil_test.go
+++ b/util/gitutil/gitutil_test.go
@@ -200,7 +200,6 @@ func TestGitRemoteURL(t *testing.T) {
 		},
 	}
 	for _, tt := range cases {
-		tt := tt
 		t.Run(tt.name, func(t *testing.T) {
 			gittestutil.Mktmp(t)
 			c, err := gitutil.New()
diff --git a/util/otelutil/span_test.go b/util/otelutil/span_test.go
index fb52d109..b4b50005 100644
--- a/util/otelutil/span_test.go
+++ b/util/otelutil/span_test.go
@@ -145,7 +145,6 @@ func TestAsAttributeKeyValue(t *testing.T) {
 		},
 	}
 	for _, tt := range tests {
-		tt := tt
 		t.Run(tt.name, func(t *testing.T) {
 			kv := keyValue{
 				Key:   "key",