dockerimg: mount Go caches for faster Docker race builds

When building sketch binary with race detector in Docker, the system
was downloading all modules and rebuilding from scratch every time,
making repeated builds slow and inefficient.

This adds host Go cache mounting to buildLinuxSketchBinWithDocker by:
- Getting host GOCACHE and GOMODCACHE directories using 'go env'
- Mounting them as volumes in the Docker container:
  -v $GOCACHE:/root/.cache/go-build
  -v $GOMODCACHE:/go/pkg/mod

The optimization ensures:
- First race build: Downloads modules (same time as before)
- Subsequent race builds: Reuses cached modules (much faster)
- Build artifacts persist between Docker container runs

Adds helper functions getHostGoCacheDir() and getHostGoModCacheDir()
to retrieve cache paths from the host environment, with proper error
handling and path validation.

Includes unit test to verify cache directory detection works correctly
on all platforms where Go is installed.

Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: sd279e56e1574e4e1k
diff --git a/dockerimg/dockerimg.go b/dockerimg/dockerimg.go
index 47d144e..e14955d 100644
--- a/dockerimg/dockerimg.go
+++ b/dockerimg/dockerimg.go
@@ -1023,9 +1023,7 @@
 
 // buildLinuxSketchBinWithDocker builds the Linux sketch binary using Docker when race detector is enabled.
 // This avoids cross-compilation issues with CGO which is required for the race detector.
-//
-// TODO: We should maybe mount a volume into /root/go so that we can cache the go.mod download
-// and so forth...
+// Mounts host Go module cache and build cache for faster subsequent builds.
 func buildLinuxSketchBinWithDocker(ctx context.Context, linuxGopath string) (string, error) {
 	// Find the git repo root
 	currentDir, err := os.Getwd()
@@ -1038,7 +1036,17 @@
 		return "", fmt.Errorf("could not find git root, cannot build with race detector outside a git repo: %w", err)
 	}
 
-	slog.DebugContext(ctx, "building Linux sketch binary with race detector using Docker", "git_root", gitRoot)
+	// Get host Go cache directories to mount for faster builds
+	goCacheDir, err := getHostGoCacheDir(ctx)
+	if err != nil {
+		return "", fmt.Errorf("failed to get host GOCACHE: %w", err)
+	}
+	goModCacheDir, err := getHostGoModCacheDir(ctx)
+	if err != nil {
+		return "", fmt.Errorf("failed to get host GOMODCACHE: %w", err)
+	}
+
+	slog.DebugContext(ctx, "building Linux sketch binary with race detector using Docker", "git_root", gitRoot, "gocache", goCacheDir, "gomodcache", goModCacheDir)
 
 	// Use the published Docker image tag
 	imageTag := dockerfileBaseHash()
@@ -1054,7 +1062,7 @@
 	// Create a unique container name
 	containerID := fmt.Sprintf("sketch-race-build-%d", time.Now().UnixNano())
 
-	// Run a container with the repo mounted
+	// Run a container with the repo mounted and Go caches for faster builds
 	start := time.Now()
 	slog.DebugContext(ctx, "running Docker container to build sketch with race detector")
 
@@ -1063,6 +1071,8 @@
 		"run",
 		"--name", containerID,
 		"-v", gitRoot + ":/app",
+		"-v", goCacheDir + ":/root/.cache/go-build",
+		"-v", goModCacheDir + ":/go/pkg/mod",
 		"-w", "/app",
 		imgName,
 		"sh", "-c", "cd /app && mkdir -p /tmp/sketch-out && go build -buildvcs=false -race -o /tmp/sketch-out/sketch sketch.dev/cmd/sketch",
@@ -1097,3 +1107,21 @@
 
 	return destFile, nil
 }
+
+// getHostGoCacheDir returns the host's GOCACHE directory
+func getHostGoCacheDir(ctx context.Context) (string, error) {
+	out, err := exec.CommandContext(ctx, "go", "env", "GOCACHE").CombinedOutput()
+	if err != nil {
+		return "", fmt.Errorf("failed to get GOCACHE: %s: %w", out, err)
+	}
+	return strings.TrimSpace(string(out)), nil
+}
+
+// getHostGoModCacheDir returns the host's GOMODCACHE directory
+func getHostGoModCacheDir(ctx context.Context) (string, error) {
+	out, err := exec.CommandContext(ctx, "go", "env", "GOMODCACHE").CombinedOutput()
+	if err != nil {
+		return "", fmt.Errorf("failed to get GOMODCACHE: %s: %w", out, err)
+	}
+	return strings.TrimSpace(string(out)), nil
+}
diff --git a/dockerimg/dockerimg_test.go b/dockerimg/dockerimg_test.go
index e69d9d7..b19b6d5 100644
--- a/dockerimg/dockerimg_test.go
+++ b/dockerimg/dockerimg_test.go
@@ -7,6 +7,7 @@
 	"io/fs"
 	"net/http"
 	"os"
+	"path/filepath"
 	"strings"
 	"testing"
 	"testing/fstest"
@@ -225,3 +226,35 @@
 		}
 	}
 }
+
+func TestGetHostGoCacheDirs(t *testing.T) {
+	ctx := context.Background()
+
+	// Test getHostGoCacheDir
+	goCacheDir, err := getHostGoCacheDir(ctx)
+	if err != nil {
+		t.Fatalf("getHostGoCacheDir failed: %v", err)
+	}
+	if goCacheDir == "" {
+		t.Fatal("getHostGoCacheDir returned empty string")
+	}
+	t.Logf("GOCACHE: %s", goCacheDir)
+
+	// Test getHostGoModCacheDir
+	goModCacheDir, err := getHostGoModCacheDir(ctx)
+	if err != nil {
+		t.Fatalf("getHostGoModCacheDir failed: %v", err)
+	}
+	if goModCacheDir == "" {
+		t.Fatal("getHostGoModCacheDir returned empty string")
+	}
+	t.Logf("GOMODCACHE: %s", goModCacheDir)
+
+	// Both should be absolute paths
+	if !filepath.IsAbs(goCacheDir) {
+		t.Errorf("GOCACHE is not an absolute path: %s", goCacheDir)
+	}
+	if !filepath.IsAbs(goModCacheDir) {
+		t.Errorf("GOMODCACHE is not an absolute path: %s", goModCacheDir)
+	}
+}