Repository: gruntwork-io/terragrunt

Issue:
Terragrunt hcl validate returns zero exit code on errors?
## Describe the bug

This seems wrong to me? Returning zero exit code on errors doesn't really gel with my established CI conventions :P

Am I missing a cli flag? Even if so, that seems non-intuitive to me.

It happens in both strict and non-strict modes.

```
root@de3b5a01ec8f:/opt/tf# terragrunt hcl validate --strict-mode
╷
│ Error: Unsupported attribute
│
│   on /opt/tf/environments/alpha/eu-west-2/cdn/terragrunt.hcl line 13, in locals:
│   13:   domain          = local.env.locals.domain
│     ├────────────────
│     │ local.env.locals is object with 5 attributes
│
│ This object does not have an attribute named "domain".
╵
╷
│ Error: Unsupported attribute
│
│   on /opt/tf/environments/alpha/eu-west-2/cdn/terragrunt.hcl line 15, in locals:
│   15:   cdn_price_class = local.env.cdn_price_class
│     ├────────────────
│     │ local.env is object with 15 attributes
│
│ This object does not have an attribute named "cdn_price_class".
╵
root@de3b5a01ec8f:/opt/tf# echo $?
0
```

Thank you for all the effort on the project otherwise, the new features are excellent.

## Steps To Reproduce

See above.

## Expected behavior

A non-zero exit code should be emitted on the event of a failed validation.

## Nice to haves

- [x] Terminal output
- [ ] Screenshots

## Versions

- Terragrunt version: terragrunt version v0.77.22
- OpenTofu/Terraform version: Terraform v1.11.4
- Environment details (Ubuntu 20.04, Windows 10, etc.): linux_arm64 debian:bookworm-slim


