From afef8a56bb6ec7d3319e1db5fe8934f5184e3ff3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Hartvig=20Gr=C3=B8nbech?= Date: Wed, 1 Apr 2026 13:02:05 +0200 Subject: [PATCH] Fix incremental builds path mismatch for nested projects with external appFolders When a project is nested (e.g. build/projects/MyProject) and its appFolders reference sources outside the project via ../ paths, the modifiedApps incremental build mode failed to match any unmodified apps for baseline download. This caused all apps to be recompiled on every PR even when only one file changed. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../DetermineProjectsToBuild.psm1 | 29 +- Tests/DetermineProjectsToBuild.Test.ps1 | 249 ++++++++++++++++++ 2 files changed, 271 insertions(+), 7 deletions(-) diff --git a/Actions/DetermineProjectsToBuild/DetermineProjectsToBuild.psm1 b/Actions/DetermineProjectsToBuild/DetermineProjectsToBuild.psm1 index fc1aeed1f9..e48f8b3cd9 100644 --- a/Actions/DetermineProjectsToBuild/DetermineProjectsToBuild.psm1 +++ b/Actions/DetermineProjectsToBuild/DetermineProjectsToBuild.psm1 @@ -430,15 +430,30 @@ function Get-UnmodifiedAppsFromBaselineWorkflowRun { Sort-AppFoldersByDependencies -appFolders $allFolders -baseFolder $baseFolder -skippedApps ([ref] $skipFolders) -unknownDependencies ([ref]$unknownDependencies) -knownApps ([ref] $knownApps) -selectSubordinates $modifiedFolders | Out-Null OutputMessageAndArray -message "Skip folders" -arrayOfStrings $skipFolders - $projectWithSeperator = '' - if ($project) { - $projectWithSeperator = "$project$([System.IO.Path]::DirectorySeparatorChar)" + # Convert a project-relative folder path (from settings.appFolders etc.) to a repo-relative path + # that matches the format used by $skipFolders (produced by GetFoldersFromAllProjects). + # Settings folders may use ../ to reference paths above the project folder, + # so we resolve to absolute first, then strip the base folder prefix to get a clean repo-relative path. + function ConvertTo-RepoRelativePath { + param( + [string] $folder, + [string] $projectPath, + [string] $baseFolder + ) + $fullPath = Join-Path $projectPath $folder -Resolve -ErrorAction SilentlyContinue + if (-not $fullPath) { + return $null + } + $normalizedBase = $baseFolder.TrimEnd([System.IO.Path]::DirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar + if ($fullPath.StartsWith($normalizedBase, [System.StringComparison]::OrdinalIgnoreCase)) { + return $fullPath.Substring($normalizedBase.Length) + } + return $null } - # AppFolders, TestFolders and BcptTestFolders in settings are always preceded by ./ or .\, so we need to remove that (hence Substring(2)) - $downloadAppFolders = @($settings.appFolders | Where-Object { $skipFolders -contains "$projectWithSeperator$($_.SubString(2))" }) - $downloadTestFolders = @($settings.testFolders | Where-Object { $skipFolders -contains "$projectWithSeperator$($_.SubString(2))" }) - $downloadBcptTestFolders = @($settings.bcptTestFolders | Where-Object { $skipFolders -contains "$projectWithSeperator$($_.SubString(2))" }) + $downloadAppFolders = @($settings.appFolders | Where-Object { $skipFolders -contains (ConvertTo-RepoRelativePath -folder $_ -projectPath $projectPath -baseFolder $baseFolder) }) + $downloadTestFolders = @($settings.testFolders | Where-Object { $skipFolders -contains (ConvertTo-RepoRelativePath -folder $_ -projectPath $projectPath -baseFolder $baseFolder) }) + $downloadBcptTestFolders = @($settings.bcptTestFolders | Where-Object { $skipFolders -contains (ConvertTo-RepoRelativePath -folder $_ -projectPath $projectPath -baseFolder $baseFolder) }) OutputMessageAndArray -message "Download appFolders" -arrayOfStrings $downloadAppFolders OutputMessageAndArray -message "Download testFolders" -arrayOfStrings $downloadTestFolders diff --git a/Tests/DetermineProjectsToBuild.Test.ps1 b/Tests/DetermineProjectsToBuild.Test.ps1 index 2d7ca5baf2..289a1ccf7a 100644 --- a/Tests/DetermineProjectsToBuild.Test.ps1 +++ b/Tests/DetermineProjectsToBuild.Test.ps1 @@ -1214,3 +1214,252 @@ Describe "Get-BuildAllProjects" { Remove-Item $baseFolder -Force -Recurse } } + +Describe "Get-UnmodifiedAppsFromBaselineWorkflowRun" { + BeforeAll { + . (Join-Path -Path $PSScriptRoot -ChildPath "../Actions/AL-Go-Helper.ps1" -Resolve) + DownloadAndImportBcContainerHelper -baseFolder $([System.IO.Path]::GetTempPath()) + + Import-Module (Join-Path $PSScriptRoot "../Actions/DetermineProjectsToBuild/DetermineProjectsToBuild.psm1" -Resolve) -DisableNameChecking -Force + } + + BeforeEach { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'baseFolder', Justification = 'False positive.')] + $baseFolder = (New-Item -ItemType Directory -Path (Join-Path $([System.IO.Path]::GetTempPath()) $([System.IO.Path]::GetRandomFileName()))).FullName + } + + It 'correctly identifies unmodified apps when appFolders reference paths above the project folder via ../' { + # Repo layout where the project is nested and appFolders reference sources via ../: + # baseFolder/ + # .github/AL-Go-Settings.json + # src/Apps/AppA/App/app.json (modified) + # src/Apps/AppB/App/app.json (unmodified - should be downloaded from baseline) + # src/Apps/AppC/App/app.json (unmodified - should be downloaded from baseline) + # build/projects/MyProject/.AL-Go/settings.json + # appFolders: ["../../../src/Apps/*/App"] + + $project = 'build/projects/MyProject' + $projectPath = Join-Path $baseFolder $project + + # Create repo-level AL-Go settings + $repoSettings = @{ + fullBuildPatterns = @() + projects = @($project) + powerPlatformSolutionFolder = '' + useProjectDependencies = $false + incrementalBuilds = @{ + onPull_Request = $true + mode = 'modifiedApps' + } + } + New-Item -Path "$baseFolder/.github/AL-Go-Settings.json" -Value (ConvertTo-Json $repoSettings -Depth 10) -type File -Force | Out-Null + + # Create project-level settings with appFolders that go above the project + $projectSettingsJson = @{ + appFolders = @("../../../src/Apps/*/App") + testFolders = @() + bcptTestFolders = @() + } + New-Item -Path "$projectPath/.AL-Go/settings.json" -Value (ConvertTo-Json $projectSettingsJson -Depth 10) -type File -Force | Out-Null + + # Create three independent apps + $appA = @{ id = 'aaaaaaaa-0000-0000-0000-000000000001'; name = 'App A'; publisher = 'TestPublisher'; version = '1.0.0.0'; dependencies = @() } + $appB = @{ id = 'bbbbbbbb-0000-0000-0000-000000000002'; name = 'App B'; publisher = 'TestPublisher'; version = '1.0.0.0'; dependencies = @() } + $appC = @{ id = 'cccccccc-0000-0000-0000-000000000003'; name = 'App C'; publisher = 'TestPublisher'; version = '1.0.0.0'; dependencies = @() } + New-Item -Path "$baseFolder/src/Apps/AppA/App/app.json" -Value (ConvertTo-Json $appA -Depth 10) -type File -Force | Out-Null + New-Item -Path "$baseFolder/src/Apps/AppB/App/app.json" -Value (ConvertTo-Json $appB -Depth 10) -type File -Force | Out-Null + New-Item -Path "$baseFolder/src/Apps/AppC/App/app.json" -Value (ConvertTo-Json $appC -Depth 10) -type File -Force | Out-Null + + # Also create a dummy .al file so there's something to be "modified" + New-Item -Path "$baseFolder/src/Apps/AppA/App/MyCodeunit.al" -Value "// modified file" -type File -Force | Out-Null + + # Set env:Settings for helper functions + $env:Settings = ConvertTo-Json $repoSettings -Depth 99 -Compress + + # Resolve appFolders the way ResolveProjectFolders/AnalyzeRepo does: + # Push-Location to project, Resolve-Path -Relative + Push-Location $projectPath + $resolvedAppFolders = @(Resolve-Path "../../../src/Apps/*/App" -Relative -ErrorAction SilentlyContinue | Where-Object { Test-Path (Join-Path $_ 'app.json') }) + Pop-Location + + # Build a settings hashtable with resolved folders (same as what RunPipeline passes in) + $resolvedSettings = @{ + appFolders = $resolvedAppFolders + testFolders = @() + bcptTestFolders = @() + } + + # Build artifact folder + $buildArtifactFolder = Join-Path $projectPath ".buildartifacts" + New-Item -Path $buildArtifactFolder -ItemType Directory -Force | Out-Null + + # Only AppA is modified + $sep = [System.IO.Path]::DirectorySeparatorChar + $modifiedFiles = @("src${sep}Apps${sep}AppA${sep}App${sep}MyCodeunit.al") + + # Mock GitHub API calls since we don't have a real baseline workflow + # Stub functions that are normally provided by GitHub Actions runtime + if (-not (Get-Command 'Trace-Information' -ErrorAction SilentlyContinue)) { + function global:Trace-Information { param([string]$Message, $AdditionalData) } + } + $env:GITHUB_API_URL = 'https://api.github.com' + $env:GITHUB_REPOSITORY = 'test/repo' + Mock InvokeWebRequest { + # Return a mock response object that mimics the GitHub API + $uri = $args[0] + if (-not $uri) { $uri = $Uri } + $content = if ($uri -like '*/actions/runs/*/artifacts*') { + # Artifacts endpoint - return empty list to stop pagination + '{"artifacts":[]}' + } else { + # Workflow run info endpoint + '{"head_branch":"main"}' + } + return [PSCustomObject]@{ Content = $content } + } -ModuleName 'Github-Helper' + + # Capture Write-Host output to verify download folder matching + $script:capturedOutput = [System.Collections.ArrayList]::new() + Mock Write-Host { $null = $script:capturedOutput.Add($Object) } -ModuleName 'DetermineProjectsToBuild' + + Get-UnmodifiedAppsFromBaselineWorkflowRun ` + -token 'fake-token' ` + -settings $resolvedSettings ` + -baseFolder $baseFolder ` + -project $project ` + -baselineWorkflowRunId '12345' ` + -modifiedFiles $modifiedFiles ` + -buildArtifactFolder $buildArtifactFolder ` + -buildMode 'Default' ` + -projectPath $projectPath + + # The output should list the unmodified app folders (AppB and AppC) as download candidates. + # Before the fix: the download list was always empty ("- None") because the path matching + # used SubString(2) which mangled paths starting with ..\ into nonsense like \..\..\src\... + $downloadAppLine = $script:capturedOutput | Where-Object { $_ -eq 'Download appFolders:' } + $downloadAppLine | Should -Not -BeNullOrEmpty -Because "the function should output the 'Download appFolders:' header" + + # Find entries after "Download appFolders:" up to the next section header + $inDownloadSection = $false + $downloadEntries = @() + foreach ($line in $script:capturedOutput) { + if ($line -eq 'Download appFolders:') { + $inDownloadSection = $true + continue + } + if ($inDownloadSection) { + if ($line -match '^Download (test|bcpt)') { break } + $downloadEntries += $line + } + } + + # With the bug: downloadEntries would be @("- None") because the path matching fails + # With the fix: downloadEntries should contain AppB and AppC folders + $downloadEntries | Should -Not -Contain '- None' -Because "unmodified apps should be identified for download from baseline" + ($downloadEntries | Where-Object { $_ -like '*AppB*' }) | Should -Not -BeNullOrEmpty -Because "AppB was not modified and should be downloaded from baseline" + ($downloadEntries | Where-Object { $_ -like '*AppC*' }) | Should -Not -BeNullOrEmpty -Because "AppC was not modified and should be downloaded from baseline" + ($downloadEntries | Where-Object { $_ -like '*AppA*' }) | Should -BeNullOrEmpty -Because "AppA was modified and should NOT be downloaded from baseline" + } + + It 'also works when appFolders are inside the project folder (standard layout)' { + # Standard layout where apps are inside the project folder: + # baseFolder/ + # .AL-Go/settings.json + # .github/AL-Go-Settings.json + # app1/app.json + # app2/app.json + + $project = '' + $projectPath = $baseFolder + + # Create repo-level AL-Go settings + $repoSettings = @{ + fullBuildPatterns = @() + projects = @() + powerPlatformSolutionFolder = '' + useProjectDependencies = $false + incrementalBuilds = @{ + onPull_Request = $true + mode = 'modifiedApps' + } + } + New-Item -Path "$baseFolder/.github/AL-Go-Settings.json" -Value (ConvertTo-Json $repoSettings -Depth 10) -type File -Force | Out-Null + New-Item -Path "$baseFolder/.AL-Go/settings.json" -Value (ConvertTo-Json @{} -Depth 10) -type File -Force | Out-Null + + # Create two apps inside the project folder + $app1 = @{ id = '11111111-0000-0000-0000-000000000001'; name = 'App One'; publisher = 'TestPublisher'; version = '1.0.0.0'; dependencies = @() } + $app2 = @{ id = '22222222-0000-0000-0000-000000000002'; name = 'App Two'; publisher = 'TestPublisher'; version = '1.0.0.0'; dependencies = @() } + New-Item -Path "$baseFolder/app1/app.json" -Value (ConvertTo-Json $app1 -Depth 10) -type File -Force | Out-Null + New-Item -Path "$baseFolder/app2/app.json" -Value (ConvertTo-Json $app2 -Depth 10) -type File -Force | Out-Null + New-Item -Path "$baseFolder/app1/MyCodeunit.al" -Value "// modified file" -type File -Force | Out-Null + + $env:Settings = ConvertTo-Json $repoSettings -Depth 99 -Compress + + # Resolve appFolders (standard layout: .\ prefix) + $resolvedSettings = @{ + appFolders = @('.\app1', '.\app2') + testFolders = @() + bcptTestFolders = @() + } + + $buildArtifactFolder = Join-Path $projectPath ".buildartifacts" + New-Item -Path $buildArtifactFolder -ItemType Directory -Force | Out-Null + + # Only app1 is modified + $sep = [System.IO.Path]::DirectorySeparatorChar + $modifiedFiles = @("app1${sep}MyCodeunit.al") + + Mock InvokeWebRequest { + $uri = $args[0] + if (-not $uri) { $uri = $Uri } + $content = if ($uri -like '*/actions/runs/*/artifacts*') { + '{"artifacts":[]}' + } else { + '{"head_branch":"main"}' + } + return [PSCustomObject]@{ Content = $content } + } -ModuleName 'Github-Helper' + # Stub Trace-Information if not already defined (normally loaded by Invoke-AlGoAction.ps1) + if (-not (Get-Command 'Trace-Information' -ErrorAction SilentlyContinue)) { + function global:Trace-Information { param([string]$Message, $AdditionalData) } + } + $env:GITHUB_API_URL = 'https://api.github.com' + $env:GITHUB_REPOSITORY = 'test/repo' + + $script:capturedOutput = [System.Collections.ArrayList]::new() + Mock Write-Host { $null = $script:capturedOutput.Add($Object) } -ModuleName 'DetermineProjectsToBuild' + + Get-UnmodifiedAppsFromBaselineWorkflowRun ` + -token 'fake-token' ` + -settings $resolvedSettings ` + -baseFolder $baseFolder ` + -project $project ` + -baselineWorkflowRunId '12345' ` + -modifiedFiles $modifiedFiles ` + -buildArtifactFolder $buildArtifactFolder ` + -buildMode 'Default' ` + -projectPath $projectPath + + $inDownloadSection = $false + $downloadEntries = @() + foreach ($line in $script:capturedOutput) { + if ($line -eq 'Download appFolders:') { + $inDownloadSection = $true + continue + } + if ($inDownloadSection) { + if ($line -match '^Download (test|bcpt)') { break } + $downloadEntries += $line + } + } + + # app2 should be marked for download (unmodified), app1 should not + $downloadEntries | Should -Not -Contain '- None' -Because "the unmodified app2 should be identified for download" + ($downloadEntries | Where-Object { $_ -like '*app2*' }) | Should -Not -BeNullOrEmpty -Because "app2 was not modified and should be downloaded from baseline" + ($downloadEntries | Where-Object { $_ -like '*app1*' }) | Should -BeNullOrEmpty -Because "app1 was modified and should NOT be downloaded from baseline" + } + + AfterEach { + Remove-Item $baseFolder -Force -Recurse + } +}