Terragrunt version: v1.1.1 and v1.1.2 (confirmed present in both; latest release as of this report)
OpenTofu version: 1.12.x
OS: Linux (GitHub Actions ubuntu-latest runner, and reproduced separately on a local Linux dev machine)
Summary
This is the same error text as #6540 and my own earlier #6554 (closed as a duplicate of #6540), but a distinct root cause in a different package — not a dependency deleted in the diff, and not fixed by #6546. I want to flag that clearly up front so this doesn't get folded into #6540 again: #6554 genuinely was a different bug, and here is the actual root cause with a verified fix.
terragrunt run --all -- plan, scoped with a git-range --filter, intermittently crashes with:
ERROR failed to parse config for unit <path>: You attempted to run terragrunt in a folder that does not contain a terragrunt.hcl file. Please add a terragrunt.hcl file and try again.
Path: "terragrunt.hcl"
on a unit whose terragrunt.hcl genuinely exists, unmodified, on both sides of the diff — nothing is deleted anywhere. terragrunt find/list with the identical filter succeed cleanly. In every occurrence I've observed, the crashing unit is a dependency shared by several other units in the same run (a "hub" dependency) — never an isolated, single-consumer unit.
Root cause (confirmed via source + go build -race + a verified one-line fix)
internal/runner/runnerpool/builder_helpers.go, checkUnitVersionConstraints():
unitConfig := unit.Config()
// This is almost definitely already parsed, but we'll check just in case.
if unitConfig == nil {
configCtx, pctx := configbridge.NewParsingContext(ctx, l, unitOpts)
pctx = pctx.WithVenv(v).WithDecodeList(
config.TerragruntVersionConstraints,
config.FeatureFlagsBlock,
)
var err error
unitConfig, err = config.PartialParseConfigFile(
configCtx,
pctx,
l,
unit.ConfigFile(), // <-- bug: bare basename, not joined with unit.Path()
nil,
)
if err != nil {
return fmt.Errorf("failed to parse config for unit %s: %w", unit.DisplayPath(), err)
}
}
unit.ConfigFile() returns a bare basename (e.g. "terragrunt.hcl") by design — that's exactly what it's set to, both by component.NewUnit()'s constructor default (configFile: config.DefaultTerragruntConfigPath) and by createComponentFromPath() in internal/discovery/helpers.go (unit.SetConfigFile(base), where base := filepath.Base(path)). Every other call site that needs the actual file path joins it with unit.Path() first — e.g. internal/runner/runnerpool/runner.go's BuildUnitOpts():
configPath := unit.Path()
if !strings.HasSuffix(configPath, ".hcl") && !strings.HasSuffix(configPath, ".json") {
fileName := config.DefaultTerragruntConfigPath
if unit.ConfigFile() != "" {
fileName = unit.ConfigFile()
}
configPath = filepath.Join(unit.Path(), fileName)
}
checkUnitVersionConstraints's fallback is the one call site that doesn't. config.PartialParseConfigFile immediately does:
fileInfo, err := os.Stat(configPath)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil, TerragruntConfigNotFoundError{Path: configPath}
}
...
}
os.Stat("terragrunt.hcl") resolves against the process's current working directory, not the unit's own directory. Under a git-range --filter, that cwd is never any individual unit's directory, so the stat always fails for this code path, exactly reproducing the reported error text and the bare Path: "terragrunt.hcl".
Why this only fires sometimes, and only on shared/hub dependencies: the fallback only runs "if unit.Config() is nil" — the comment above it says "almost definitely already parsed, but we'll check just in case." In practice, a unit reached only as a dependency of multiple sibling units (via internal/discovery/phase_relationship.go's dependencyToDiscover(), which creates it as a lightweight placeholder via component.NewUnit(path) rather than through the main filesystem-discovery walk) can still have a nil Config() at the point checkVersionConstraints's own concurrent pass reaches it, depending on discovery ordering/timing. A unit discovered only once, directly, by the filesystem walk, gets its config eagerly populated and never hits this fallback at all. This is why the crash always lands on a shared dependency and never an isolated unit.
Confirmed fix
unitConfig, err = config.PartialParseConfigFile(
configCtx,
pctx,
l,
- unit.ConfigFile(),
+ filepath.Join(unit.Path(), unit.ConfigFile()),
nil,
)
(filepath is already imported in this file.)
Verified empirically, against a private ~40-unit OpenTofu/Terragrunt monorepo where this fires reliably under CI's real git-range filter (real S3 remote_state backend, GitHub Actions ubuntu-latest, one dependency referenced by 9 sibling units):
| Build |
Crashes / 20 runs |
Data races reported |
| v1.1.1 (release, unmodified) |
13/20 |
20/20 (see note below) |
| v1.1.2 (release, unmodified) |
14/20 |
0/20 |
| v1.1.2 + this one-line patch |
0/25 |
0/25 |
All builds run with go build -race for the first two rows; the patched row is the identical v1.1.2 source with only the diff above applied.
A separate, already-fixed race — ruled out, not the cause
While investigating, go build -race on the pinned v1.1.1 caught a real, distinct data race in internal/discovery/phase_relationship.go's dependencyToDiscover(): Unit.SetDiscoveryContext()/SetExternal() were called unsynchronized on a dependency object that can be shared by multiple concurrent discoverers. I traced this and found it's already fixed in v1.1.2 by gating that mutation on created (only the goroutine that actually registers the component touches it). I want to flag this clearly as ruled out as the cause of the crash above, since it's easy to conflate the two: v1.1.2 has zero race reports across 20 runs yet still crashes 14/20 times with the identical error text — proof the two are independent. Mentioning it mainly so it isn't rediscovered and mistakenly credited as "the" fix for this issue.
Reproduction attempts (both negative — noting this for anyone else who tries)
I built two synthetic fixtures matching the shape as closely as I could (real git repo, real remote_state { backend = "local" }, a git-range --filter, a hub unit referenced by many sibling dependency blocks, a second commit touching the diff without deleting anything):
- 1 hub + 15 consumers (16 units total): 0/20 crashes
- 1 hub + 15 consumers + 30 unrelated noise units (46 units total): 0/20 crashes
Neither reproduced, over 20 runs each, on the same machine (16 cores) where the real monorepo reproduces at ~65-70%. This matches what #6554 and the unrelated #6002 both independently found: this class of bug resists small synthetic fixtures and appears to depend on real-repo scale/timing (git-worktree materialization cost, total discovery graph size, or both) in a way a quick synthetic repro doesn't capture. I'm not including a "steps to reproduce" script for that reason — the root cause above is verifiable by inspection (compare the flagged call site against every other unit.Path()/unit.ConfigFile() join in the codebase) and the fix is verified against the actual failing environment, even though I can't hand you a portable trigger.
Expected vs actual
Expected: run --all succeeds (or fails with an accurate error) on a live, unmodified, shared dependency.
Actual: intermittent hard crash claiming a missing config file, using a bare relative path that was never joined with the unit's actual directory.
Related issues
Terragrunt version: v1.1.1 and v1.1.2 (confirmed present in both; latest release as of this report)
OpenTofu version: 1.12.x
OS: Linux (GitHub Actions
ubuntu-latestrunner, and reproduced separately on a local Linux dev machine)Summary
This is the same error text as #6540 and my own earlier #6554 (closed as a duplicate of #6540), but a distinct root cause in a different package — not a dependency deleted in the diff, and not fixed by #6546. I want to flag that clearly up front so this doesn't get folded into #6540 again: #6554 genuinely was a different bug, and here is the actual root cause with a verified fix.
terragrunt run --all -- plan, scoped with a git-range--filter, intermittently crashes with:on a unit whose
terragrunt.hclgenuinely exists, unmodified, on both sides of the diff — nothing is deleted anywhere.terragrunt find/listwith the identical filter succeed cleanly. In every occurrence I've observed, the crashing unit is a dependency shared by several other units in the same run (a "hub" dependency) — never an isolated, single-consumer unit.Root cause (confirmed via source +
go build -race+ a verified one-line fix)internal/runner/runnerpool/builder_helpers.go,checkUnitVersionConstraints():unit.ConfigFile()returns a bare basename (e.g."terragrunt.hcl") by design — that's exactly what it's set to, both bycomponent.NewUnit()'s constructor default (configFile: config.DefaultTerragruntConfigPath) and bycreateComponentFromPath()ininternal/discovery/helpers.go(unit.SetConfigFile(base), wherebase := filepath.Base(path)). Every other call site that needs the actual file path joins it withunit.Path()first — e.g.internal/runner/runnerpool/runner.go'sBuildUnitOpts():checkUnitVersionConstraints's fallback is the one call site that doesn't.config.PartialParseConfigFileimmediately does:os.Stat("terragrunt.hcl")resolves against the process's current working directory, not the unit's own directory. Under a git-range--filter, that cwd is never any individual unit's directory, so the stat always fails for this code path, exactly reproducing the reported error text and the barePath: "terragrunt.hcl".Why this only fires sometimes, and only on shared/hub dependencies: the fallback only runs "if
unit.Config()is nil" — the comment above it says "almost definitely already parsed, but we'll check just in case." In practice, a unit reached only as a dependency of multiple sibling units (viainternal/discovery/phase_relationship.go'sdependencyToDiscover(), which creates it as a lightweight placeholder viacomponent.NewUnit(path)rather than through the main filesystem-discovery walk) can still have a nilConfig()at the pointcheckVersionConstraints's own concurrent pass reaches it, depending on discovery ordering/timing. A unit discovered only once, directly, by the filesystem walk, gets its config eagerly populated and never hits this fallback at all. This is why the crash always lands on a shared dependency and never an isolated unit.Confirmed fix
unitConfig, err = config.PartialParseConfigFile( configCtx, pctx, l, - unit.ConfigFile(), + filepath.Join(unit.Path(), unit.ConfigFile()), nil, )(
filepathis already imported in this file.)Verified empirically, against a private ~40-unit OpenTofu/Terragrunt monorepo where this fires reliably under CI's real git-range filter (real S3
remote_statebackend, GitHub Actionsubuntu-latest, one dependency referenced by 9 sibling units):All builds run with
go build -racefor the first two rows; the patched row is the identical v1.1.2 source with only the diff above applied.A separate, already-fixed race — ruled out, not the cause
While investigating,
go build -raceon the pinned v1.1.1 caught a real, distinct data race ininternal/discovery/phase_relationship.go'sdependencyToDiscover():Unit.SetDiscoveryContext()/SetExternal()were called unsynchronized on a dependency object that can be shared by multiple concurrent discoverers. I traced this and found it's already fixed in v1.1.2 by gating that mutation oncreated(only the goroutine that actually registers the component touches it). I want to flag this clearly as ruled out as the cause of the crash above, since it's easy to conflate the two: v1.1.2 has zero race reports across 20 runs yet still crashes 14/20 times with the identical error text — proof the two are independent. Mentioning it mainly so it isn't rediscovered and mistakenly credited as "the" fix for this issue.Reproduction attempts (both negative — noting this for anyone else who tries)
I built two synthetic fixtures matching the shape as closely as I could (real git repo, real
remote_state { backend = "local" }, a git-range--filter, a hub unit referenced by many siblingdependencyblocks, a second commit touching the diff without deleting anything):Neither reproduced, over 20 runs each, on the same machine (16 cores) where the real monorepo reproduces at ~65-70%. This matches what #6554 and the unrelated #6002 both independently found: this class of bug resists small synthetic fixtures and appears to depend on real-repo scale/timing (git-worktree materialization cost, total discovery graph size, or both) in a way a quick synthetic repro doesn't capture. I'm not including a "steps to reproduce" script for that reason — the root cause above is verifiable by inspection (compare the flagged call site against every other
unit.Path()/unit.ConfigFile()join in the codebase) and the fix is verified against the actual failing environment, even though I can't hand you a portable trigger.Expected vs actual
Expected:
run --allsucceeds (or fails with an accurate error) on a live, unmodified, shared dependency.Actual: intermittent hard crash claiming a missing config file, using a bare relative path that was never joined with the unit's actual directory.
Related issues
run --allcrashes with "does not contain a terragrunt.hcl file" when a live unit depends on a unit deleted in the same diff — whilefind/listreport the same filter as healthy #6540 / fix: handling deleted dependencies in filter #6546 — different bug (a dependency deleted in the diff), different package (internal/discovery's graph/relationship phases), unaffected by this fix and doesn't affect this bug.run --allcrashes with "does not contain a terragrunt.hcl file" on a live, non-deleted unit —find/listreport it healthy (possible new variant of #6540) #6554 — my own earlier report of this exact symptom, closed as a duplicate ofrun --allcrashes with "does not contain a terragrunt.hcl file" when a live unit depends on a unit deleted in the same diff — whilefind/listreport the same filter as healthy #6540 for lack of repro steps. This issue is the follow-up with the actual root cause; the two are not duplicates of each other.