Discussion:
@yhakbar: @jhrr do you mind supplying a test fixture or something so we can reproduce the issue?@jhrr: > [@jhrr](https://github.com/jhrr) do you mind supplying a test fixture or something so we can reproduce the issue?

Yes sure thing. Here is an extremely simplified version of the stack I found the issue in.

https://github.com/ocp/terragrunt-hcl-validate-exit-repro

Let me know if it's just my own wrong-headedness!

Solution patch:
diff --git a/cli/commands/hcl/validate/validate.go b/cli/commands/hcl/validate/validate.go
index 4b446428..2aba7c83 100644
--- a/cli/commands/hcl/validate/validate.go
+++ b/cli/commands/hcl/validate/validate.go
@@ -101,6 +101,14 @@ func RunValidate(ctx context.Context, l log.Logger, opts *options.TerragruntOpti
 		if err := writeDiagnostics(l, opts, diags); err != nil {
 			return err
 		}
+
+		// If there were diagnostics and stackErr is currently nil,
+		// create a new error to signal overall validation failure.
+		//
+		// This also ensures a non-zero exit code is returned by Terragrunt.
+		if stackErr == nil {
+			stackErr = errors.Errorf("%d HCL validation error(s) found", len(diags))
+		}
 	}
 
 	return stackErr
@@ -276,8 +284,8 @@ func getTerraformInputNamesFromEnvVar(opts *options.TerragruntOptions, terragrun
 	)
 
 	for envName := range envVars {
-		if strings.HasPrefix(envName, tfVarPrefix) {
-			inputName := strings.TrimPrefix(envName, tfVarPrefix)
+		if after, ok := strings.CutPrefix(envName, tfVarPrefix); ok {
+			inputName := after
 			out = append(out, inputName)
 		}
 	}
@@ -450,8 +458,8 @@ func GetVarFlagsFromArgList(argList []string) ([]string, []string, error) {
 			vars = append(vars, splitArg[1])
 		}
 
-		if strings.HasPrefix(shlexedArg, "-var-file=") {
-			varFiles = append(varFiles, strings.TrimPrefix(shlexedArg, "-var-file="))
+		if after, ok := strings.CutPrefix(shlexedArg, "-var-file="); ok {
+			varFiles = append(varFiles, after)
 		}
 	}
 
diff --git a/config/dependency.go b/config/dependency.go
index 7f03b883..c841efa3 100644
--- a/config/dependency.go
+++ b/config/dependency.go
@@ -414,8 +414,6 @@ func dependencyBlocksToCtyValue(ctx *ParsingContext, l log.Logger, dependencyCon
 	dependencyErrGroup, _ := errgroup.WithContext(ctx)
 
 	for _, dependencyConfig := range dependencyConfigs {
-		dependencyConfig := dependencyConfig // https://golang.org/doc/faq#closures_and_goroutines
-
 		dependencyErrGroup.Go(func() error {
 			// Loose struct to hold the attributes of the dependency. This includes:
 			// - outputs: The module outputs of the target config
diff --git a/docs-starlight/src/components/Flag.astro b/docs-starlight/src/components/Flag.astro
index 17e229f7..63c28f39 100644
--- a/docs-starlight/src/components/Flag.astro
+++ b/docs-starlight/src/components/Flag.astro
@@ -11,7 +11,7 @@ const { slug } = Astro.props;
 
 const flagEntry = await getEntry('flags', slug) as CollectionEntry<'flags'>;
 const { Content } = await render(flagEntry);
-const { aliases, description, type, env, name } = flagEntry.data;
+const { aliases, description, type, env, name, defaultVal } = flagEntry.data;
 ---
 
 <section id={name}>
@@ -21,6 +21,11 @@ const { aliases, description, type, env, name } = flagEntry.data;
     <div>
       <span>Type: </span><Badge text={type} variant="note" style={{ fontWeight: 'bold' }}/>
     </div>
+    {defaultVal && (
+      <div>
+        <span>Default: </span><Badge text={defaultVal} variant="tip" style={{ fontWeight: 'bold' }}/>
+      </div>
+    )}
     {aliases && aliases.length > 0 && (
       <>
         <p>Aliases:</p>
diff --git a/docs-starlight/src/content.config.ts b/docs-starlight/src/content.config.ts
index 50f42c2a..3ae96e22 100644
--- a/docs-starlight/src/content.config.ts
+++ b/docs-starlight/src/content.config.ts
@@ -52,6 +52,7 @@ const flags = defineCollection({
 	schema: z.object({
 		name: z.string(),
 		description: z.string(),
+		defaultVal: z.string().optional(),
 		type: z.string(),
 		env: z.array(z.string()).optional(),
 		aliases: z.array(z.string()).optional(),
diff --git a/docs-starlight/src/data/flags/queue-excludes-file.mdx b/docs-starlight/src/data/flags/queue-excludes-file.mdx
index 744116e8..f9907d51 100644
--- a/docs-starlight/src/data/flags/queue-excludes-file.mdx
+++ b/docs-starlight/src/data/flags/queue-excludes-file.mdx
@@ -1,11 +1,17 @@
 ---
 name: queue-excludes-file
-description: Path to a file containing Unix-style globs of directories to exclude from the queue of Units to run.
+description: Path to a file with a list of directories that need to be excluded when running `run --all` commands.
 type: string
+defaultVal: .terragrunt-excludes
 env:
   - TG_QUEUE_EXCLUDES_FILE
 ---
 
-Specifies a file containing patterns of directories to exclude when running commands with [`--all`](/docs/reference/cli/commands/run#all).
+Units in these directories will be excluded during execution of the commands. If a relative path is specified, it should be relative from
+[--working-dir](/docs/reference/cli/global-flags#working-directory). This will only exclude the module, not its dependencies.
 
-To learn more about how to use this flag, see the [Stacks](/docs/features/stacks/#the-run---all-command) feature documentation.
+This flag has been designed to integrate nicely with the `hcl validate` command, which can return a list of invalid files delimited by newlines when passed the `--show-config-path` flag. To integrate the two, you can run something like the following using bash process substitution:
+
+```bash
+terragrunt run --all plan --queue-excludes-file <(terragrunt hcl validate --show-config-path || true)
+```
diff --git a/docs/_docs/04_reference/02-cli-options.md b/docs/_docs/04_reference/02-cli-options.md
index 05dad011..5823e9a5 100644
--- a/docs/_docs/04_reference/02-cli-options.md
+++ b/docs/_docs/04_reference/02-cli-options.md
@@ -1783,14 +1783,14 @@ When passed in, the `*-all` commands continue processing components even if a de
 **Environment Variable Alias**: `TERRAGRUNT_EXCLUDES_FILE` (deprecated: [See migration guide](/docs/migrate/cli-redesign/))<br/>
 **Requires an argument**: `--queue-excludes-file /path/to/file`<br/>
 
-Path to a file with a list of directories that need to be excluded when running *-all commands, by default `.terragrunt-excludes`. Modules under these directories will be
+Path to a file with a list of directories that need to be excluded when running `run --all` commands, by default `.terragrunt-excludes`. Units in these directories will be
 excluded during execution of the commands. If a relative path is specified, it should be relative from
 [--working-dir](#working-dir). This will only exclude the module, not its dependencies.
 
-This flag has been designed to integrate nicely with the `hclvalidate` command, which can return a list of invalid files delimited by newlines when passed the `--show-config-path` flag. To integrate the two, you can run something like the following using bash process substitution:
+This flag has been designed to integrate nicely with the `hcl validate` command, which can return a list of invalid files delimited by newlines when passed the `--show-config-path` flag. To integrate the two, you can run something like the following using bash process substitution:
 
 ```bash
-terragrunt run --all plan --queue-excludes-file <(terragrunt hclvalidate --show-config-path)
+terragrunt run --all plan --queue-excludes-file <(terragrunt hcl validate --show-config-path || true)
 ```
 
 ### queue-exclude-dir
diff --git a/internal/cas/content.go b/internal/cas/content.go
index 64a28cf9..08132d4a 100644
--- a/internal/cas/content.go
+++ b/internal/cas/content.go
@@ -109,82 +109,7 @@ func (c *Content) Store(l log.Logger, hash string, data []byte) error {
 		return wrapError("create_partition_dir", partitionDir, ErrCreateDir)
 	}
 
-	path := c.getPath(hash)
-	tempPath := path + ".tmp"
-
-	f, err := os.OpenFile(tempPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, RegularFilePerms)
-	if err != nil {
-		return wrapError("create_temp_file", tempPath, err)
-	}
-
-	buf := bufio.NewWriter(f)
-
-	if _, err := buf.Write(data); err != nil {
-		f.Close()
-
-		if removeErr := os.Remove(tempPath); removeErr != nil {
-			l.Warnf("failed to remove temp file %s: %v", tempPath, removeErr)
-		}
-
-		return wrapError("write_to_store", tempPath, err)
-	}
-
-	if err := buf.Flush(); err != nil {
-		f.Close()
-
-		if removeErr := os.Remove(tempPath); removeErr != nil {
-			l.Warnf("failed to remove temp file %s: %v", tempPath, removeErr)
-		}
-
-		return wrapError("flush_buffer", tempPath, err)
-	}
-
-	if err := f.Close(); err != nil {
-		if removeErr := os.Remove(tempPath); removeErr != nil {
-			l.Warnf("failed to remove temp file %s: %v", tempPath, removeErr)
-		}
-
-		return wrapError("close_file", tempPath, err)
-	}
-
-	// Set read-only permissions on the temporary file
-	if err := os.Chmod(tempPath, StoredFilePerms); err != nil {
-		if removeErr := os.Remove(tempPath); removeErr != nil {
-			l.Warnf("failed to remove temp file %s: %v", tempPath, removeErr)
-		}
-
-		return wrapError("chmod_temp_file", tempPath, err)
-	}
-
-	// For Windows, handle readonly attributes specifically
-	if runtime.GOOS == WindowsOS {
-		// Check if a destination file exists and is read-only
-		if _, err := os.Stat(path); err == nil {
-			// File exists, make it writable before rename operation
-			if err := os.Chmod(path, RegularFilePerms); err != nil {
-				l.Warnf("failed to make destination file writable %s: %v", path, err)
-			}
-		}
-	}
-
-	// Atomic rename
-	if err := os.Rename(tempPath, path); err != nil {
-		if removeErr := os.Remove(tempPath); removeErr != nil {
-			l.Warnf("failed to remove temp file %s: %v", tempPath, removeErr)
-		}
-
-		return wrapError("finalize_store", path, err)
-	}
-
-	// For Windows, we need to set the permissions again after rename
-	if runtime.GOOS == WindowsOS {
-		// Ensure the file has read-only permissions after rename
-		if err := os.Chmod(path, StoredFilePerms); err != nil {
-			return wrapError("chmod_final_file", path, err)
-		}
-	}
-
-	return nil
+	return c.writeContentToFile(l, hash, data)
 }
 
 // Ensure ensures that a content item exists in the store
@@ -227,6 +152,12 @@ func (c *Content) EnsureWithWait(l log.Logger, hash string, data []byte) error {
 		return wrapError("create_partition_dir", partitionDir, ErrCreateDir)
 	}
 
+	return c.writeContentToFile(l, hash, data)
+}
+
+// writeContentToFile writes data to a temporary file,
+// sets appropriate permissions, and performs an atomic rename.
+func (c *Content) writeContentToFile(l log.Logger, hash string, data []byte) error {
 	path := c.getPath(hash)
 	tempPath := path + ".tmp"
 
diff --git a/internal/cas/getter.go b/internal/cas/getter.go
index 48222b73..b4024e87 100644
--- a/internal/cas/getter.go
+++ b/internal/cas/getter.go
@@ -30,8 +30,6 @@ func NewCASGetter(l log.Logger, cas *CAS, opts *CloneOptions) *CASGetter {
 			new(getter.GitDetector),
 			new(getter.BitBucketDetector),
 			new(getter.GitLabDetector),
-			// This always has to be the last detector,
-			// as we check for the last detector in Detect().
 			new(getter.FileDetector),
 		},
 		CAS:    cas,
@@ -93,15 +91,15 @@ func (g *CASGetter) Detect(req *getter.Request) (bool, error) {
 		return true, nil
 	}
 
-	for i, detector := range g.Detectors {
+	for _, detector := range g.Detectors {
 		src, ok, err := detector.Detect(req.Src, req.Pwd)
 		if err != nil {
 			return ok, err
 		}
 
 		if ok {
-			// The final detector is always the FileDetector.
-			if i == len(g.Detectors)-1 {
+			// Check if this is a FileDetector using type assertion
+			if _, isFileDetector := detector.(*getter.FileDetector); isFileDetector {
 				info, statErr := os.Stat(src)
 				if statErr != nil {
 					return false, fmt.Errorf("%w: %s", ErrDirectoryNotFound, src)
diff --git a/internal/cas/tree.go b/internal/cas/tree.go
index 1a35f86c..597c15ca 100644
--- a/internal/cas/tree.go
+++ b/internal/cas/tree.go
@@ -7,7 +7,8 @@ import (
 	"path/filepath"
 	"runtime"
 	"strings"
-	"sync"
+
+	"golang.org/x/sync/errgroup"
 )
 
 const (
@@ -149,91 +150,44 @@ func (t *Tree) LinkTree(ctx context.Context, store *Store, targetDir string) err
 		}
 	}
 
+	// Use errgroup for concurrent processing
+	g, ctx := errgroup.WithContext(ctx)
+
+	// Set concurrency limit
 	scalingFactor := 2
 	maxWorkers := max(1, runtime.NumCPU()/scalingFactor)
-	workChan := make(chan workItem, len(workItems))
-	errChan := make(chan error, 1)
-
-	var wg sync.WaitGroup
-
-	for i := 0; i < maxWorkers; i++ {
-		wg.Add(1)
-
-		go func() {
-			defer wg.Done()
+	g.SetLimit(maxWorkers)
 
-			for work := range workChan {
-				select {
-				case <-ctx.Done():
-					return
-				case <-errChan:
-					return
-				default:
+	// Process work items concurrently
+	for _, work := range workItems {
+		g.Go(func() error {
+			switch work.itemType {
+			case "link":
+				err := content.Link(ctx, work.entry.Hash, work.path)
+				if err != nil {
+					return wrapError("link_blob", work.path, err)
 				}
-
-				var err error
-
-				switch work.itemType {
-				case "link":
-					err = content.Link(ctx, work.entry.Hash, work.path)
-					if err != nil {
-						err = wrapError("link_blob", work.path, err)
-					}
-				case "subtree":
-					var treeData []byte
-
-					treeData, err = content.Read(work.entry.Hash)
-					if err != nil {
-						err = wrapError("read_tree", work.entry.Hash, err)
-						break
-					}
-
-					var subTree *Tree
-
-					subTree, err = ParseTree(string(treeData), work.path)
-					if err != nil {
-						err = wrapError("parse_tree", work.entry.Hash, err)
-						break
-					}
-
-					err = subTree.LinkTree(ctx, store, work.path)
-					if err != nil {
-						err = wrapError("link_subtree", work.path, err)
-					}
+			case "subtree":
+				treeData, err := content.Read(work.entry.Hash)
+				if err != nil {
+					return wrapError("read_tree", work.entry.Hash, err)
 				}
 
+				subTree, err := ParseTree(string(treeData), work.path)
 				if err != nil {
-					select {
-					case errChan <- err:
-					default:
-					}
+					return wrapError("parse_tree", work.entry.Hash, err)
+				}
 
-					return
+				err = subTree.LinkTree(ctx, store, work.path)
+				if err != nil {
+					return wrapError("link_subtree", work.path, err)
 				}
 			}
-		}()
-	}
 
-	for _, work := range workItems {
-		select {
-		case <-ctx.Done():
-			close(workChan)
-			return ctx.Err()
-		case err := <-errChan:
-			close(workChan)
-			return err
-		case workChan <- work:
-		}
+			return nil
+		})
 	}
 
-	close(workChan)
-
-	wg.Wait()
-
-	select {
-	case err := <-errChan:
-		return err
-	default:
-		return nil
-	}
+	// Wait for all goroutines to complete and return first error if any
+	return g.Wait()
 }
diff --git a/tf/cache/server.go b/tf/cache/server.go
index 699a5e27..4c89d677 100644
--- a/tf/cache/server.go
+++ b/tf/cache/server.go
@@ -92,8 +92,6 @@ func (server *Server) Run(ctx context.Context, ln net.Listener) error {
 	errGroup, ctx := errgroup.WithContext(ctx)
 
 	for _, service := range server.services {
-		service := service
-
 		errGroup.Go(func() error {
 			return service.Run(ctx)
 		})
diff --git a/tflint/tflint.go b/tflint/tflint.go
index 4f20610b..d1cc06ee 100644
--- a/tflint/tflint.go
+++ b/tflint/tflint.go
@@ -182,8 +182,8 @@ func tfArgumentsToTflintVar(l log.Logger, hook config.Hook,
 		if arg.EnvVars != nil {
 			// extract env_vars
 			for name, value := range *arg.EnvVars {
-				if strings.HasPrefix(name, tfVarPrefix) {
-					varName := strings.TrimPrefix(name, tfVarPrefix)
+				if after, ok := strings.CutPrefix(name, tfVarPrefix); ok {
+					varName := after
 
 					varValue, err := util.AsTerraformEnvVarJSONValue(value)
 					if err != nil {
@@ -199,14 +199,14 @@ func tfArgumentsToTflintVar(l log.Logger, hook config.Hook,
 		if arg.Arguments != nil {
 			// extract variables and var files from arguments
 			for _, value := range *arg.Arguments {
-				if strings.HasPrefix(value, argVarPrefix) {
-					varName := strings.TrimPrefix(value, argVarPrefix)
+				if after, ok := strings.CutPrefix(value, argVarPrefix); ok {
+					varName := after
 					newVar := fmt.Sprintf("--var='%s'", varName)
 					variables = append(variables, newVar)
 				}
 
-				if strings.HasPrefix(value, argVarFilePrefix) {
-					varName := strings.TrimPrefix(value, argVarFilePrefix)
+				if after, ok := strings.CutPrefix(value, argVarFilePrefix); ok {
+					varName := after
 					newVar := "--var-file=" + varName
 					variables = append(variables, newVar)
 				}



Test patch:
diff --git a/cli/commands/catalog/tui/model_test.go b/cli/commands/catalog/tui/model_test.go
index c9eb42f0..2bc68c1b 100644
--- a/cli/commands/catalog/tui/model_test.go
+++ b/cli/commands/catalog/tui/model_test.go
@@ -41,7 +41,7 @@ func createMockCatalogService(t *testing.T, opts *options.TerragruntOptions) cat
 		// Initialize git repository
 		gitDir := filepath.Join(dummyRepoDir, ".git")
 		os.MkdirAll(gitDir, 0755)
-		os.WriteFile(filepath.Join(gitDir, "config"), []byte(fmt.Sprintf(`[core]
+		os.WriteFile(filepath.Join(gitDir, "config"), fmt.Appendf(nil, `[core]
 	repositoryformatversion = 0
 	filemode = true
 	bare = false
@@ -52,7 +52,7 @@ func createMockCatalogService(t *testing.T, opts *options.TerragruntOptions) cat
 [branch "main"]
 	remote = origin
 	merge = refs/heads/main
-`, repoURL)), 0644)
+`, repoURL), 0644)
 		os.WriteFile(filepath.Join(gitDir, "HEAD"), []byte("ref: refs/heads/main\n"), 0644)
 
 		// Create refs directory structure
diff --git a/cli/commands/hcl/format/format_test.go b/cli/commands/hcl/format/format_test.go
index f3157b48..50d0ddbf 100644
--- a/cli/commands/hcl/format/format_test.go
+++ b/cli/commands/hcl/format/format_test.go
@@ -50,7 +50,6 @@ func TestHCLFmt(t *testing.T) {
 		}
 		for _, dir := range dirs {
 			// Capture range variable into for block so it doesn't change while looping
-			dir := dir
 
 			t.Run(dir, func(t *testing.T) {
 				t.Parallel()
@@ -114,7 +113,6 @@ func TestHCLFmtErrors(t *testing.T) {
 	}
 	for _, dir := range dirs {
 		// Capture range variable into for block so it doesn't change while looping
-		dir := dir
 
 		t.Run(dir, func(t *testing.T) {
 			t.Parallel()
@@ -164,7 +162,6 @@ func TestHCLFmtCheck(t *testing.T) {
 
 	for _, dir := range dirs {
 		// Capture range variable into for block so it doesn't change while looping
-		dir := dir
 
 		t.Run(dir, func(t *testing.T) {
 			t.Parallel()
@@ -210,7 +207,6 @@ func TestHCLFmtCheckErrors(t *testing.T) {
 
 	for _, dir := range dirs {
 		// Capture range variable into for block so it doesn't change while looping
-		dir := dir
 
 		t.Run(dir, func(t *testing.T) {
 			t.Parallel()
@@ -268,7 +264,6 @@ func TestHCLFmtFile(t *testing.T) {
 	// test that none of the other files were formatted
 	for _, dir := range dirs {
 		// Capture range variable into for block so it doesn't change while looping
-		dir := dir
 
 		t.Run(dir, func(t *testing.T) {
 			t.Parallel()
diff --git a/cli/commands/run/cli_test.go b/cli/commands/run/cli_test.go
index 45c2eca3..58790acf 100644
--- a/cli/commands/run/cli_test.go
+++ b/cli/commands/run/cli_test.go
@@ -37,7 +37,6 @@ func TestAction(t *testing.T) {
 	}
 
 	for _, tc := range tt {
-		tc := tc
 
 		t.Run(tc.name, func(t *testing.T) {
 			t.Parallel()
diff --git a/config/config_helpers_test.go b/config/config_helpers_test.go
index 5e56135f..6a4c9f2b 100644
--- a/config/config_helpers_test.go
+++ b/config/config_helpers_test.go
@@ -202,7 +202,6 @@ func TestRunCommand(t *testing.T) {
 		},
 	}
 	for _, tc := range testCases {
-		tc := tc
 
 		t.Run(tc.terragruntOptions.TerragruntConfigPath, func(t *testing.T) {
 			t.Parallel()
@@ -315,7 +314,6 @@ func TestFindInParentFolders(t *testing.T) {
 	}
 
 	for _, tc := range testCases {
-		tc := tc
 
 		t.Run(tc.name, func(t *testing.T) {
 			t.Parallel()
@@ -385,7 +383,6 @@ func TestResolveTerragruntInterpolation(t *testing.T) {
 	for _, tc := range testCases {
 		// The following is necessary to make sure tc's values don't
 		// get updated due to concurrency within the scope of t.Run(..) below
-		tc := tc
 
 		t.Run(fmt.Sprintf("%s--%s", tc.str, tc.terragruntOptions.TerragruntConfigPath), func(t *testing.T) {
 			t.Parallel()
@@ -485,7 +482,6 @@ func TestResolveEnvInterpolationConfigString(t *testing.T) {
 	for _, tc := range testCases {
 		// The following is necessary to make sure tc's values don't
 		// get updated due to concurrency within the scope of t.Run(..) below
-		tc := tc
 		t.Run(tc.str, func(t *testing.T) {
 			t.Parallel()
 
@@ -535,7 +531,6 @@ func TestResolveCommandsInterpolationConfigString(t *testing.T) {
 	for _, tc := range testCases {
 		// The following is necessary to make sure tc's values don't
 		// get updated due to concurrency within the scope of t.Run(..) below
-		tc := tc
 
 		t.Run(tc.str, func(t *testing.T) {
 			t.Parallel()
@@ -796,7 +791,6 @@ func TestTerraformBuiltInFunctions(t *testing.T) {
 	}
 
 	for _, tc := range testCases {
-		tc := tc
 
 		t.Run(tc.input, func(t *testing.T) {
 			t.Parallel()
diff --git a/internal/report/report_test.go b/internal/report/report_test.go
index ca3c24c3..110e28f6 100644
--- a/internal/report/report_test.go
+++ b/internal/report/report_test.go
@@ -674,7 +674,7 @@ func TestWriteJSON(t *testing.T) {
 			require.NoError(t, err)
 
 			// Parse both expected and actual JSON to compare them
-			var expectedJSON, actualJSON []map[string]interface{}
+			var expectedJSON, actualJSON []map[string]any
 			err = json.Unmarshal([]byte(tt.expected), &expectedJSON)
 			require.NoError(t, err)
 			err = json.Unmarshal(actualBytes, &actualJSON)
@@ -795,7 +795,7 @@ func TestWriteSchema(t *testing.T) {
 	assert.JSONEq(t, ExpectedSchema, buf.String())
 
 	// Parse the schema
-	var schema map[string]interface{}
+	var schema map[string]any
 	err = json.Unmarshal(buf.Bytes(), &schema)
 	require.NoError(t, err)
 
@@ -805,15 +805,15 @@ func TestWriteSchema(t *testing.T) {
 	assert.Equal(t, "Terragrunt Run Report Schema", schema["title"])
 
 	// Verify the items schema
-	items, ok := schema["items"].(map[string]interface{})
+	items, ok := schema["items"].(map[string]any)
 	require.True(t, ok)
 
 	// Verify the properties
-	properties, ok := items["properties"].(map[string]interface{})
+	properties, ok := items["properties"].(map[string]any)
 	require.True(t, ok)
 
 	// Verify required fields
-	required, ok := items["required"].([]interface{})
+	required, ok := items["required"].([]any)
 	require.True(t, ok)
 	assert.Contains(t, required, "Name")
 	assert.Contains(t, required, "Started")
@@ -821,17 +821,17 @@ func TestWriteSchema(t *testing.T) {
 	assert.Contains(t, required, "Result")
 
 	// Verify field types
-	assert.Equal(t, "string", properties["Name"].(map[string]interface{})["type"])
-	assert.Equal(t, "string", properties["Result"].(map[string]interface{})["type"])
-	assert.Equal(t, "string", properties["Started"].(map[string]interface{})["type"])
-	assert.Equal(t, "string", properties["Ended"].(map[string]interface{})["type"])
+	assert.Equal(t, "string", properties["Name"].(map[string]any)["type"])
+	assert.Equal(t, "string", properties["Result"].(map[string]any)["type"])
+	assert.Equal(t, "string", properties["Started"].(map[string]any)["type"])
+	assert.Equal(t, "string", properties["Ended"].(map[string]any)["type"])
 
 	// Verify optional fields
-	reason, ok := properties["Reason"].(map[string]interface{})
+	reason, ok := properties["Reason"].(map[string]any)
 	require.True(t, ok)
 	assert.Equal(t, "string", reason["type"])
 
-	cause, ok := properties["Cause"].(map[string]interface{})
+	cause, ok := properties["Cause"].(map[string]any)
 	require.True(t, ok)
 	assert.Equal(t, "string", cause["type"])
 }
diff --git a/internal/worker/worker_test.go b/internal/worker/worker_test.go
index 23d02c63..96a0cc7c 100644
--- a/internal/worker/worker_test.go
+++ b/internal/worker/worker_test.go
@@ -68,7 +68,6 @@ func TestSomeTasksReturnErrors(t *testing.T) {
 
 	// Submit tasks, half of which return an error
 	for i := range 10 {
-		i := i
 		wp.Submit(func() error {
 			if i%2 == 0 {
 				return errors.New("mock error")
diff --git a/shell/error_explainer_test.go b/shell/error_explainer_test.go
index 6025eb5f..db42d87c 100644
--- a/shell/error_explainer_test.go
+++ b/shell/error_explainer_test.go
@@ -40,7 +40,6 @@ func TestExplainError(t *testing.T) {
 	}
 
 	for _, tt := range testCases {
-		tt := tt
 
 		t.Run(tt.errorOutput, func(t *testing.T) {
 			t.Parallel()
diff --git a/telemetry/meter_test.go b/telemetry/meter_test.go
index 1e283c3d..31bd3136 100644
--- a/telemetry/meter_test.go
+++ b/telemetry/meter_test.go
@@ -56,7 +56,6 @@ func TestNewMetricsExporter(t *testing.T) {
 	}
 
 	for _, tt := range tests {
-		tt := tt
 		t.Run(tt.name, func(t *testing.T) {
 			t.Parallel()
 
@@ -122,7 +121,6 @@ func TestCleanMetricName(t *testing.T) {
 	}
 
 	for _, tc := range testCases {
-		tc := tc
 		t.Run(tc.name, func(t *testing.T) {
 			t.Parallel()
 			result := telemetry.CleanMetricName(tc.input)
diff --git a/telemetry/tracer_test.go b/telemetry/tracer_test.go
index 34793156..cf065505 100644
--- a/telemetry/tracer_test.go
+++ b/telemetry/tracer_test.go
@@ -68,7 +68,6 @@ func TestNewTraceExporter(t *testing.T) {
 	}
 
 	for _, tt := range tests {
-		tt := tt
 		t.Run(tt.name, func(t *testing.T) {
 			t.Parallel()
 
diff --git a/test/integration_debug_test.go b/test/integration_debug_test.go
index 70342c94..27f15ec8 100644
--- a/test/integration_debug_test.go
+++ b/test/integration_debug_test.go
@@ -101,7 +101,6 @@ func TestTerragruntValidateInputs(t *testing.T) {
 	for _, module := range moduleDirs {
 		// capture range var within range scope so it doesn't change as the tests are spun to the background in the
 		// t.Parallel call.
-		module := module
 
 		name := filepath.Base(module)
 		t.Run(name, func(t *testing.T) {
diff --git a/test/integration_deprecated_test.go b/test/integration_deprecated_test.go
index f8225ff0..bfc7abac 100644
--- a/test/integration_deprecated_test.go
+++ b/test/integration_deprecated_test.go
@@ -30,7 +30,7 @@ func TestDeprecatedHclvalidateCommand_HclvalidateInvalidConfigPath(t *testing.T)
 	}
 
 	stdout, _, err := helpers.RunTerragruntCommandWithOutput(t, fmt.Sprintf("terragrunt hclvalidate --terragrunt-working-dir %s --terragrunt-hclvalidate-json --terragrunt-hclvalidate-show-config-path", rootPath))
-	require.NoError(t, err)
+	require.Error(t, err)
 
 	var actualPaths []string
 
diff --git a/test/integration_report_test.go b/test/integration_report_test.go
index 49943e59..020c9661 100644
--- a/test/integration_report_test.go
+++ b/test/integration_report_test.go
@@ -134,7 +134,7 @@ func TestTerragruntReportExperimentSaveToFile(t *testing.T) {
 	}
 
 	for _, tc := range testCases {
-		tc := tc // capture range variable
+		// capture range variable
 		t.Run(tc.name, func(t *testing.T) {
 			t.Parallel()
 
@@ -323,7 +323,7 @@ func TestTerragruntReportExperimentSaveToFileWithFormat(t *testing.T) {
 				assert.True(t, strings.HasPrefix(string(content), "Name,Started,Ended,Result,Reason,Cause"))
 			case "json":
 				// For JSON, verify it's valid JSON and has the expected structure
-				var jsonContent []map[string]interface{}
+				var jsonContent []map[string]any
 				err := json.Unmarshal(content, &jsonContent)
 
 				require.NoError(t, err)
@@ -348,7 +348,7 @@ func TestTerragruntReportExperimentSaveToFileWithFormat(t *testing.T) {
 				require.NoError(t, err)
 
 				// Verify it's valid JSON
-				var schema map[string]interface{}
+				var schema map[string]any
 				err = json.Unmarshal(schemaContent, &schema)
 				require.NoError(t, err)
 
@@ -358,11 +358,11 @@ func TestTerragruntReportExperimentSaveToFileWithFormat(t *testing.T) {
 				assert.Equal(t, "Terragrunt Run Report Schema", schema["title"])
 
 				// Verify items schema
-				items, ok := schema["items"].(map[string]interface{})
+				items, ok := schema["items"].(map[string]any)
 				require.True(t, ok)
 
 				// Verify required fields
-				required, ok := items["required"].([]interface{})
+				required, ok := items["required"].([]any)
 				require.True(t, ok)
 				assert.Contains(t, required, "Name")
 				assert.Contains(t, required, "Started")
@@ -370,14 +370,14 @@ func TestTerragruntReportExperimentSaveToFileWithFormat(t *testing.T) {
 				assert.Contains(t, required, "Result")
 
 				// Verify properties
-				properties, ok := items["properties"].(map[string]interface{})
+				properties, ok := items["properties"].(map[string]any)
 				require.True(t, ok)
 
 				// Verify field types
-				assert.Equal(t, "string", properties["Name"].(map[string]interface{})["type"])
-				assert.Equal(t, "string", properties["Result"].(map[string]interface{})["type"])
-				assert.Equal(t, "string", properties["Started"].(map[string]interface{})["type"])
-				assert.Equal(t, "string", properties["Ended"].(map[string]interface{})["type"])
+				assert.Equal(t, "string", properties["Name"].(map[string]any)["type"])
+				assert.Equal(t, "string", properties["Result"].(map[string]any)["type"])
+				assert.Equal(t, "string", properties["Started"].(map[string]any)["type"])
+				assert.Equal(t, "string", properties["Ended"].(map[string]any)["type"])
 			}
 		})
 	}
@@ -508,7 +508,7 @@ func TestReportWithExternalDependenciesExcluded(t *testing.T) {
 	require.NoError(t, err)
 
 	// Verify that the report file contains the expected content
-	var report []map[string]interface{}
+	var report []map[string]any
 	err = json.Unmarshal(reportContent, &report)
 	require.NoError(t, err)
 
diff --git a/test/integration_test.go b/test/integration_test.go
index ddc029ce..e3f3577c 100644
--- a/test/integration_test.go
+++ b/test/integration_test.go
@@ -702,7 +702,7 @@ func TestHclvalidateDiagnostic(t *testing.T) {
 	}
 
 	stdout, _, err := helpers.RunTerragruntCommandWithOutput(t, fmt.Sprintf("terragrunt hcl validate --working-dir %s --json", rootPath))
-	require.NoError(t, err)
+	require.Error(t, err)
 
 	var actualDiags diagnostic.Diagnostics
 
@@ -712,6 +712,24 @@ func TestHclvalidateDiagnostic(t *testing.T) {
 	assert.ElementsMatch(t, expectedDiags, actualDiags)
 }
 
+func TestHclvalidateReturnsNonZeroExitCodeOnError(t *testing.T) {
+	t.Parallel()
+
+	helpers.CleanupTerraformFolder(t, testFixtureHclvalidate)
+	tmpEnvPath := helpers.CopyEnvironment(t, testFixtureHclvalidate)
+	rootPath := util.JoinPath(tmpEnvPath, testFixtureHclvalidate)
+
+	// We expect an error because the fixture has HCL validation issues.
+	// The content of stdout and stderr isn't the primary focus here,
+	// rather the fact that an error (non-zero exit code) is returned.
+	_, _, err := helpers.RunTerragruntCommandWithOutput(t, "terragrunt hcl validate --working-dir "+rootPath)
+	require.Error(t, err, "terragrunt hcl validate should return a non-zero exit code on HCL errors")
+
+	// As an additional check, we can verify that the error message indicates HCL validation errors.
+	// This makes the test more robust.
+	assert.Contains(t, err.Error(), "HCL validation error(s) found")
+}
+
 func TestHclvalidateInvalidConfigPath(t *testing.T) {
 	t.Parallel()
 
@@ -725,7 +743,7 @@ func TestHclvalidateInvalidConfigPath(t *testing.T) {
 	}
 
 	stdout, _, err := helpers.RunTerragruntCommandWithOutput(t, fmt.Sprintf("terragrunt hcl validate --working-dir %s --json --show-config-path", rootPath))
-	require.NoError(t, err)
+	require.Error(t, err)
 
 	var actualPaths []string
 
diff --git a/test/integration_tofu_state_encryption_test.go b/test/integration_tofu_state_encryption_test.go
index 20ec407f..bf4095aa 100644
--- a/test/integration_tofu_state_encryption_test.go
+++ b/test/integration_tofu_state_encryption_test.go
@@ -87,13 +87,13 @@ func TestTofuRenderJSONConfigWithEncryption(t *testing.T) {
 	jsonBytes, err := os.ReadFile(jsonOut)
 	require.NoError(t, err)
 
-	var rendered map[string]interface{}
+	var rendered map[string]any
 	require.NoError(t, json.Unmarshal(jsonBytes, &rendered))
 
 	// Make sure all terraform block is visible
 	terraformBlock, hasTerraform := rendered["terraform"]
 	if assert.True(t, hasTerraform) {
-		source, hasSource := terraformBlock.(map[string]interface{})["source"]
+		source, hasSource := terraformBlock.(map[string]any)["source"]
 		assert.True(t, hasSource)
 		assert.Equal(t, "./module", source)
 	}
@@ -103,23 +103,23 @@ func TestTofuRenderJSONConfigWithEncryption(t *testing.T) {
 	if assert.True(t, hasRemoteState) {
 		assert.Equal(
 			t,
-			map[string]interface{}{
+			map[string]any{
 				"backend": "local",
-				"generate": map[string]interface{}{
+				"generate": map[string]any{
 					"path":      "backend.tf",
 					"if_exists": "overwrite_terragrunt",
 				},
-				"config": map[string]interface{}{
+				"config": map[string]any{
 					"path": "foo.tfstate",
 				},
-				"encryption": map[string]interface{}{
+				"encryption": map[string]any{
 					"key_provider": "pbkdf2",
 					"passphrase":   "correct-horse-battery-staple",
 				},
 				"disable_init":                    false,
 				"disable_dependency_optimization": false,
 			},
-			remoteState.(map[string]interface{}),
+			remoteState.(map[string]any),
 		)
 	}
 
@@ -128,8 +128,8 @@ func TestTofuRenderJSONConfigWithEncryption(t *testing.T) {
 	if assert.True(t, hasDependency) {
 		assert.Equal(
 			t,
-			map[string]interface{}{
-				"dep": map[string]interface{}{
+			map[string]any{
+				"dep": map[string]any{
 					"name":         "dep",
 					"config_path":  "../dep",
 					"outputs":      nil,
@@ -142,7 +142,7 @@ func TestTofuRenderJSONConfigWithEncryption(t *testing.T) {
 					"skip":                                    nil,
 				},
 			},
-			dependencyBlocks.(map[string]interface{}),
+			dependencyBlocks.(map[string]any),
 		)
 	}
 
@@ -151,8 +151,8 @@ func TestTofuRenderJSONConfigWithEncryption(t *testing.T) {
 	if assert.True(t, hasGenerate) {
 		assert.Equal(
 			t,
-			map[string]interface{}{
-				"provider": map[string]interface{}{
+			map[string]any{
+				"provider": map[string]any{
 					"path":              "provider.tf",
 					"comment_prefix":    "# ",
 					"disable_signature": false,
@@ -166,7 +166,7 @@ func TestTofuRenderJSONConfigWithEncryption(t *testing.T) {
 `,
 				},
 			},
-			generateBlocks.(map[string]interface{}),
+			generateBlocks.(map[string]any),
 		)
 	}
 
@@ -175,13 +175,13 @@ func TestTofuRenderJSONConfigWithEncryption(t *testing.T) {
 	if assert.True(t, hasInputs) {
 		assert.Equal(
 			t,
-			map[string]interface{}{
+			map[string]any{
 				"env":        "qa",
 				"name":       "dep",
 				"type":       "main",
 				"aws_region": "us-east-1",
 			},
-			inputsBlock.(map[string]interface{}),
+			inputsBlock.(map[string]any),
 		)
 	}
 }
@@ -201,13 +201,13 @@ func TestTofuRenderJSONConfigWithEncryptionExp(t *testing.T) {
 	jsonBytes, err := os.ReadFile(jsonOut)
 	require.NoError(t, err)
 
-	var rendered map[string]interface{}
+	var rendered map[string]any
 	require.NoError(t, json.Unmarshal(jsonBytes, &rendered))
 
 	// Make sure all terraform block is visible
 	terraformBlock, hasTerraform := rendered["terraform"]
 	if assert.True(t, hasTerraform) {
-		source, hasSource := terraformBlock.(map[string]interface{})["source"]
+		source, hasSource := terraformBlock.(map[string]any)["source"]
 		assert.True(t, hasSource)
 		assert.Equal(t, "./module", source)
 	}
@@ -217,23 +217,23 @@ func TestTofuRenderJSONConfigWithEncryptionExp(t *testing.T) {
 	if assert.True(t, hasRemoteState) {
 		assert.Equal(
 			t,
-			map[string]interface{}{
+			map[string]any{
 				"backend": "local",
-				"generate": map[string]interface{}{
+				"generate": map[string]any{
 					"path":      "backend.tf",
 					"if_exists": "overwrite_terragrunt",
 				},
-				"config": map[string]interface{}{
+				"config": map[string]any{
 					"path": "foo.tfstate",
 				},
-				"encryption": map[string]interface{}{
+				"encryption": map[string]any{
 					"key_provider": "pbkdf2",
 					"passphrase":   "correct-horse-battery-staple",
 				},
 				"disable_init":                    false,
 				"disable_dependency_optimization": false,
 			},
-			remoteState.(map[string]interface{}),
+			remoteState.(map[string]any),
 		)
 	}
 
@@ -242,8 +242,8 @@ func TestTofuRenderJSONConfigWithEncryptionExp(t *testing.T) {
 	if assert.True(t, hasDependency) {
 		assert.Equal(
 			t,
-			map[string]interface{}{
-				"dep": map[string]interface{}{
+			map[string]any{
+				"dep": map[string]any{
 					"name":         "dep",
 					"config_path":  "../dep",
 					"outputs":      nil,
@@ -256,7 +256,7 @@ func TestTofuRenderJSONConfigWithEncryptionExp(t *testing.T) {
 					"skip":                                    nil,
 				},
 			},
-			dependencyBlocks.(map[string]interface{}),
+			dependencyBlocks.(map[string]any),
 		)
 	}
 
@@ -265,8 +265,8 @@ func TestTofuRenderJSONConfigWithEncryptionExp(t *testing.T) {
 	if assert.True(t, hasGenerate) {
 		assert.Equal(
 			t,
-			map[string]interface{}{
-				"provider": map[string]interface{}{
+			map[string]any{
+				"provider": map[string]any{
 					"path":              "provider.tf",
 					"comment_prefix":    "# ",
 					"disable_signature": false,
@@ -280,7 +280,7 @@ func TestTofuRenderJSONConfigWithEncryptionExp(t *testing.T) {
 `,
 				},
 			},
-			generateBlocks.(map[string]interface{}),
+			generateBlocks.(map[string]any),
 		)
 	}
 
@@ -289,13 +289,13 @@ func TestTofuRenderJSONConfigWithEncryptionExp(t *testing.T) {
 	if assert.True(t, hasInputs) {
 		assert.Equal(
 			t,
-			map[string]interface{}{
+			map[string]any{
 				"env":        "qa",
 				"name":       "dep",
 				"type":       "main",
 				"aws_region": "us-east-1",
 			},
-			inputsBlock.(map[string]interface{}),
+			inputsBlock.(map[string]any),
 		)
 	}
 }
@@ -313,7 +313,7 @@ func validateStateIsEncrypted(t *testing.T, fileName string, path string) {
 	byteValue, err := io.ReadAll(file)
 	require.NoError(t, err)
 
-	var result map[string]interface{}
+	var result map[string]any
 	err = json.Unmarshal(byteValue, &result)
 	require.NoError(t, err, "Error unmarshalling the state file '%s'", fileName)